@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.
Files changed (55) hide show
  1. package/dist/cjs/graphs/Graph.cjs +32 -19
  2. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  3. package/dist/cjs/hooks/HookRegistry.cjs +7 -1
  4. package/dist/cjs/hooks/HookRegistry.cjs.map +1 -1
  5. package/dist/cjs/hooks/index.cjs +1 -1
  6. package/dist/cjs/main.cjs +3 -1
  7. package/dist/cjs/run.cjs +4 -0
  8. package/dist/cjs/run.cjs.map +1 -1
  9. package/dist/cjs/tools/SubagentTool.cjs +8 -3
  10. package/dist/cjs/tools/SubagentTool.cjs.map +1 -1
  11. package/dist/cjs/tools/ToolNode.cjs +1 -1
  12. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs +399 -0
  13. package/dist/cjs/tools/subagent/InMemorySubagentTaskStore.cjs.map +1 -0
  14. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +168 -60
  15. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +1 -1
  16. package/dist/cjs/tools/subagent/index.cjs +1 -0
  17. package/dist/esm/graphs/Graph.mjs +32 -19
  18. package/dist/esm/graphs/Graph.mjs.map +1 -1
  19. package/dist/esm/hooks/HookRegistry.mjs +7 -1
  20. package/dist/esm/hooks/HookRegistry.mjs.map +1 -1
  21. package/dist/esm/hooks/index.mjs +1 -1
  22. package/dist/esm/main.mjs +3 -2
  23. package/dist/esm/run.mjs +4 -0
  24. package/dist/esm/run.mjs.map +1 -1
  25. package/dist/esm/tools/SubagentTool.mjs +8 -3
  26. package/dist/esm/tools/SubagentTool.mjs.map +1 -1
  27. package/dist/esm/tools/ToolNode.mjs +1 -1
  28. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs +399 -0
  29. package/dist/esm/tools/subagent/InMemorySubagentTaskStore.mjs.map +1 -0
  30. package/dist/esm/tools/subagent/SubagentExecutor.mjs +168 -60
  31. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +1 -1
  32. package/dist/esm/tools/subagent/index.mjs +1 -0
  33. package/dist/types/graphs/Graph.d.ts +6 -3
  34. package/dist/types/hooks/HookRegistry.d.ts +8 -0
  35. package/dist/types/run.d.ts +1 -0
  36. package/dist/types/tools/SubagentTool.d.ts +3 -1
  37. package/dist/types/tools/subagent/InMemorySubagentTaskStore.d.ts +45 -0
  38. package/dist/types/tools/subagent/SubagentExecutor.d.ts +26 -3
  39. package/dist/types/tools/subagent/index.d.ts +2 -0
  40. package/dist/types/types/graph.d.ts +11 -4
  41. package/dist/types/types/index.d.ts +1 -0
  42. package/dist/types/types/run.d.ts +6 -0
  43. package/dist/types/types/subagentTasks.d.ts +140 -0
  44. package/package.json +1 -1
  45. package/src/graphs/Graph.ts +54 -30
  46. package/src/hooks/HookRegistry.ts +18 -0
  47. package/src/run.ts +4 -0
  48. package/src/tools/SubagentTool.ts +20 -2
  49. package/src/tools/subagent/InMemorySubagentTaskStore.ts +624 -0
  50. package/src/tools/subagent/SubagentExecutor.ts +341 -74
  51. package/src/tools/subagent/index.ts +2 -0
  52. package/src/types/graph.ts +11 -4
  53. package/src/types/index.ts +1 -0
  54. package/src/types/run.ts +6 -0
  55. 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
+ }