@librechat/agents 3.6.3 → 3.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/graphs/Graph.cjs +32 -19
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/hooks/HookRegistry.cjs +7 -1
- package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
- package/dist/cjs/hooks/index.cjs +1 -1
- package/dist/cjs/main.cjs +3 -1
- package/dist/cjs/run.cjs +4 -0
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/SubagentTool.cjs +8 -3
- package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +1 -1
- package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs +399 -0
- package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs.map +1 -0
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs +168 -60
- package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
- package/dist/cjs/tools/subagent/index.cjs +1 -0
- package/dist/esm/graphs/Graph.mjs +32 -19
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/hooks/HookRegistry.mjs +7 -1
- package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
- package/dist/esm/hooks/index.mjs +1 -1
- package/dist/esm/main.mjs +3 -2
- package/dist/esm/run.mjs +4 -0
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/SubagentTool.mjs +8 -3
- package/dist/esm/tools/SubagentTool.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +1 -1
- package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs +399 -0
- package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs.map +1 -0
- package/dist/esm/tools/subagent/SubagentExecutor.mjs +168 -60
- package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
- package/dist/esm/tools/subagent/index.mjs +1 -0
- package/dist/types/graphs/Graph.d.ts +6 -3
- package/dist/types/hooks/HookRegistry.d.ts +8 -0
- package/dist/types/run.d.ts +1 -0
- package/dist/types/tools/SubagentTool.d.ts +3 -1
- package/dist/types/tools/subagent/InMemorySubagentTaskStore.d.ts +45 -0
- package/dist/types/tools/subagent/SubagentExecutor.d.ts +26 -3
- package/dist/types/tools/subagent/index.d.ts +2 -0
- package/dist/types/types/graph.d.ts +11 -4
- package/dist/types/types/index.d.ts +1 -0
- package/dist/types/types/run.d.ts +6 -0
- package/dist/types/types/subagentTasks.d.ts +140 -0
- package/package.json +1 -1
- package/src/graphs/Graph.ts +54 -30
- package/src/hooks/HookRegistry.ts +18 -0
- package/src/run.ts +4 -0
- package/src/tools/SubagentTool.ts +20 -2
- package/src/tools/subagent/InMemorySubagentTaskStore.ts +624 -0
- package/src/tools/subagent/SubagentExecutor.ts +341 -74
- package/src/tools/subagent/index.ts +2 -0
- package/src/types/graph.ts +11 -4
- package/src/types/index.ts +1 -0
- package/src/types/run.ts +6 -0
- package/src/types/subagentTasks.ts +129 -0
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import type {
|
|
3
|
+
InjectedMessage,
|
|
4
|
+
SubagentTaskBoundary,
|
|
5
|
+
SubagentTaskClaim,
|
|
6
|
+
SubagentTaskControlCommand,
|
|
7
|
+
SubagentTaskControlResult,
|
|
8
|
+
SubagentTaskProgress,
|
|
9
|
+
SubagentTaskRuntime,
|
|
10
|
+
SubagentTaskSnapshot,
|
|
11
|
+
SubagentTaskStartRequest,
|
|
12
|
+
SubagentTaskStartResult,
|
|
13
|
+
SubagentTaskStatus,
|
|
14
|
+
SubagentTaskStore,
|
|
15
|
+
SubagentUpdateEvent,
|
|
16
|
+
} from '@/types';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_COMPLETED_TTL_MS = 60 * 60 * 1000;
|
|
19
|
+
const DEFAULT_MAX_ERROR_CHARS = 4 * 1024;
|
|
20
|
+
const DEFAULT_MAX_MESSAGE_CHARS = 64 * 1024;
|
|
21
|
+
const DEFAULT_TASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
22
|
+
const DEFAULT_MAX_CONTROLS = 32;
|
|
23
|
+
const DEFAULT_MAX_RESULT_CHARS = 100_000;
|
|
24
|
+
const DEFAULT_MAX_RUNNING_PER_SCOPE = 10;
|
|
25
|
+
const DEFAULT_MAX_RUNNING_TOTAL = 100;
|
|
26
|
+
const DEFAULT_MAX_TASKS_PER_SCOPE = 200;
|
|
27
|
+
const DEFAULT_MAX_TASKS_TOTAL = 2_000;
|
|
28
|
+
|
|
29
|
+
export interface InMemorySubagentTaskStoreOptions {
|
|
30
|
+
completedTtlMs?: number;
|
|
31
|
+
maxControlMessageChars?: number;
|
|
32
|
+
maxControlsPerTask?: number;
|
|
33
|
+
maxErrorChars?: number;
|
|
34
|
+
maxResultChars?: number;
|
|
35
|
+
maxRunningPerScope?: number;
|
|
36
|
+
maxRunningTotal?: number;
|
|
37
|
+
maxTasksPerScope?: number;
|
|
38
|
+
maxTasksTotal?: number;
|
|
39
|
+
taskTimeoutMs?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type PendingControl = {
|
|
43
|
+
id: string;
|
|
44
|
+
action: 'steer' | 'queue' | 'interrupt';
|
|
45
|
+
message: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type StoredTask = {
|
|
49
|
+
id: string;
|
|
50
|
+
idempotencyKey: string;
|
|
51
|
+
requestFingerprint?: string;
|
|
52
|
+
scopeId: string;
|
|
53
|
+
subagentType: string;
|
|
54
|
+
status: SubagentTaskStatus;
|
|
55
|
+
createdAt: number;
|
|
56
|
+
updatedAt: number;
|
|
57
|
+
controller: AbortController;
|
|
58
|
+
controls: PendingControl[];
|
|
59
|
+
progressEvents: number;
|
|
60
|
+
resultClaimed: boolean;
|
|
61
|
+
acceptingControls: boolean;
|
|
62
|
+
result?: string;
|
|
63
|
+
error?: string;
|
|
64
|
+
progress?: SubagentTaskProgress;
|
|
65
|
+
expiry?: ReturnType<typeof setTimeout>;
|
|
66
|
+
timeout?: ReturnType<typeof setTimeout>;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type TaskBucket = {
|
|
70
|
+
tasks: Map<string, StoredTask>;
|
|
71
|
+
taskIdByIdempotencyKey: Map<string, string>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type ResolvedOptions = Required<InMemorySubagentTaskStoreOptions>;
|
|
75
|
+
|
|
76
|
+
function resolvePositiveInteger(
|
|
77
|
+
value: number | undefined,
|
|
78
|
+
fallback: number
|
|
79
|
+
): number {
|
|
80
|
+
return Number.isSafeInteger(value) && value != null && value > 0
|
|
81
|
+
? value
|
|
82
|
+
: fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveOptions(
|
|
86
|
+
options: InMemorySubagentTaskStoreOptions
|
|
87
|
+
): ResolvedOptions {
|
|
88
|
+
const maxTasksPerScope = resolvePositiveInteger(
|
|
89
|
+
options.maxTasksPerScope,
|
|
90
|
+
DEFAULT_MAX_TASKS_PER_SCOPE
|
|
91
|
+
);
|
|
92
|
+
const maxTasksTotal = resolvePositiveInteger(
|
|
93
|
+
options.maxTasksTotal,
|
|
94
|
+
DEFAULT_MAX_TASKS_TOTAL
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
completedTtlMs: resolvePositiveInteger(
|
|
98
|
+
options.completedTtlMs,
|
|
99
|
+
DEFAULT_COMPLETED_TTL_MS
|
|
100
|
+
),
|
|
101
|
+
maxControlMessageChars: resolvePositiveInteger(
|
|
102
|
+
options.maxControlMessageChars,
|
|
103
|
+
DEFAULT_MAX_MESSAGE_CHARS
|
|
104
|
+
),
|
|
105
|
+
maxControlsPerTask: resolvePositiveInteger(
|
|
106
|
+
options.maxControlsPerTask,
|
|
107
|
+
DEFAULT_MAX_CONTROLS
|
|
108
|
+
),
|
|
109
|
+
maxErrorChars: resolvePositiveInteger(
|
|
110
|
+
options.maxErrorChars,
|
|
111
|
+
DEFAULT_MAX_ERROR_CHARS
|
|
112
|
+
),
|
|
113
|
+
maxResultChars: resolvePositiveInteger(
|
|
114
|
+
options.maxResultChars,
|
|
115
|
+
DEFAULT_MAX_RESULT_CHARS
|
|
116
|
+
),
|
|
117
|
+
maxRunningPerScope: Math.min(
|
|
118
|
+
resolvePositiveInteger(
|
|
119
|
+
options.maxRunningPerScope,
|
|
120
|
+
DEFAULT_MAX_RUNNING_PER_SCOPE
|
|
121
|
+
),
|
|
122
|
+
maxTasksPerScope
|
|
123
|
+
),
|
|
124
|
+
maxRunningTotal: Math.min(
|
|
125
|
+
resolvePositiveInteger(
|
|
126
|
+
options.maxRunningTotal,
|
|
127
|
+
DEFAULT_MAX_RUNNING_TOTAL
|
|
128
|
+
),
|
|
129
|
+
maxTasksTotal
|
|
130
|
+
),
|
|
131
|
+
maxTasksPerScope,
|
|
132
|
+
maxTasksTotal,
|
|
133
|
+
taskTimeoutMs: resolvePositiveInteger(
|
|
134
|
+
options.taskTimeoutMs,
|
|
135
|
+
DEFAULT_TASK_TIMEOUT_MS
|
|
136
|
+
),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function toErrorMessage(error: unknown): string {
|
|
141
|
+
if (error instanceof Error && error.message.trim() !== '') {
|
|
142
|
+
return error.message;
|
|
143
|
+
}
|
|
144
|
+
return 'Detached subagent task failed.';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function truncateMiddle(value: string, maxChars: number): string {
|
|
148
|
+
if (value.length <= maxChars) {
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
const marker = '\n…[truncated]…\n';
|
|
152
|
+
const available = Math.max(0, maxChars - marker.length);
|
|
153
|
+
const head = Math.ceil(available / 2);
|
|
154
|
+
return `${value.slice(0, head)}${marker}${value.slice(
|
|
155
|
+
value.length - (available - head)
|
|
156
|
+
)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function toInjectedMessage(control: PendingControl): InjectedMessage {
|
|
160
|
+
return {
|
|
161
|
+
role: 'user',
|
|
162
|
+
content: control.message,
|
|
163
|
+
source: 'steer',
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function snapshot(task: StoredTask): SubagentTaskSnapshot {
|
|
168
|
+
return {
|
|
169
|
+
taskId: task.id,
|
|
170
|
+
subagentType: task.subagentType,
|
|
171
|
+
status: task.status,
|
|
172
|
+
createdAt: task.createdAt,
|
|
173
|
+
updatedAt: task.updatedAt,
|
|
174
|
+
resultAvailable:
|
|
175
|
+
task.status === 'completed' && task.result != null && !task.resultClaimed,
|
|
176
|
+
resultClaimed: task.resultClaimed,
|
|
177
|
+
pendingControls: task.controls.length,
|
|
178
|
+
...(task.progress == null ? {} : { progress: { ...task.progress } }),
|
|
179
|
+
...(task.error == null ? {} : { error: task.error }),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function abortReason(signal: AbortSignal): Error {
|
|
184
|
+
return signal.reason instanceof Error
|
|
185
|
+
? signal.reason
|
|
186
|
+
: new Error('Detached subagent task cancelled.');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Bounded process-local task ownership for detached subagents. Terminal tasks
|
|
191
|
+
* keep only a bounded claimable result: the child graph, checkpoint, and full
|
|
192
|
+
* transcript are released. Hosts that need later child-chat continuation may
|
|
193
|
+
* replace this store and persist the canonical messages returned by `run`.
|
|
194
|
+
* This default deliberately makes no restart or cross-replica durability
|
|
195
|
+
* claim.
|
|
196
|
+
*/
|
|
197
|
+
export class InMemorySubagentTaskStore implements SubagentTaskStore {
|
|
198
|
+
private readonly buckets = new Map<string, TaskBucket>();
|
|
199
|
+
private readonly options: ResolvedOptions;
|
|
200
|
+
private runningTasks = 0;
|
|
201
|
+
private totalTasks = 0;
|
|
202
|
+
|
|
203
|
+
constructor(options: InMemorySubagentTaskStoreOptions = {}) {
|
|
204
|
+
this.options = resolveOptions(options);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
start(request: SubagentTaskStartRequest): SubagentTaskStartResult {
|
|
208
|
+
const scopeId = request.scopeId.trim();
|
|
209
|
+
const idempotencyKey = request.idempotencyKey.trim();
|
|
210
|
+
const requestFingerprint = request.requestFingerprint?.trim();
|
|
211
|
+
if (scopeId === '' || idempotencyKey === '') {
|
|
212
|
+
throw new Error('Subagent task scope and idempotency key are required.');
|
|
213
|
+
}
|
|
214
|
+
const now = Date.now();
|
|
215
|
+
const bucket = this.getBucket(scopeId);
|
|
216
|
+
this.sweepBucket(bucket, now);
|
|
217
|
+
const existingId = bucket.taskIdByIdempotencyKey.get(idempotencyKey);
|
|
218
|
+
if (existingId != null) {
|
|
219
|
+
const existing = bucket.tasks.get(existingId);
|
|
220
|
+
if (existing != null) {
|
|
221
|
+
if (
|
|
222
|
+
requestFingerprint != null &&
|
|
223
|
+
requestFingerprint !== '' &&
|
|
224
|
+
existing.requestFingerprint != null &&
|
|
225
|
+
existing.requestFingerprint !== requestFingerprint
|
|
226
|
+
) {
|
|
227
|
+
return {
|
|
228
|
+
accepted: false,
|
|
229
|
+
reason: 'conflict',
|
|
230
|
+
task: snapshot(existing),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { accepted: true, isNew: false, task: snapshot(existing) };
|
|
234
|
+
}
|
|
235
|
+
bucket.taskIdByIdempotencyKey.delete(idempotencyKey);
|
|
236
|
+
}
|
|
237
|
+
let running = 0;
|
|
238
|
+
for (const task of bucket.tasks.values()) {
|
|
239
|
+
if (task.status === 'running') {
|
|
240
|
+
running += 1;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (
|
|
244
|
+
running >= this.options.maxRunningPerScope ||
|
|
245
|
+
this.runningTasks >= this.options.maxRunningTotal
|
|
246
|
+
) {
|
|
247
|
+
this.dropEmptyBucket(scopeId, bucket);
|
|
248
|
+
return { accepted: false, reason: 'capacity' };
|
|
249
|
+
}
|
|
250
|
+
if (!this.makeRoom(bucket) || !this.makeGlobalRoom()) {
|
|
251
|
+
this.dropEmptyBucket(scopeId, bucket);
|
|
252
|
+
return { accepted: false, reason: 'capacity' };
|
|
253
|
+
}
|
|
254
|
+
const task: StoredTask = {
|
|
255
|
+
id: nanoid(),
|
|
256
|
+
idempotencyKey,
|
|
257
|
+
...(requestFingerprint == null || requestFingerprint === ''
|
|
258
|
+
? {}
|
|
259
|
+
: { requestFingerprint }),
|
|
260
|
+
scopeId,
|
|
261
|
+
subagentType: request.subagentType,
|
|
262
|
+
status: 'running',
|
|
263
|
+
createdAt: now,
|
|
264
|
+
updatedAt: now,
|
|
265
|
+
controller: new AbortController(),
|
|
266
|
+
controls: [],
|
|
267
|
+
progressEvents: 0,
|
|
268
|
+
resultClaimed: false,
|
|
269
|
+
acceptingControls: true,
|
|
270
|
+
};
|
|
271
|
+
this.buckets.set(scopeId, bucket);
|
|
272
|
+
bucket.tasks.set(task.id, task);
|
|
273
|
+
bucket.taskIdByIdempotencyKey.set(idempotencyKey, task.id);
|
|
274
|
+
this.runningTasks += 1;
|
|
275
|
+
this.totalTasks += 1;
|
|
276
|
+
task.timeout = setTimeout(() => {
|
|
277
|
+
this.finishWithError(
|
|
278
|
+
task,
|
|
279
|
+
'error',
|
|
280
|
+
new Error('Detached subagent task timed out.')
|
|
281
|
+
);
|
|
282
|
+
}, this.options.taskTimeoutMs);
|
|
283
|
+
task.timeout.unref();
|
|
284
|
+
const runtime = this.createRuntime(task);
|
|
285
|
+
void Promise.resolve()
|
|
286
|
+
.then(() => {
|
|
287
|
+
if (task.status !== 'running' || task.controller.signal.aborted) {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
return request.run(runtime);
|
|
291
|
+
})
|
|
292
|
+
.then(
|
|
293
|
+
(result) => {
|
|
294
|
+
if (task.status !== 'running' || result == null) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (task.controller.signal.aborted) {
|
|
298
|
+
this.finishWithError(
|
|
299
|
+
task,
|
|
300
|
+
'cancelled',
|
|
301
|
+
abortReason(task.controller.signal)
|
|
302
|
+
);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
task.status = 'completed';
|
|
306
|
+
task.acceptingControls = false;
|
|
307
|
+
task.controls.length = 0;
|
|
308
|
+
task.result = truncateMiddle(
|
|
309
|
+
result.content,
|
|
310
|
+
this.options.maxResultChars
|
|
311
|
+
);
|
|
312
|
+
task.updatedAt = Date.now();
|
|
313
|
+
this.runningTasks -= 1;
|
|
314
|
+
this.scheduleExpiry(task);
|
|
315
|
+
},
|
|
316
|
+
(error: unknown) => {
|
|
317
|
+
const status = task.controller.signal.aborted ? 'cancelled' : 'error';
|
|
318
|
+
this.finishWithError(task, status, error);
|
|
319
|
+
}
|
|
320
|
+
);
|
|
321
|
+
return { accepted: true, isNew: true, task: snapshot(task) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
get(scopeId: string, taskId: string): SubagentTaskSnapshot | undefined {
|
|
325
|
+
const task = this.find(scopeId, taskId);
|
|
326
|
+
return task == null ? undefined : snapshot(task);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
list(scopeId: string): SubagentTaskSnapshot[] {
|
|
330
|
+
const bucket = this.buckets.get(scopeId.trim());
|
|
331
|
+
if (bucket == null) {
|
|
332
|
+
return [];
|
|
333
|
+
}
|
|
334
|
+
this.sweepBucket(bucket, Date.now());
|
|
335
|
+
return [...bucket.tasks.values()]
|
|
336
|
+
.sort((left, right) => left.createdAt - right.createdAt)
|
|
337
|
+
.map(snapshot);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
claim(scopeId: string, taskId: string): SubagentTaskClaim {
|
|
341
|
+
const task = this.find(scopeId, taskId);
|
|
342
|
+
if (task == null) {
|
|
343
|
+
return { status: 'not_found' };
|
|
344
|
+
}
|
|
345
|
+
if (task.status === 'running') {
|
|
346
|
+
return { status: 'running', task: snapshot(task) };
|
|
347
|
+
}
|
|
348
|
+
if (task.resultClaimed) {
|
|
349
|
+
return { status: 'claimed', task: snapshot(task) };
|
|
350
|
+
}
|
|
351
|
+
task.resultClaimed = true;
|
|
352
|
+
task.updatedAt = Date.now();
|
|
353
|
+
const taskSnapshot = snapshot(task);
|
|
354
|
+
this.scheduleExpiry(task);
|
|
355
|
+
if (task.status === 'completed') {
|
|
356
|
+
const result = task.result ?? '';
|
|
357
|
+
task.result = undefined;
|
|
358
|
+
return { status: 'completed', task: taskSnapshot, result };
|
|
359
|
+
}
|
|
360
|
+
const error = task.error ?? 'Detached subagent task did not complete.';
|
|
361
|
+
return { status: task.status, task: taskSnapshot, error };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
control(
|
|
365
|
+
scopeId: string,
|
|
366
|
+
taskId: string,
|
|
367
|
+
command: SubagentTaskControlCommand
|
|
368
|
+
): SubagentTaskControlResult {
|
|
369
|
+
const task = this.find(scopeId, taskId);
|
|
370
|
+
if (task == null) {
|
|
371
|
+
return { status: 'not_found' };
|
|
372
|
+
}
|
|
373
|
+
if (command.action === 'cancel') {
|
|
374
|
+
if (task.status !== 'running') {
|
|
375
|
+
return { status: 'not_running', task: snapshot(task) };
|
|
376
|
+
}
|
|
377
|
+
this.finishWithError(
|
|
378
|
+
task,
|
|
379
|
+
'cancelled',
|
|
380
|
+
new Error('Detached subagent task cancelled by its parent.')
|
|
381
|
+
);
|
|
382
|
+
return { status: 'cancelled', task: snapshot(task) };
|
|
383
|
+
}
|
|
384
|
+
if (task.status !== 'running' || !task.acceptingControls) {
|
|
385
|
+
return { status: 'not_running', task: snapshot(task) };
|
|
386
|
+
}
|
|
387
|
+
if (command.action === 'cancel_message') {
|
|
388
|
+
const index = task.controls.findIndex(
|
|
389
|
+
(control) => control.id === command.controlId
|
|
390
|
+
);
|
|
391
|
+
if (index < 0) {
|
|
392
|
+
return { status: 'control_not_found', task: snapshot(task) };
|
|
393
|
+
}
|
|
394
|
+
task.controls.splice(index, 1);
|
|
395
|
+
task.updatedAt = Date.now();
|
|
396
|
+
return { status: 'accepted', task: snapshot(task) };
|
|
397
|
+
}
|
|
398
|
+
const message = command.message.trim();
|
|
399
|
+
if (message === '') {
|
|
400
|
+
return { status: 'invalid', message: 'A non-empty message is required.' };
|
|
401
|
+
}
|
|
402
|
+
if (message.length > this.options.maxControlMessageChars) {
|
|
403
|
+
return {
|
|
404
|
+
status: 'invalid',
|
|
405
|
+
message: `Message exceeds ${this.options.maxControlMessageChars} characters.`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (task.controls.length >= this.options.maxControlsPerTask) {
|
|
409
|
+
return {
|
|
410
|
+
status: 'invalid',
|
|
411
|
+
message: `Task already has ${this.options.maxControlsPerTask} pending messages.`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const control: PendingControl = {
|
|
415
|
+
id: nanoid(),
|
|
416
|
+
action: command.action,
|
|
417
|
+
message,
|
|
418
|
+
};
|
|
419
|
+
task.controls.push(control);
|
|
420
|
+
task.updatedAt = Date.now();
|
|
421
|
+
return {
|
|
422
|
+
status: 'accepted',
|
|
423
|
+
task: snapshot(task),
|
|
424
|
+
controlId: control.id,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private getBucket(scopeId: string): TaskBucket {
|
|
429
|
+
let bucket = this.buckets.get(scopeId);
|
|
430
|
+
if (bucket == null) {
|
|
431
|
+
bucket = {
|
|
432
|
+
tasks: new Map(),
|
|
433
|
+
taskIdByIdempotencyKey: new Map(),
|
|
434
|
+
};
|
|
435
|
+
this.buckets.set(scopeId, bucket);
|
|
436
|
+
}
|
|
437
|
+
return bucket;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private find(scopeId: string, taskId: string): StoredTask | undefined {
|
|
441
|
+
const bucket = this.buckets.get(scopeId.trim());
|
|
442
|
+
if (bucket == null) {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
this.sweepBucket(bucket, Date.now());
|
|
446
|
+
return bucket.tasks.get(taskId);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
private makeRoom(bucket: TaskBucket): boolean {
|
|
450
|
+
if (bucket.tasks.size < this.options.maxTasksPerScope) {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
const terminal = [...bucket.tasks.values()]
|
|
454
|
+
.filter((task) => task.status !== 'running')
|
|
455
|
+
.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
456
|
+
let removeCount = bucket.tasks.size - this.options.maxTasksPerScope + 1;
|
|
457
|
+
for (const task of terminal) {
|
|
458
|
+
if (removeCount <= 0) {
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
this.removeTask(task);
|
|
462
|
+
removeCount -= 1;
|
|
463
|
+
}
|
|
464
|
+
return bucket.tasks.size < this.options.maxTasksPerScope;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private makeGlobalRoom(): boolean {
|
|
468
|
+
if (this.totalTasks < this.options.maxTasksTotal) {
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
const terminal = [...this.buckets.values()]
|
|
472
|
+
.flatMap((bucket) => [...bucket.tasks.values()])
|
|
473
|
+
.filter((task) => task.status !== 'running')
|
|
474
|
+
.sort((left, right) => left.updatedAt - right.updatedAt);
|
|
475
|
+
let removeCount = this.totalTasks - this.options.maxTasksTotal + 1;
|
|
476
|
+
for (const task of terminal) {
|
|
477
|
+
if (removeCount <= 0) {
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
this.removeTask(task);
|
|
481
|
+
removeCount -= 1;
|
|
482
|
+
}
|
|
483
|
+
return this.totalTasks < this.options.maxTasksTotal;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private sweepBucket(bucket: TaskBucket, now: number): void {
|
|
487
|
+
for (const task of bucket.tasks.values()) {
|
|
488
|
+
if (
|
|
489
|
+
task.status !== 'running' &&
|
|
490
|
+
now - task.updatedAt > this.options.completedTtlMs
|
|
491
|
+
) {
|
|
492
|
+
this.removeTask(task);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
private removeTask(task: StoredTask): void {
|
|
498
|
+
const bucket = this.buckets.get(task.scopeId);
|
|
499
|
+
if (bucket?.tasks.get(task.id) !== task) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
bucket.tasks.delete(task.id);
|
|
503
|
+
this.totalTasks -= 1;
|
|
504
|
+
if (bucket.taskIdByIdempotencyKey.get(task.idempotencyKey) === task.id) {
|
|
505
|
+
bucket.taskIdByIdempotencyKey.delete(task.idempotencyKey);
|
|
506
|
+
}
|
|
507
|
+
if (bucket.tasks.size === 0) {
|
|
508
|
+
this.buckets.delete(task.scopeId);
|
|
509
|
+
}
|
|
510
|
+
this.clearTaskExpiry(task);
|
|
511
|
+
this.clearTaskTimeout(task);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private dropEmptyBucket(scopeId: string, bucket: TaskBucket): void {
|
|
515
|
+
if (bucket.tasks.size === 0 && this.buckets.get(scopeId) === bucket) {
|
|
516
|
+
this.buckets.delete(scopeId);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private scheduleExpiry(task: StoredTask): void {
|
|
521
|
+
this.clearTaskTimeout(task);
|
|
522
|
+
this.clearTaskExpiry(task);
|
|
523
|
+
task.expiry = setTimeout(() => {
|
|
524
|
+
this.removeTask(task);
|
|
525
|
+
}, this.options.completedTtlMs);
|
|
526
|
+
task.expiry.unref();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private clearTaskExpiry(task: StoredTask): void {
|
|
530
|
+
if (task.expiry == null) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
clearTimeout(task.expiry);
|
|
534
|
+
task.expiry = undefined;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private clearTaskTimeout(task: StoredTask): void {
|
|
538
|
+
if (task.timeout == null) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
clearTimeout(task.timeout);
|
|
542
|
+
task.timeout = undefined;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private finishWithError(
|
|
546
|
+
task: StoredTask,
|
|
547
|
+
status: 'error' | 'cancelled',
|
|
548
|
+
error: unknown
|
|
549
|
+
): void {
|
|
550
|
+
if (task.status !== 'running') {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
const message = truncateMiddle(
|
|
554
|
+
toErrorMessage(error),
|
|
555
|
+
this.options.maxErrorChars
|
|
556
|
+
);
|
|
557
|
+
const resolved = new Error(message);
|
|
558
|
+
task.status = status;
|
|
559
|
+
this.runningTasks -= 1;
|
|
560
|
+
task.acceptingControls = false;
|
|
561
|
+
task.controls.length = 0;
|
|
562
|
+
task.error = message;
|
|
563
|
+
task.updatedAt = Date.now();
|
|
564
|
+
this.scheduleExpiry(task);
|
|
565
|
+
task.controller.abort(resolved);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private createRuntime(task: StoredTask): SubagentTaskRuntime {
|
|
569
|
+
const take = (
|
|
570
|
+
accept: (control: PendingControl) => boolean
|
|
571
|
+
): InjectedMessage[] => {
|
|
572
|
+
if (task.status !== 'running') {
|
|
573
|
+
return [];
|
|
574
|
+
}
|
|
575
|
+
const selected: PendingControl[] = [];
|
|
576
|
+
const retained: PendingControl[] = [];
|
|
577
|
+
for (const control of task.controls) {
|
|
578
|
+
(accept(control) ? selected : retained).push(control);
|
|
579
|
+
}
|
|
580
|
+
task.controls = retained;
|
|
581
|
+
if (selected.length > 0) {
|
|
582
|
+
task.updatedAt = Date.now();
|
|
583
|
+
}
|
|
584
|
+
return selected.map(toInjectedMessage);
|
|
585
|
+
};
|
|
586
|
+
return {
|
|
587
|
+
taskId: task.id,
|
|
588
|
+
signal: task.controller.signal,
|
|
589
|
+
shouldPreempt: (): boolean =>
|
|
590
|
+
task.status === 'running' &&
|
|
591
|
+
task.controls.some((control) => control.action === 'interrupt'),
|
|
592
|
+
drain: (boundary: SubagentTaskBoundary): InjectedMessage[] => {
|
|
593
|
+
if (boundary === 'preempt') {
|
|
594
|
+
return take((control) => control.action === 'interrupt');
|
|
595
|
+
}
|
|
596
|
+
if (boundary === 'tool') {
|
|
597
|
+
return take((control) => control.action !== 'queue');
|
|
598
|
+
}
|
|
599
|
+
return take(() => true);
|
|
600
|
+
},
|
|
601
|
+
closeTurn: (): { closed: boolean; messages: InjectedMessage[] } => {
|
|
602
|
+
const messages = take(() => true);
|
|
603
|
+
if (messages.length > 0) {
|
|
604
|
+
return { closed: false, messages };
|
|
605
|
+
}
|
|
606
|
+
task.acceptingControls = false;
|
|
607
|
+
return { closed: true, messages: [] };
|
|
608
|
+
},
|
|
609
|
+
reportProgress: (event: SubagentUpdateEvent): void => {
|
|
610
|
+
if (task.status !== 'running') {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
task.progressEvents += 1;
|
|
614
|
+
task.updatedAt = Date.now();
|
|
615
|
+
task.progress = {
|
|
616
|
+
phase: event.phase,
|
|
617
|
+
at: task.updatedAt,
|
|
618
|
+
eventCount: task.progressEvents,
|
|
619
|
+
...(event.label == null ? {} : { label: event.label }),
|
|
620
|
+
};
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|