@bubblebrain-ai/bubble 0.0.21 → 0.0.23

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 (65) hide show
  1. package/README.md +197 -34
  2. package/dist/agent/abort-errors.d.ts +14 -0
  3. package/dist/agent/abort-errors.js +21 -0
  4. package/dist/agent/budget-ledger.d.ts +41 -0
  5. package/dist/agent/budget-ledger.js +64 -0
  6. package/dist/agent/child-runner.d.ts +55 -0
  7. package/dist/agent/child-runner.js +312 -0
  8. package/dist/agent/internal-reminder-sanitizer.js +29 -9
  9. package/dist/agent/profiles.d.ts +8 -0
  10. package/dist/agent/profiles.js +27 -5
  11. package/dist/agent/result-integrator.d.ts +22 -0
  12. package/dist/agent/result-integrator.js +50 -0
  13. package/dist/agent/subagent-control.d.ts +31 -0
  14. package/dist/agent/subagent-control.js +27 -0
  15. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  16. package/dist/agent/subagent-scheduler.d.ts +95 -0
  17. package/dist/agent/subagent-scheduler.js +256 -0
  18. package/dist/agent/subagent-store.d.ts +41 -0
  19. package/dist/agent/subagent-store.js +149 -0
  20. package/dist/agent/subagent-summary.d.ts +30 -0
  21. package/dist/agent/subagent-summary.js +74 -0
  22. package/dist/agent/worktree.d.ts +29 -0
  23. package/dist/agent/worktree.js +73 -0
  24. package/dist/agent.d.ts +63 -5
  25. package/dist/agent.js +360 -287
  26. package/dist/approval/controller.js +9 -1
  27. package/dist/approval/tool-helper.js +2 -0
  28. package/dist/approval/types.d.ts +17 -1
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +1 -0
  33. package/dist/main.js +38 -2
  34. package/dist/model-catalog.js +6 -0
  35. package/dist/network/errors.d.ts +28 -0
  36. package/dist/network/errors.js +24 -0
  37. package/dist/orchestrator/default-hooks.js +5 -1
  38. package/dist/prompt/compose.js +3 -0
  39. package/dist/prompt/delegation.d.ts +14 -0
  40. package/dist/prompt/delegation.js +64 -0
  41. package/dist/prompt/task-reminders.d.ts +5 -1
  42. package/dist/prompt/task-reminders.js +10 -2
  43. package/dist/provider-anthropic.js +23 -0
  44. package/dist/provider-transform.js +14 -0
  45. package/dist/provider.js +23 -3
  46. package/dist/slash-commands/commands.js +29 -2
  47. package/dist/slash-commands/types.d.ts +2 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/child-tools.d.ts +31 -0
  51. package/dist/tools/child-tools.js +106 -0
  52. package/dist/tools/index.js +1 -1
  53. package/dist/tui/run.d.ts +17 -1
  54. package/dist/tui/run.js +155 -10
  55. package/dist/tui/session-picker-data.d.ts +18 -0
  56. package/dist/tui/session-picker-data.js +21 -0
  57. package/dist/tui/trace-groups.js +41 -5
  58. package/dist/tui/wordmark.d.ts +2 -0
  59. package/dist/tui/wordmark.js +31 -4
  60. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  61. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  62. package/dist/types.d.ts +17 -0
  63. package/dist/update/index.d.ts +18 -4
  64. package/dist/update/index.js +41 -19
  65. package/package.json +1 -1
package/dist/agent.js CHANGED
@@ -16,15 +16,21 @@ import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode }
16
16
  import { HookBus } from "./orchestrator/hooks.js";
17
17
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
18
18
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
19
- import { resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
19
+ import { mergeAgentCategories, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
20
20
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
21
- import { composeAbortSignals } from "./agent/budget-ledger.js";
22
- import { assignAgentNickname, builtinAgentProfiles, mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
21
+ import { composeAbortSignals, computeChildTokenCap, DEFAULT_CHILD_TOKEN_CAP, PARENT_POOL_RESERVE_RATIO } from "./agent/budget-ledger.js";
22
+ import { assignAgentNickname, builtinAgentProfiles, validateAgentProfileTools } from "./agent/profiles.js";
23
23
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
24
+ import { SubagentStore } from "./agent/subagent-store.js";
25
+ import { SubagentScheduler } from "./agent/subagent-scheduler.js";
26
+ import { ChildRunner, classifySubagentAbortReason } from "./agent/child-runner.js";
27
+ import { ResultIntegrator } from "./agent/result-integrator.js";
28
+ import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./agent/abort-errors.js";
29
+ import { createSubagentWorktree, finalizeSubagentWorktree } from "./agent/worktree.js";
30
+ import { createWorktreeChildTools } from "./tools/child-tools.js";
24
31
  import { isHiddenToolResult } from "./agent/discovery-barrier.js";
25
32
  import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
26
33
  import { buildSystemPrompt } from "./system-prompt.js";
27
- import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "./provider-artifacts.js";
28
34
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
29
35
  import { buildToolPromptOptions } from "./tools/prompt-metadata.js";
30
36
  import { stopAutoServersForSession } from "./tools/server-manager.js";
@@ -39,7 +45,7 @@ const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
39
45
  const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained no user-visible assistant content and no tool calls. " +
40
46
  "Respond now with a concise, user-visible answer, or call an available tool if more work is required. " +
41
47
  "Do not put the final answer only in hidden reasoning.";
42
- const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
48
+ export { AgentAbortError, SubagentAbortError } from "./agent/abort-errors.js";
43
49
  // Model-facing interruption boundary. Persisted into the transcript so the
44
50
  // next turn sees an explicit stop instead of a dangling request — but it must
45
51
  // never render in the UI as if the assistant said it (the TUIs strip it and
@@ -77,12 +83,6 @@ function agentEventFromHookProgress(event) {
77
83
  error: event.error ?? "Hook failed.",
78
84
  };
79
85
  }
80
- export class AgentAbortError extends Error {
81
- constructor(message = "Agent run cancelled.") {
82
- super(message);
83
- this.name = "AgentAbortError";
84
- }
85
- }
86
86
  export class Agent {
87
87
  messages = [];
88
88
  provider;
@@ -114,7 +114,12 @@ export class Agent {
114
114
  fileStateTracker;
115
115
  agentCategories;
116
116
  providerFactory;
117
- subagentThreads = new Map();
117
+ subagentStore;
118
+ subagentScheduler;
119
+ childRunner;
120
+ resultIntegrator = new ResultIntegrator();
121
+ subagentsConfig;
122
+ rateLimitPolicy;
118
123
  pendingSubagentUpdates = [];
119
124
  lastInputTokens = null;
120
125
  lastAnchorMessageCount = null;
@@ -144,6 +149,46 @@ export class Agent {
144
149
  this.fileStateTracker = options.fileStateTracker;
145
150
  this.agentCategories = options.agentCategories ?? {};
146
151
  this.providerFactory = options.providerFactory;
152
+ this.subagentsConfig = options.subagents ?? {};
153
+ this.rateLimitPolicy = options.rateLimitPolicy;
154
+ // Children persist next to the session file so a later process can
155
+ // resume them via send_input (design §7). Child agents themselves
156
+ // (agentRole "subagent") never persist children — no recursion exists.
157
+ const persistDir = this.agentRole === "parent"
158
+ ? this.subagentsConfig.persistDir
159
+ ?? (this.sessionID?.endsWith(".jsonl") ? this.sessionID.replace(/\.jsonl$/, ".subagents") : undefined)
160
+ : undefined;
161
+ this.subagentStore = new SubagentStore(persistDir);
162
+ this.subagentStore.loadPersisted();
163
+ this.subagentScheduler = new SubagentScheduler({
164
+ maxActiveSubagents: this.subagentsConfig.maxActiveSubagents,
165
+ launchBurst: this.subagentsConfig.launchBurst,
166
+ launchIntervalMs: this.subagentsConfig.launchIntervalMs,
167
+ rateLimitMaxAttempts: this.subagentsConfig.rateLimitMaxAttempts,
168
+ rateLimitBackoffMs: this.subagentsConfig.rateLimitBackoffMs,
169
+ getCategoryLimit: (category) => mergeAgentCategories(this.agentCategories)[category]?.maxConcurrent,
170
+ });
171
+ this.childRunner = new ChildRunner({
172
+ allTools: () => [...this.tools.values()],
173
+ budgetLedger: () => this.budgetLedger,
174
+ emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
175
+ runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
176
+ finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
177
+ createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
178
+ notifyWaiters: (record) => this.subagentStore.notifyWaiters(record),
179
+ onFinal: (record, options) => {
180
+ if (record.worktree) {
181
+ // Inspect and clean up the worktree: unchanged → removed; changed →
182
+ // kept for the parent to review, with a diff stat in the handoff (§8).
183
+ finalizeSubagentWorktree(record.worktree);
184
+ if (record.worktree.changed) {
185
+ record.toolNotes.push(`worktree: changes left in ${record.worktree.path} — review the diff before applying`);
186
+ }
187
+ }
188
+ this.subagentStore.persist(record);
189
+ this.maybeEnqueueIngestion(record, options);
190
+ },
191
+ });
147
192
  if (options.systemPrompt) {
148
193
  this.messages.push({ role: "system", content: options.systemPrompt });
149
194
  }
@@ -195,6 +240,10 @@ export class Agent {
195
240
  this.injectSystemReminder(`[Hook ${result.eventName}] ${context}`);
196
241
  }
197
242
  }
243
+ /** Whether a tool is registered on this agent (e.g. delegation tools on parents). */
244
+ hasToolAvailable(name) {
245
+ return this.tools.has(name);
246
+ }
198
247
  /** Unlock a list of deferred tools so they're included in subsequent turns. */
199
248
  unlockDeferredTools(names) {
200
249
  for (const n of names) {
@@ -432,27 +481,29 @@ export class Agent {
432
481
  this.setTodos([]);
433
482
  yield emit({ type: "todos_updated", todos: [] });
434
483
  }
435
- const promptHook = await this.runExternalHook({
436
- eventName: "UserPromptSubmit",
437
- cwd,
438
- runId,
439
- target: typeof userInput === "string" ? userInput : "content_parts",
440
- payload: normalizeHookInput(userInput),
441
- fullPayload: { prompt: userInput },
442
- }, abortSignal);
443
- for (const event of promptHook.events)
444
- yield emit(event);
445
- if (promptHook.result.decision === "deny") {
446
- const message = promptHook.result.reason
447
- ?? `Prompt blocked by hook ${promptHook.result.sourceHookId ?? "<unknown>"}.`;
448
- yield emit({ type: "turn_start" });
449
- yield emit({ type: "text_delta", content: message });
450
- yield emit({ type: "turn_end", willContinue: false });
451
- yield emit({ type: "agent_end" });
452
- return;
484
+ if (!options.resumeWithoutInput) {
485
+ const promptHook = await this.runExternalHook({
486
+ eventName: "UserPromptSubmit",
487
+ cwd,
488
+ runId,
489
+ target: typeof userInput === "string" ? userInput : "content_parts",
490
+ payload: normalizeHookInput(userInput),
491
+ fullPayload: { prompt: userInput },
492
+ }, abortSignal);
493
+ for (const event of promptHook.events)
494
+ yield emit(event);
495
+ if (promptHook.result.decision === "deny") {
496
+ const message = promptHook.result.reason
497
+ ?? `Prompt blocked by hook ${promptHook.result.sourceHookId ?? "<unknown>"}.`;
498
+ yield emit({ type: "turn_start" });
499
+ yield emit({ type: "text_delta", content: message });
500
+ yield emit({ type: "turn_end", willContinue: false });
501
+ yield emit({ type: "agent_end" });
502
+ return;
503
+ }
504
+ this.injectHookModelContext(promptHook.result);
505
+ this.appendMessage({ role: "user", content: userInput });
453
506
  }
454
- this.injectHookModelContext(promptHook.result);
455
- this.appendMessage({ role: "user", content: userInput });
456
507
  await hookBus.runBeforeTurn({
457
508
  agent: this,
458
509
  cwd,
@@ -479,6 +530,9 @@ export class Agent {
479
530
  while (true) {
480
531
  throwIfAborted(abortSignal);
481
532
  flushGovernorReminders();
533
+ // Background child completions surface before the next inference turn
534
+ // without requiring a wait_agent call (design §5).
535
+ this.flushSubagentIngestions();
482
536
  for (const update of this.drainSubagentToolUpdates())
483
537
  yield emit(update);
484
538
  for (const event of await applyPendingInputs())
@@ -593,6 +647,7 @@ export class Agent {
593
647
  temperature: this.temperature,
594
648
  thinkingLevel: this.thinkingLevel,
595
649
  abortSignal,
650
+ rateLimitPolicy: this.rateLimitPolicy,
596
651
  });
597
652
  for await (const chunk of stream) {
598
653
  throwIfAborted(abortSignal);
@@ -842,6 +897,19 @@ export class Agent {
842
897
  }
843
898
  let tc = parsedCalls[index];
844
899
  let blockedResult;
900
+ // agent_team must be the only tool call in its response (design
901
+ // §1.2): concurrent calls race the scheduler and the aggregated
902
+ // reply; the rejection teaches the model how to re-issue.
903
+ if (tc.name === "agent_team" && parsedCalls.length > 1) {
904
+ blockedResult = {
905
+ content: [
906
+ "agent_team must be the only tool call in your response.",
907
+ "Re-issue agent_team alone — one call, nothing else in the same message — and run any other tools or additional teams sequentially after it returns.",
908
+ ].join(" "),
909
+ isError: true,
910
+ status: "blocked",
911
+ };
912
+ }
845
913
  await hookBus.runBeforeToolCall({
846
914
  agent: this,
847
915
  cwd,
@@ -1330,12 +1398,20 @@ export class Agent {
1330
1398
  nickname: options.nickname,
1331
1399
  route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
1332
1400
  });
1333
- await this.runSubagentThread(record, input, cwd, {
1334
- approval: options.approval ?? options.profile.approval,
1401
+ this.subagentStore.set(record);
1402
+ const approval = options.approval ?? options.profile.approval;
1403
+ const admissionError = this.admitSubagentProfile(record, approval);
1404
+ if (admissionError) {
1405
+ this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
1406
+ return subagentResultFromThread(record);
1407
+ }
1408
+ record.promise = this.dispatchSubagentRun(record, input, cwd, {
1409
+ approval,
1335
1410
  abortSignal: options.abortSignal,
1336
1411
  forkContext: options.forkContext,
1337
1412
  directEmit: options.emitUpdate,
1338
1413
  });
1414
+ await record.promise;
1339
1415
  return subagentResultFromThread(record);
1340
1416
  }
1341
1417
  async spawnSubAgent(input, cwd, options) {
@@ -1346,24 +1422,35 @@ export class Agent {
1346
1422
  parentToolName: "spawn_agent",
1347
1423
  route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
1348
1424
  });
1349
- this.subagentThreads.set(record.agentId, record);
1425
+ this.subagentStore.set(record);
1426
+ const approval = options.approval ?? record.profile.approval;
1427
+ // Admission validation runs before queueing (design §4.2): a request that
1428
+ // would block never consumes a queue slot.
1429
+ const admissionError = this.admitSubagentProfile(record, approval);
1430
+ if (admissionError) {
1431
+ this.finalizeSubagentBlocked(record, admissionError, { queueUpdates: true });
1432
+ return this.snapshotSubagent(record);
1433
+ }
1350
1434
  this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
1351
- record.promise = this.runSubagentThread(record, input, cwd, {
1352
- approval: options.approval ?? record.profile.approval,
1435
+ record.promise = this.dispatchSubagentRun(record, input, cwd, {
1436
+ approval,
1353
1437
  abortSignal: options.abortSignal,
1354
1438
  forkContext: options.forkContext,
1355
1439
  queueUpdates: true,
1356
1440
  });
1357
- void record.promise.finally(() => this.notifySubagentWaiters(record));
1358
- return snapshotSubagentThread(record);
1441
+ void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1442
+ return this.snapshotSubagent(record);
1359
1443
  }
1360
1444
  async waitSubAgents(options = {}) {
1361
1445
  const targets = this.resolveSubagentTargets(options.agentIds);
1362
1446
  if (targets.length === 0)
1363
1447
  return [];
1364
1448
  const completed = targets.filter((record) => isFinalSubagentStatus(record.status));
1365
- if (completed.length > 0)
1366
- return completed.map(snapshotSubagentThread);
1449
+ if (completed.length > 0) {
1450
+ for (const record of completed)
1451
+ this.subagentStore.markDelivered(record.agentId);
1452
+ return completed.map((record) => this.snapshotSubagent(record));
1453
+ }
1367
1454
  const timeoutMs = normalizeWaitTimeout(options.timeoutMs);
1368
1455
  let waiter;
1369
1456
  await Promise.race([
@@ -1382,10 +1469,12 @@ export class Agent {
1382
1469
  new Promise((resolve) => setTimeout(resolve, timeoutMs)),
1383
1470
  ]);
1384
1471
  const finished = targets.filter((record) => isFinalSubagentStatus(record.status));
1385
- return (finished.length > 0 ? finished : targets).map(snapshotSubagentThread);
1472
+ for (const record of finished)
1473
+ this.subagentStore.markDelivered(record.agentId);
1474
+ return (finished.length > 0 ? finished : targets).map((record) => this.snapshotSubagent(record));
1386
1475
  }
1387
1476
  async sendSubAgentInput(agentId, input, cwd, options = {}) {
1388
- const record = this.subagentThreads.get(agentId);
1477
+ const record = this.subagentStore.get(agentId);
1389
1478
  if (!record) {
1390
1479
  throw new Error(`Unknown subagent: ${agentId}`);
1391
1480
  }
@@ -1393,7 +1482,7 @@ export class Agent {
1393
1482
  if (!options.interrupt) {
1394
1483
  throw new Error(`Subagent ${agentId} is still running. Call wait_agent first or pass interrupt:true.`);
1395
1484
  }
1396
- record.abortController.abort(new AgentAbortError(`Subagent ${agentId} interrupted.`));
1485
+ record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} interrupted.`, "interrupt"));
1397
1486
  await record.promise?.catch(() => undefined);
1398
1487
  record.abortController = new AbortController();
1399
1488
  }
@@ -1407,33 +1496,210 @@ export class Agent {
1407
1496
  record.toolNotes = [];
1408
1497
  record.usage = undefined;
1409
1498
  record.error = undefined;
1499
+ record.finalReason = undefined;
1500
+ record.deliveredAt = undefined;
1410
1501
  record.updatedAt = Date.now();
1411
- record.promise = this.runSubagentThread(record, input, cwd, {
1502
+ // A send_input restart is a launch like any other: it goes through the
1503
+ // scheduler's dispatch point and is subject to the same admission limits
1504
+ // (design §4.1) — batch-resuming team members cannot bypass concurrency caps.
1505
+ record.promise = this.dispatchSubagentRun(record, input, cwd, {
1412
1506
  approval: record.profile.approval,
1413
1507
  abortSignal: options.abortSignal,
1414
1508
  queueUpdates: true,
1415
1509
  reuseAgent: true,
1416
1510
  });
1417
- void record.promise.finally(() => this.notifySubagentWaiters(record));
1418
- return snapshotSubagentThread(record);
1511
+ void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1512
+ return this.snapshotSubagent(record);
1419
1513
  }
1420
1514
  async closeSubAgent(agentId) {
1421
- const record = this.subagentThreads.get(agentId);
1515
+ const record = this.subagentStore.get(agentId);
1422
1516
  if (!record) {
1423
1517
  throw new Error(`Unknown subagent: ${agentId}`);
1424
1518
  }
1425
1519
  if (!isFinalSubagentStatus(record.status)) {
1426
- record.abortController.abort(new AgentAbortError(`Subagent ${agentId} closed.`));
1520
+ record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} closed.`, "user_close"));
1427
1521
  await record.promise?.catch(() => undefined);
1428
1522
  }
1429
1523
  record.status = "closed";
1524
+ record.finalReason = record.finalReason ?? "cancelled_user";
1430
1525
  record.updatedAt = Date.now();
1431
1526
  this.queueSubagentUpdate(record, "cancelled", undefined, `${record.nickname} closed`);
1432
- this.notifySubagentWaiters(record);
1433
- return snapshotSubagentThread(record);
1527
+ this.subagentStore.persist(record);
1528
+ this.subagentStore.notifyWaiters(record);
1529
+ return this.snapshotSubagent(record);
1434
1530
  }
1435
1531
  listSubAgents() {
1436
- return [...this.subagentThreads.values()].map(snapshotSubagentThread);
1532
+ return this.subagentStore.values().map((record) => this.snapshotSubagent(record));
1533
+ }
1534
+ /**
1535
+ * Homogeneous map fan-out (design §1.2): one profile, one template, N items.
1536
+ * Every member goes through the same admission and the same scheduler
1537
+ * dispatch as spawn_agent; the tool blocks until all members are final.
1538
+ */
1539
+ async runAgentTeam(cwd, options) {
1540
+ // Team budget pre-check (§6): items × per-member cap must fit in what
1541
+ // remains of a limited pool after the parent's reserve. On limit-free
1542
+ // hosts the only bound is the items cap enforced by the tool.
1543
+ const limit = this.budgetLedger?.poolLimit;
1544
+ if (this.budgetLedger && limit !== undefined) {
1545
+ const reserve = Math.floor(limit * PARENT_POOL_RESERVE_RATIO);
1546
+ const available = Math.max(0, (this.budgetLedger.remaining() ?? 0) - reserve);
1547
+ const memberCap = Math.min(this.subagentsConfig.childTokenCap ?? DEFAULT_CHILD_TOKEN_CAP, options.profile.maxTokens ?? Number.POSITIVE_INFINITY);
1548
+ const affordable = Math.floor(available / memberCap);
1549
+ if (options.items.length > affordable) {
1550
+ throw new Error([
1551
+ `agent_team rejected: the remaining token budget affords at most ${affordable} member${affordable === 1 ? "" : "s"}`,
1552
+ `but ${options.items.length} were requested. Reduce items or run smaller batches sequentially.`,
1553
+ ].join(" "));
1554
+ }
1555
+ }
1556
+ const approval = options.approval ?? options.profile.approval;
1557
+ const route = this.resolveRouteForSubagent(options.profile, options.category);
1558
+ const records = options.items.map((item) => this.createSubagentThreadRecord({
1559
+ profile: options.profile,
1560
+ task: options.promptTemplate.split("{{item}}").join(item),
1561
+ parentToolCallId: options.parentToolCallId,
1562
+ parentToolName: "agent_team",
1563
+ route,
1564
+ }));
1565
+ const promises = records.map((record) => {
1566
+ this.subagentStore.set(record);
1567
+ const admissionError = this.admitSubagentProfile(record, approval);
1568
+ if (admissionError) {
1569
+ this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
1570
+ return Promise.resolve();
1571
+ }
1572
+ // Member events flow through the team tool's own emitUpdate
1573
+ // (directEmit): while a foreground tool runs, the parent loop blocks in
1574
+ // updateQueue.wait and the queued channel is not drained until the tool
1575
+ // settles — queueUpdates would freeze the TUI for the whole team (§1.2).
1576
+ record.promise = this.dispatchSubagentRun(record, record.task, cwd, {
1577
+ approval,
1578
+ abortSignal: options.abortSignal,
1579
+ directEmit: options.emitUpdate,
1580
+ });
1581
+ void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1582
+ return record.promise;
1583
+ });
1584
+ await Promise.all(promises);
1585
+ // The aggregated reply carries every member's full summary.
1586
+ for (const record of records)
1587
+ this.subagentStore.markDelivered(record.agentId);
1588
+ return records.map((record) => this.snapshotSubagent(record));
1589
+ }
1590
+ /** Marks a child's full summary as delivered to parent context (design §3.3). */
1591
+ markSubagentDelivered(agentId) {
1592
+ this.subagentStore.markDelivered(agentId);
1593
+ }
1594
+ snapshotSubagent(record) {
1595
+ const snapshot = snapshotSubagentThread(record);
1596
+ if (record.status === "queued") {
1597
+ const queuePosition = this.subagentScheduler.queuePosition(record.agentId);
1598
+ if (queuePosition !== undefined)
1599
+ return { ...snapshot, queuePosition };
1600
+ }
1601
+ return snapshot;
1602
+ }
1603
+ /** Returns the blocking diagnostic message when the profile cannot run, else undefined. */
1604
+ admitSubagentProfile(record, approval) {
1605
+ const diagnostics = validateAgentProfileTools([...this.tools.values()], record.profile, approval);
1606
+ const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1607
+ if (blocking.length === 0)
1608
+ return undefined;
1609
+ return blocking.map((diagnostic) => diagnostic.message).join("\n");
1610
+ }
1611
+ /**
1612
+ * Background children (queueUpdates) get their results ingested before the
1613
+ * parent's next inference turn (design §5); foreground children (team,
1614
+ * legacy task) deliver through their tool result instead.
1615
+ */
1616
+ maybeEnqueueIngestion(record, options) {
1617
+ if (options.queueUpdates) {
1618
+ this.resultIntegrator.enqueue(record.agentId);
1619
+ }
1620
+ }
1621
+ flushSubagentIngestions() {
1622
+ if (!this.resultIntegrator.hasPending())
1623
+ return;
1624
+ for (const notice of this.resultIntegrator.drainNotices(this.subagentStore)) {
1625
+ this.injectSystemReminder(notice);
1626
+ }
1627
+ }
1628
+ finalizeSubagentBlocked(record, error, emitOptions) {
1629
+ record.status = "blocked";
1630
+ record.finalReason = "blocked";
1631
+ record.error = error;
1632
+ record.updatedAt = Date.now();
1633
+ this.emitSubagentLifecycle(record, emitOptions, "blocked", undefined, error);
1634
+ this.subagentStore.persist(record);
1635
+ this.subagentStore.notifyWaiters(record);
1636
+ }
1637
+ dispatchSubagentRun(record, input, cwd, options) {
1638
+ record.status = "queued";
1639
+ record.updatedAt = Date.now();
1640
+ record.tokenCap = computeChildTokenCap({
1641
+ ledger: this.budgetLedger,
1642
+ subAgentId: record.agentId,
1643
+ activeChildren: this.subagentScheduler.activeCount(),
1644
+ configCap: this.subagentsConfig.childTokenCap,
1645
+ profileMaxTokens: record.profile.maxTokens,
1646
+ });
1647
+ const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
1648
+ return this.subagentScheduler.dispatch({
1649
+ agentId: record.agentId,
1650
+ category: record.category,
1651
+ signal: queueSignal,
1652
+ run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
1653
+ onCancelledWhileQueued: (reason) => {
1654
+ record.status = "cancelled";
1655
+ record.finalReason = classifySubagentAbortReason(reason, options.abortSignal, this.budgetLedger);
1656
+ record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
1657
+ record.updatedAt = Date.now();
1658
+ // The run never started, so no SubagentStart fired and no SubagentStop follows.
1659
+ this.emitSubagentLifecycle(record, options, "cancelled", undefined, record.error);
1660
+ this.subagentStore.persist(record);
1661
+ this.subagentStore.notifyWaiters(record);
1662
+ this.maybeEnqueueIngestion(record, options);
1663
+ },
1664
+ onRateLimitExhausted: (attempts) => {
1665
+ record.status = "failed";
1666
+ record.finalReason = "rate_limited_exhausted";
1667
+ record.error = `Provider rate limit persisted after ${attempts} attempts.`;
1668
+ record.updatedAt = Date.now();
1669
+ void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
1670
+ this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
1671
+ this.subagentStore.persist(record);
1672
+ this.subagentStore.notifyWaiters(record);
1673
+ this.maybeEnqueueIngestion(record, options);
1674
+ },
1675
+ });
1676
+ }
1677
+ emitSubagentLifecycle(record, options, status, event, message) {
1678
+ const update = this.buildSubagentUpdate(record, status, event, message);
1679
+ options.directEmit?.(update);
1680
+ if (options.queueUpdates) {
1681
+ this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
1682
+ }
1683
+ }
1684
+ async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
1685
+ try {
1686
+ await this.runExternalHook({
1687
+ eventName,
1688
+ cwd,
1689
+ runId: record.runId,
1690
+ target: record.profile.name,
1691
+ payload: {
1692
+ agentId: record.agentId,
1693
+ nickname: record.nickname,
1694
+ profile: record.profile.name,
1695
+ status,
1696
+ error,
1697
+ },
1698
+ }, abortSignal);
1699
+ }
1700
+ catch {
1701
+ // Subagent lifecycle hooks are observe-only; never fail the subagent.
1702
+ }
1437
1703
  }
1438
1704
  resolveRouteForSubagent(profile, category) {
1439
1705
  const parentRoute = {
@@ -1482,169 +1748,23 @@ export class Agent {
1482
1748
  waiters: new Set(),
1483
1749
  };
1484
1750
  }
1485
- async runSubagentThread(record, input, cwd, options) {
1486
- const emit = (status, event, message) => {
1487
- const update = this.buildSubagentUpdate(record, status, event, message);
1488
- options.directEmit?.(update);
1489
- if (options.queueUpdates) {
1490
- this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
1491
- }
1492
- };
1493
- const runSubagentLifecycleHook = async (eventName, status, error) => {
1494
- try {
1495
- await this.runExternalHook({
1496
- eventName,
1497
- cwd,
1498
- runId: record.runId,
1499
- target: record.profile.name,
1500
- payload: {
1501
- agentId: record.agentId,
1502
- nickname: record.nickname,
1503
- profile: record.profile.name,
1504
- status,
1505
- error,
1506
- },
1507
- }, options.abortSignal);
1508
- }
1509
- catch {
1510
- // Subagent lifecycle hooks are observe-only; never fail the subagent.
1511
- }
1512
- };
1513
- const allTools = [...this.tools.values()];
1514
- const diagnostics = validateAgentProfileTools(allTools, record.profile, options.approval);
1515
- const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
1516
- for (const diagnostic of diagnostics.filter((item) => item.severity === "warning")) {
1517
- record.toolNotes.push(`profile: ${diagnostic.message}`);
1518
- }
1519
- if (blockingDiagnostics.length > 0) {
1520
- record.status = "blocked";
1521
- record.error = blockingDiagnostics.map((diagnostic) => diagnostic.message).join("\n");
1522
- record.updatedAt = Date.now();
1523
- await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1524
- emit("blocked", undefined, record.error);
1525
- this.notifySubagentWaiters(record);
1526
- return;
1527
- }
1528
- const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
1529
- let subAgent;
1530
- try {
1531
- subAgent = options.reuseAgent && record.agent
1532
- ? record.agent
1533
- : await this.createSubAgentInstance(record, tools, cwd, options.forkContext);
1534
- }
1535
- catch (error) {
1536
- record.status = "blocked";
1537
- record.error = error?.message || String(error);
1538
- record.updatedAt = Date.now();
1539
- await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1540
- emit("blocked", undefined, record.error);
1541
- this.notifySubagentWaiters(record);
1542
- return;
1543
- }
1544
- record.agent = subAgent;
1545
- record.status = "running";
1546
- record.updatedAt = Date.now();
1547
- await runSubagentLifecycleHook("SubagentStart", record.status);
1548
- emit("running", undefined, `Running ${record.nickname} (${record.profile.name})...`);
1549
- let turnSummaryBuffer = "";
1550
- let turnHadToolCall = false;
1551
- let executedAnyTool = false;
1552
- try {
1553
- const childAbortSignal = composeAbortSignals([
1554
- options.abortSignal,
1555
- record.abortController.signal,
1556
- ]);
1557
- for await (const event of subAgent.run(input, cwd, { abortSignal: childAbortSignal })) {
1558
- if (event.type === "text_delta") {
1559
- turnSummaryBuffer += event.content;
1560
- }
1561
- if (event.type === "tool_call_start"
1562
- || event.type === "tool_call_delta"
1563
- || event.type === "tool_call_end"
1564
- || event.type === "tool_start") {
1565
- turnHadToolCall = true;
1566
- }
1567
- if (event.type === "tool_end") {
1568
- executedAnyTool = true;
1569
- record.toolNotes.push(`${event.name}: ${summarizeSubagentToolEnd(event)}`);
1570
- }
1571
- if (event.type === "turn_end" && event.usage) {
1572
- record.usage = mergeUsage(record.usage, event.usage);
1573
- }
1574
- if (event.type === "turn_end") {
1575
- const turnSummary = stripProviderProtocolArtifacts(turnSummaryBuffer).trim();
1576
- if (!turnHadToolCall && turnSummary) {
1577
- // Only the latest tool-free assistant turn is a candidate for the summary;
1578
- // earlier ones are intermediate "I'll do X next" reasoning, not the final answer.
1579
- record.summary = turnSummary;
1580
- }
1581
- turnSummaryBuffer = "";
1582
- turnHadToolCall = false;
1583
- }
1584
- record.updatedAt = Date.now();
1585
- emit("running", event);
1586
- }
1587
- }
1588
- catch (error) {
1589
- const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
1590
- record.status = cancelled ? "cancelled" : "failed";
1591
- record.summary = sanitizeSubagentSummary(record.summary);
1592
- record.error = error?.message || String(error);
1593
- record.updatedAt = Date.now();
1594
- await runSubagentLifecycleHook("SubagentStop", record.status, record.error);
1595
- emit(record.status, undefined, record.error);
1596
- this.notifySubagentWaiters(record);
1597
- return;
1598
- }
1599
- record.summary = sanitizeSubagentSummary(record.summary);
1600
- if (needsExplicitFinalSummary(record, executedAnyTool)) {
1601
- await this.runSubagentFinalSummaryTurn(record, subAgent, cwd, options.abortSignal, emit);
1602
- }
1603
- record.status = "completed";
1604
- record.summary = sanitizeSubagentSummary(record.summary);
1605
- record.updatedAt = Date.now();
1606
- await runSubagentLifecycleHook("SubagentStop", record.status);
1607
- emit("completed", undefined, record.summary || `${record.nickname} completed`);
1608
- this.notifySubagentWaiters(record);
1609
- }
1610
- async runSubagentFinalSummaryTurn(record, subAgent, cwd, abortSignal, emit) {
1611
- const prompt = [
1612
- "Produce the final subagent summary now.",
1613
- "Do not call tools. Do not announce next steps or plans.",
1614
- "Use the evidence already gathered in this child thread.",
1615
- "Return concise findings with concrete file paths and explicit uncertainty.",
1616
- "Your entire response will be returned to the parent as the subagent's answer.",
1617
- ].join("\n");
1618
- subAgent.injectSystemReminder([
1619
- "Subagent final-summary mode is active.",
1620
- "Do not call tools. Do not announce next steps.",
1621
- "Use only the evidence already gathered in this child thread.",
1622
- "Return the final concise summary as your complete response.",
1623
- ].join("\n"));
1624
- let finalBuffer = "";
1625
- let finalHadToolCall = false;
1626
- const finalAbortSignal = composeAbortSignals([abortSignal, record.abortController.signal]);
1627
- for await (const event of subAgent.run(prompt, cwd, { abortSignal: finalAbortSignal })) {
1628
- if (event.type === "text_delta") {
1629
- finalBuffer += event.content;
1630
- }
1631
- if (event.type === "tool_call_start"
1632
- || event.type === "tool_call_delta"
1633
- || event.type === "tool_call_end"
1634
- || event.type === "tool_start") {
1635
- finalHadToolCall = true;
1636
- }
1637
- if (event.type === "turn_end" && event.usage) {
1638
- record.usage = mergeUsage(record.usage, event.usage);
1639
- }
1640
- emit("running", event);
1641
- }
1642
- const finalSummary = sanitizeSubagentSummary(finalBuffer);
1643
- if (!finalHadToolCall && finalSummary) {
1644
- record.summary = finalSummary;
1645
- }
1751
+ runSubagentThread(record, input, cwd, options) {
1752
+ return this.childRunner.run(record, input, cwd, options);
1646
1753
  }
1647
1754
  async createSubAgentInstance(record, tools, cwd, forkContext) {
1755
+ let childCwd = cwd;
1756
+ let childMode = "plan";
1757
+ if (record.profile.mode === "write_worktree") {
1758
+ // Write children work in a runtime-allocated worktree with fresh tool
1759
+ // instances bound to it (design §8): the parent tree is never touched,
1760
+ // and the tools' own workspace fence enforces containment in code.
1761
+ if (!record.worktree) {
1762
+ record.worktree = createSubagentWorktree(cwd, record.agentId);
1763
+ }
1764
+ childCwd = record.worktree.path;
1765
+ childMode = "default";
1766
+ tools = createWorktreeChildTools(childCwd, record.profile.tools.include);
1767
+ }
1648
1768
  const childToolNames = tools.map((tool) => tool.name);
1649
1769
  const route = record.route ?? {
1650
1770
  providerId: this.providerId,
@@ -1659,14 +1779,21 @@ export class Agent {
1659
1779
  configuredModel: route.model || "none",
1660
1780
  configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
1661
1781
  thinkingLevel: route.thinkingLevel,
1662
- mode: "plan",
1663
- workingDir: cwd,
1782
+ mode: childMode,
1783
+ workingDir: childCwd,
1664
1784
  ...buildToolPromptOptions(tools),
1665
1785
  memoryPrompt: childToolNames.some((name) => name === "memory_search" || name === "memory_read_summary")
1666
1786
  ? this.memoryPrompt
1667
1787
  : undefined,
1668
1788
  agentProfilePrompt: [
1669
1789
  `You are subagent ${record.nickname}. Your agent profile is ${record.profile.name}.`,
1790
+ record.profile.mode === "write_worktree"
1791
+ ? [
1792
+ "You work inside an isolated git worktree; the parent reviews your diff after you finish.",
1793
+ "Make your changes, verify them (run tests where possible), and end with a handoff that lists the files you changed and how you verified them.",
1794
+ "Do not commit, push, or touch anything outside this worktree.",
1795
+ ].join(" ")
1796
+ : "",
1670
1797
  record.profile.prompt,
1671
1798
  ].filter(Boolean).join("\n\n"),
1672
1799
  });
@@ -1677,7 +1804,7 @@ export class Agent {
1677
1804
  tools,
1678
1805
  temperature: this.temperature,
1679
1806
  thinkingLevel: route.thinkingLevel,
1680
- mode: "plan",
1807
+ mode: childMode,
1681
1808
  maxTurns: record.profile.maxTurns,
1682
1809
  budgetLedger: this.budgetLedger,
1683
1810
  budgetSource: { runId: record.runId, subAgentId: record.agentId },
@@ -1688,8 +1815,18 @@ export class Agent {
1688
1815
  subAgentId: record.agentId,
1689
1816
  agentCategories: this.agentCategories,
1690
1817
  providerFactory: this.providerFactory,
1818
+ // The scheduler owns 429 backoff for children; the transport must not
1819
+ // stack its own retries on top (design §4.5).
1820
+ rateLimitPolicy: "defer",
1691
1821
  });
1692
- if (forkContext) {
1822
+ if (record.messages && record.messages.length > 0) {
1823
+ // Cross-restart resume (design §7): rebuild the child from its
1824
+ // persisted history — including its original system prompt — so
1825
+ // send_input continues with context intact.
1826
+ subAgent.messages = record.messages.map((message) => ({ ...message }));
1827
+ record.messages = undefined;
1828
+ }
1829
+ else if (forkContext) {
1693
1830
  subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
1694
1831
  }
1695
1832
  return subAgent;
@@ -1775,27 +1912,20 @@ export class Agent {
1775
1912
  }));
1776
1913
  }
1777
1914
  activeSubagentNicknames() {
1778
- return [...this.subagentThreads.values()]
1779
- .filter((record) => !isFinalSubagentStatus(record.status))
1780
- .map((record) => record.nickname);
1915
+ return this.subagentStore.active().map((record) => record.nickname);
1781
1916
  }
1782
1917
  resolveSubagentTargets(agentIds) {
1783
1918
  if (!agentIds || agentIds.length === 0) {
1784
- return [...this.subagentThreads.values()].filter((record) => record.status !== "closed");
1919
+ return this.subagentStore.values().filter((record) => record.status !== "closed");
1785
1920
  }
1786
1921
  return agentIds.map((id) => {
1787
- const record = this.subagentThreads.get(id);
1922
+ const record = this.subagentStore.get(id);
1788
1923
  if (!record) {
1789
1924
  throw new Error(`Unknown subagent: ${id}`);
1790
1925
  }
1791
1926
  return record;
1792
1927
  });
1793
1928
  }
1794
- notifySubagentWaiters(record) {
1795
- for (const waiter of record.waiters) {
1796
- waiter();
1797
- }
1798
- }
1799
1929
  maybeCompactResidentHistory() {
1800
1930
  if (this.messages.length === 0) {
1801
1931
  return;
@@ -2242,64 +2372,7 @@ function isSubagentLifecycleTool(name) {
2242
2372
  || name === "spawn_agent"
2243
2373
  || name === "wait_agent"
2244
2374
  || name === "send_input"
2245
- || name === "close_agent";
2246
- }
2247
- function sanitizeSubagentSummary(value) {
2248
- return stripProviderProtocolArtifacts(value).trim();
2249
- }
2250
- function needsExplicitFinalSummary(record, executedAnyTool) {
2251
- if (!record.summary)
2252
- return executedAnyTool;
2253
- if (isOnlyProviderProtocolArtifacts(record.summary))
2254
- return true;
2255
- if (/<\/?[||][^<>]*>/.test(record.summary))
2256
- return true;
2257
- if (!executedAnyTool)
2258
- return false;
2259
- if (record.summary === EMPTY_ASSISTANT_FALLBACK)
2260
- return true;
2261
- return isLikelyIntermediateSubagentSummary(record.summary);
2262
- }
2263
- function isLikelyIntermediateSubagentSummary(value) {
2264
- const normalized = value.trim().replace(/\s+/g, " ").toLowerCase();
2265
- if (!normalized)
2266
- return false;
2267
- if (/^(let me|i'll|i will|i need to|i should|i'm going to|now i'll|now i will)\b/.test(normalized)) {
2268
- return true;
2269
- }
2270
- return /:\s*$/.test(normalized) && /\b(read|inspect|check|look|search|try|open)\b/.test(normalized);
2271
- }
2272
- function summarizeSubagentToolEnd(event) {
2273
- const metadata = (event.result.metadata ?? {});
2274
- const reason = readString(metadata.reason);
2275
- if (reason)
2276
- return reason;
2277
- const summary = readString(metadata.summary);
2278
- if (summary)
2279
- return summary;
2280
- if (event.result.isError) {
2281
- const firstLine = event.result.content.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
2282
- return firstLine ? truncateForNote(firstLine) : "failed";
2283
- }
2284
- const matches = readNumber(metadata.matches);
2285
- const pattern = readString(metadata.pattern);
2286
- const path = readString(metadata.path);
2287
- if (matches !== undefined) {
2288
- const target = pattern ? ` for ${pattern}` : "";
2289
- const within = path ? ` in ${path}` : "";
2290
- return `${matches} match${matches === 1 ? "" : "es"}${target}${within}`;
2291
- }
2292
- const kind = readString(metadata.kind);
2293
- if (path)
2294
- return kind ? `${kind} ${path}` : path;
2295
- return event.result.status ?? "completed";
2296
- }
2297
- function readString(value) {
2298
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
2299
- }
2300
- function readNumber(value) {
2301
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
2302
- }
2303
- function truncateForNote(value, max = 200) {
2304
- return value.length <= max ? value : `${value.slice(0, max - 3)}...`;
2375
+ || name === "close_agent"
2376
+ || name === "list_agents"
2377
+ || name === "agent_team";
2305
2378
  }