@librechat/agents 3.6.3 → 3.6.5

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