@bubblebrain-ai/bubble 0.0.46 → 0.0.48

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 (95) hide show
  1. package/dist/agent/child-runner.js +11 -0
  2. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  3. package/dist/agent/internal-reminder-sanitizer.js +13 -0
  4. package/dist/agent/subagent/router.d.ts +65 -0
  5. package/dist/agent/subagent/router.js +198 -0
  6. package/dist/agent/subagent/runtime.d.ts +203 -0
  7. package/dist/agent/subagent/runtime.js +789 -0
  8. package/dist/agent/subagent-control.d.ts +5 -0
  9. package/dist/agent/subagent-store.d.ts +18 -2
  10. package/dist/agent/subagent-store.js +32 -1
  11. package/dist/agent/tool-intent.d.ts +10 -0
  12. package/dist/agent/tool-intent.js +18 -0
  13. package/dist/agent/tool-visibility.d.ts +10 -0
  14. package/dist/agent/tool-visibility.js +6 -0
  15. package/dist/agent/workflow/runs.d.ts +72 -0
  16. package/dist/agent/workflow/runs.js +157 -0
  17. package/dist/agent.d.ts +78 -96
  18. package/dist/agent.js +266 -1072
  19. package/dist/context/compact-llm.js +14 -5
  20. package/dist/context/compact.d.ts +9 -0
  21. package/dist/context/compact.js +36 -10
  22. package/dist/context/compaction-files.d.ts +44 -0
  23. package/dist/context/compaction-files.js +148 -0
  24. package/dist/context/llm-compactor.js +24 -5
  25. package/dist/feishu/agent-host/run-driver.js +3 -0
  26. package/dist/feishu/serve.js +13 -9
  27. package/dist/goal/tools.js +12 -3
  28. package/dist/main.js +24 -19
  29. package/dist/model-catalog.js +36 -2
  30. package/dist/model-config.d.ts +2 -0
  31. package/dist/model-data.generated.d.ts +16 -0
  32. package/dist/model-data.generated.js +275 -0
  33. package/dist/model-pricing.d.ts +11 -7
  34. package/dist/model-pricing.js +0 -0
  35. package/dist/oauth/grok.d.ts +1 -1
  36. package/dist/oauth/grok.js +5 -2
  37. package/dist/oauth/storage.d.ts +9 -2
  38. package/dist/oauth/storage.js +16 -9
  39. package/dist/orchestrator/default-hooks.d.ts +20 -0
  40. package/dist/orchestrator/default-hooks.js +47 -295
  41. package/dist/orchestrator/hooks.d.ts +6 -39
  42. package/dist/permission/mode.js +1 -1
  43. package/dist/prompt/reminders.d.ts +9 -35
  44. package/dist/prompt/reminders.js +30 -136
  45. package/dist/prompt/task-reminders.d.ts +0 -31
  46. package/dist/prompt/task-reminders.js +0 -87
  47. package/dist/provider-ai-sdk.d.ts +2 -0
  48. package/dist/provider-ai-sdk.js +1 -0
  49. package/dist/provider-anthropic.d.ts +2 -0
  50. package/dist/provider-anthropic.js +3 -1
  51. package/dist/provider-ark-responses.d.ts +2 -0
  52. package/dist/provider-ark-responses.js +1 -0
  53. package/dist/provider-grok.d.ts +26 -0
  54. package/dist/provider-grok.js +39 -0
  55. package/dist/provider-registry.d.ts +14 -1
  56. package/dist/provider-registry.js +131 -3
  57. package/dist/provider-transform.js +37 -1
  58. package/dist/provider.d.ts +7 -0
  59. package/dist/provider.js +13 -1
  60. package/dist/sdk/index.d.ts +152 -0
  61. package/dist/sdk/index.js +400 -0
  62. package/dist/session.js +9 -1
  63. package/dist/slash-commands/commands.js +33 -19
  64. package/dist/tools/exit-plan-mode.js +8 -0
  65. package/dist/tui/detect-theme.d.ts +1 -0
  66. package/dist/tui/detect-theme.js +11 -5
  67. package/dist/tui-ink/app.d.ts +2 -6
  68. package/dist/tui-ink/app.js +11 -18
  69. package/dist/tui-ink/display-history.d.ts +0 -2
  70. package/dist/tui-ink/display-history.js +0 -52
  71. package/dist/tui-ink/footer.js +2 -2
  72. package/dist/tui-ink/input-box.js +18 -6
  73. package/dist/tui-ink/message-list.js +62 -23
  74. package/dist/tui-ink/run.js +10 -5
  75. package/dist/tui-ink/sync-output.d.ts +17 -0
  76. package/dist/tui-ink/sync-output.js +35 -0
  77. package/dist/tui-ink/theme.d.ts +0 -1
  78. package/dist/tui-ink/theme.js +0 -2
  79. package/dist/tui-ink/trace-groups.js +1 -1
  80. package/dist/types.d.ts +21 -1
  81. package/package.json +16 -2
  82. package/dist/agent/discovery-barrier.d.ts +0 -21
  83. package/dist/agent/discovery-barrier.js +0 -173
  84. package/dist/agent/evidence-tracker.d.ts +0 -15
  85. package/dist/agent/evidence-tracker.js +0 -93
  86. package/dist/agent/execution-governor.d.ts +0 -36
  87. package/dist/agent/execution-governor.js +0 -218
  88. package/dist/agent/task-classifier.d.ts +0 -3
  89. package/dist/agent/task-classifier.js +0 -114
  90. package/dist/agent/task-size.d.ts +0 -9
  91. package/dist/agent/task-size.js +0 -33
  92. package/dist/agent/tool-arbiter.d.ts +0 -7
  93. package/dist/agent/tool-arbiter.js +0 -33
  94. package/dist/orchestrator/workflow.d.ts +0 -10
  95. package/dist/orchestrator/workflow.js +0 -22
package/dist/agent.js CHANGED
@@ -4,9 +4,6 @@
4
4
  */
5
5
  import { compactCurrentTurnToolGroups, compactMessages, isRealUserMessage } from "./context/compact.js";
6
6
  import { randomUUID } from "node:crypto";
7
- import { mkdirSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { getBubbleHome } from "./bubble-home.js";
10
7
  import { compactMessagesWithLLM } from "./context/compact-llm.js";
11
8
  import { estimateContextTokens, getContextBudget } from "./context/budget.js";
12
9
  import { buildContextUsageSnapshot } from "./context/usage.js";
@@ -15,42 +12,24 @@ import { computeRetryDelayMs, isProviderStreamInterruption, MAX_STREAM_INTERRUPT
15
12
  import { projectMessages } from "./context/projector.js";
16
13
  import { aggressivePruneMessages, markStableCurrentToolResultsForCache, markToolResultCacheStable } from "./context/prune.js";
17
14
  import { truncateToolOutputForModel } from "./context/tool-output-truncate.js";
18
- import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode } from "./prompt/reminders.js";
15
+ import { buildDeferredToolsReminder, buildToolFreezeReminder, isPermissionModeReminder, reminderForMode } from "./prompt/reminders.js";
19
16
  import { HookBus } from "./orchestrator/hooks.js";
20
17
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
21
18
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
22
19
  import { buildTaskLifecycleReminder } from "./agent/task-lifecycle-reminder.js";
23
- import { classifyTask } from "./agent/task-classifier.js";
24
- import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
25
- import { DEFAULT_AGENT_ROUTING, nearModelMatches, sanitizeAgentRouting, tierContextFromSnapshot } from "./agent/routing-catalog.js";
26
- import { buildModelRoutingPrompt } from "./prompt/routing.js";
27
- import { getBuiltinModel } from "./model-catalog.js";
28
- import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeInheritedThinkingLevel, normalizeThinkingLevel } from "./variant/variant-resolver.js";
29
- import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateStructuredSummary } from "./agent/structured-output.js";
30
- import { runWorkflow, WorkflowConcurrencyGate } from "./agent/workflow/runtime.js";
31
- import { buildWorkflowDeliveryNotice, renderWorkflowResultValue } from "./agent/workflow/control.js";
32
- import { composeAbortSignals } from "./agent/budget-ledger.js";
33
- import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
34
- import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
35
- import { SubagentStore } from "./agent/subagent-store.js";
36
- import { SubagentScheduler } from "./agent/subagent-scheduler.js";
37
- import { ChildRunner, classifySubagentAbortReason } from "./agent/child-runner.js";
38
- import { ResultIntegrator } from "./agent/result-integrator.js";
39
- import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./agent/abort-errors.js";
40
- import { createSubagentWorktree, finalizeSubagentWorktree } from "./agent/worktree.js";
41
- import { createWorktreeChildTools, isolateReadonlyChildFileTools } from "./tools/child-tools.js";
42
- import { isHiddenToolResult } from "./agent/discovery-barrier.js";
20
+ import { DEFAULT_AGENT_ROUTING, sanitizeAgentRouting } from "./agent/routing-catalog.js";
21
+ import { SubagentRouter } from "./agent/subagent/router.js";
22
+ import { normalizeWaitTimeout, SubagentRuntime } from "./agent/subagent/runtime.js";
23
+ import { WorkflowLedger } from "./agent/workflow/runs.js";
24
+ import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK } from "./agent/abort-errors.js";
25
+ import { isHiddenToolResult } from "./agent/tool-visibility.js";
43
26
  import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
44
- import { buildSystemPrompt } from "./system-prompt.js";
45
27
  import { debugReasoningStream, summarizeDebugText } from "./reasoning-debug.js";
46
28
  import { buildToolPromptOptions } from "./tools/prompt-metadata.js";
47
29
  import { stopAutoServersForSession } from "./tools/server-manager.js";
48
30
  import { summarizeAgentEventForTrace, summarizeTraceError, summarizeTraceMessage, summarizeTraceToolResult, summarizeTraceValue, traceEvent, } from "./debug-trace.js";
49
31
  const MAX_CONSECUTIVE_OVERFLOW_RECOVERIES = 3;
50
32
  const RESIDENT_HISTORY_KEEP_RECENT_TURNS = 3;
51
- const RESIDENT_HISTORY_MESSAGE_LIMIT = 160;
52
- const RESIDENT_HISTORY_CHAR_SOFT_LIMIT = 256 * 1024;
53
- const RESIDENT_HISTORY_CHAR_HARD_LIMIT = 512 * 1024;
54
33
  const RESIDENT_HISTORY_HEAP_HARD_LIMIT = 768 * 1024 * 1024;
55
34
  const MAX_EMPTY_ASSISTANT_RECOVERIES = 1;
56
35
  const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained no user-visible assistant content and no tool calls. " +
@@ -94,10 +73,6 @@ function agentEventFromHookProgress(event) {
94
73
  error: event.error ?? "Hook failed.",
95
74
  };
96
75
  }
97
- /** Detector threshold N (design §6): 1–2 strong-model children are often a
98
- * deliberate deep side-investigation; at 3+ uniform defaults the odds that
99
- * all need frontier capability drop sharply. Revisit against live traces. */
100
- const ROUTING_REMINDER_THRESHOLD = 3;
101
76
  export class Agent {
102
77
  messages = [];
103
78
  provider;
@@ -116,7 +91,6 @@ export class Agent {
116
91
  * If the model read the nudge once and chose not to delegate, repeating
117
92
  * it is noise.
118
93
  */
119
- largeTaskNudgeConsumed = false;
120
94
  _providerId;
121
95
  _model;
122
96
  tools = new Map();
@@ -126,6 +100,7 @@ export class Agent {
126
100
  _mode;
127
101
  _modeVersion = 0;
128
102
  onModeUpdate;
103
+ onCompactionApplied;
129
104
  _todos;
130
105
  _todosVersion = 0;
131
106
  onTodosUpdate;
@@ -144,32 +119,21 @@ export class Agent {
144
119
  fileStateTracker;
145
120
  agentCategories;
146
121
  agentRouting;
147
- routingSnapshotAccessor;
148
- routableModelIndex;
149
- /** Detector state (design §6): once per session, absolute defaulted count. */
150
- routingReminderFired = false;
151
- defaultedRoutingStreak = 0;
152
- pendingRoutingReminder;
122
+ /** Owns the routing decision chain, the §6 detector state and the §4 menu. */
123
+ router;
153
124
  providerFactory;
154
- subagentStore;
155
- subagentScheduler;
156
- childRunner;
157
- resultIntegrator = new ResultIntegrator();
158
- /** Background dynamic-workflow runs (option C Phase 4), keyed by runId. */
159
- workflowRuns = new Map();
160
- /** runIds whose completed result should be ingested at the next turn. */
161
- pendingWorkflowDeliveries = new Set();
162
- subagentsConfig;
163
- rateLimitPolicy;
164
- pendingSubagentUpdates = [];
165
125
  /**
166
- * Wakers for tool-execution update loops currently awaiting updates. A
167
- * blocking lifecycle tool (wait_workflow / wait_agent) produces no updates of
168
- * its own, so without this wake the background children's queued lifecycle
169
- * updates sit undrained until the tool settles — the UI would only show
170
- * subagent traces after the whole team finished.
126
+ * Subagent thread lifecycle + workflow script execution: the store,
127
+ * scheduler, ChildRunner and their dispatch/ingestion plumbing.
128
+ */
129
+ subagents;
130
+ /**
131
+ * Background dynamic-workflow runs (option C Phase 4): run records, waiters,
132
+ * cancellation and the pending-delivery set. Execution stays on Agent.
171
133
  */
172
- subagentUpdateWakers = new Set();
134
+ workflowLedger;
135
+ subagentsConfig;
136
+ rateLimitPolicy;
173
137
  lastInputTokens = null;
174
138
  lastAnchorMessageCount = null;
175
139
  // How often each compaction path actually rewrote history this run.
@@ -192,6 +156,7 @@ export class Agent {
192
156
  this.onToolResult = options.onToolResult;
193
157
  this.onTodosUpdate = options.onTodosUpdate;
194
158
  this.onModeUpdate = options.onModeUpdate;
159
+ this.onCompactionApplied = options.onCompactionApplied;
195
160
  this.hookDefinitions = options.hooks ?? [];
196
161
  this.externalHooks = options.externalHooks;
197
162
  this.agentRole = options.agentRole ?? "parent";
@@ -207,53 +172,48 @@ export class Agent {
207
172
  this.agentRouting = options.agentRouting
208
173
  ? sanitizeAgentRouting(options.agentRouting)
209
174
  : { ...DEFAULT_AGENT_ROUTING };
210
- this.routingSnapshotAccessor = options.routingSnapshot;
211
- this.routableModelIndex = options.routableModels;
175
+ // parentRoute is a thunk, not a snapshot: /model reassigns provider/model/
176
+ // thinking mid-session and children must inherit the CURRENT values.
177
+ this.router = new SubagentRouter({
178
+ parentRoute: () => ({
179
+ providerId: this.providerId,
180
+ model: this.apiModel,
181
+ thinkingLevel: this.thinkingLevel,
182
+ }),
183
+ categories: this.agentCategories,
184
+ routing: this.agentRouting,
185
+ snapshot: options.routingSnapshot,
186
+ routableModels: options.routableModels,
187
+ });
212
188
  this.providerFactory = options.providerFactory;
189
+ this.workflowLedger = new WorkflowLedger({
190
+ execute: (cwd, opts) => this.subagents.executeWorkflow(cwd, opts),
191
+ });
213
192
  this.subagentsConfig = options.subagents ?? {};
214
193
  this.rateLimitPolicy = options.rateLimitPolicy;
215
- // Children persist next to the session file so a later process can
216
- // resume them via send_input (design §7). Child agents themselves
217
- // (agentRole "subagent") never persist children — no recursion exists.
218
- const persistDir = this.agentRole === "parent"
219
- ? this.subagentsConfig.persistDir
220
- ?? (this.sessionID?.endsWith(".jsonl") ? this.sessionID.replace(/\.jsonl$/, ".subagents") : undefined)
221
- : undefined;
222
- this.subagentStore = new SubagentStore(persistDir);
223
- this.subagentStore.loadPersisted();
224
- this.subagentScheduler = new SubagentScheduler({
225
- maxActiveSubagents: this.subagentsConfig.maxActiveSubagents,
226
- launchBurst: this.subagentsConfig.launchBurst,
227
- launchIntervalMs: this.subagentsConfig.launchIntervalMs,
228
- rateLimitMaxAttempts: this.subagentsConfig.rateLimitMaxAttempts,
229
- rateLimitBackoffMs: this.subagentsConfig.rateLimitBackoffMs,
230
- transportRetryMaxAttempts: this.subagentsConfig.transportRetryMaxAttempts,
231
- transportRetryBackoffMs: this.subagentsConfig.transportRetryBackoffMs,
232
- getCategoryLimit: (category) => mergeAgentCategories(this.agentCategories)[category]?.maxConcurrent,
233
- });
234
- this.childRunner = new ChildRunner({
235
- allTools: () => [...this.tools.values()],
236
- emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
237
- runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
238
- finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
239
- createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
240
- notifyWaiters: (record) => this.subagentStore.notifyWaiters(record),
241
- onFinal: (record, options) => {
242
- if (record.worktree) {
243
- // Inspect and clean up the worktree: unchanged → removed; changed →
244
- // kept for the parent to review, with a diff stat in the handoff (§8).
245
- finalizeSubagentWorktree(record.worktree);
246
- if (record.worktree.changed) {
247
- record.toolNotes.push(`worktree: changes left in ${record.worktree.path} — review the diff before applying`);
248
- }
249
- }
250
- // Workflow-internal agents are not persisted (they never re-import into
251
- // the store on restart) and never ingest into parent context (option C).
252
- if (!record.workflowInternal) {
253
- this.subagentStore.persist(record);
254
- this.maybeEnqueueIngestion(record, options);
255
- }
194
+ const persistDir = this.deriveSubagentPersistDir();
195
+ // `self` so the parent adapter's getters read through to this agent.
196
+ const self = this;
197
+ this.subagents = new SubagentRuntime({
198
+ parent: {
199
+ // Live accessors, never snapshots — /model reassigns these mid-session.
200
+ get provider() { return self.provider; },
201
+ get providerId() { return self.providerId; },
202
+ get apiModel() { return self.apiModel; },
203
+ get thinkingLevel() { return self.thinkingLevel; },
204
+ get memoryPrompt() { return self.memoryPrompt; },
205
+ get providerFactory() { return self.providerFactory; },
206
+ // Unfiltered on purpose: profile admission and child tool selection
207
+ // must see deferred-but-registered tools (an explicit include in a
208
+ // profile is the author pre-unlocking them).
209
+ allTools: () => [...this.tools.values()],
210
+ createChild: (spec) => this.createChildAgent(spec),
211
+ runExternalHook: (input, abortSignal) => this.runExternalHook(input, abortSignal),
256
212
  },
213
+ router: this.router,
214
+ categories: this.agentCategories,
215
+ config: this.subagentsConfig,
216
+ persistDir,
257
217
  });
258
218
  if (options.systemPrompt) {
259
219
  this.messages.push({ role: "system", content: options.systemPrompt });
@@ -292,6 +252,23 @@ export class Agent {
292
252
  */
293
253
  setSessionID(sessionID) {
294
254
  this.sessionID = sessionID;
255
+ // The TUI reuses one Agent across session switches, so the subagent
256
+ // persist directory must follow the session — otherwise children spawned
257
+ // after a switch land in the OLD session's directory and the new
258
+ // session's persisted children never load (known-defects #2).
259
+ this.subagents.repointPersistDir(this.deriveSubagentPersistDir());
260
+ }
261
+ /**
262
+ * Children persist next to the session file so a later process can resume
263
+ * them via send_input (design §7). An explicit subagents.persistDir config
264
+ * always wins over the sessionID derivation. Child agents themselves
265
+ * (agentRole "subagent") never persist children — no recursion exists.
266
+ */
267
+ deriveSubagentPersistDir() {
268
+ if (this.agentRole !== "parent")
269
+ return undefined;
270
+ return this.subagentsConfig.persistDir
271
+ ?? (this.sessionID?.endsWith(".jsonl") ? this.sessionID.replace(/\.jsonl$/, ".subagents") : undefined);
295
272
  }
296
273
  getSessionID() {
297
274
  return this.sessionID;
@@ -372,10 +349,7 @@ export class Agent {
372
349
  }
373
350
  /** Current routing menu (design §4); undefined when no accessor is wired. */
374
351
  buildModelRoutingPromptSection() {
375
- const snapshot = this.currentRoutingSnapshot();
376
- if (!snapshot)
377
- return undefined;
378
- return buildModelRoutingPrompt(snapshot, this.agentRouting);
352
+ return this.router.promptSection();
379
353
  }
380
354
  /**
381
355
  * Routing menu rendered for a prospective parent route — used by model-
@@ -383,14 +357,7 @@ export class Agent {
383
357
  * (design §1.5).
384
358
  */
385
359
  renderModelRoutingPromptFor(parent) {
386
- if (!this.routingSnapshotAccessor)
387
- return undefined;
388
- try {
389
- return buildModelRoutingPrompt(this.routingSnapshotAccessor(parent), this.agentRouting);
390
- }
391
- catch {
392
- return undefined;
393
- }
360
+ return this.router.promptSectionFor(parent);
394
361
  }
395
362
  getContextUsageSnapshot() {
396
363
  return buildContextUsageSnapshot({
@@ -424,11 +391,39 @@ export class Agent {
424
391
  const last = this.messages.at(-1);
425
392
  if (last?.role === "meta"
426
393
  && last.kind === "system-reminder"
427
- && last.content === reminder) {
394
+ && last.content === reminder
395
+ && last.includeInLlm !== false) {
428
396
  return;
429
397
  }
398
+ this.retireStaleModeReminders();
430
399
  this.injectSystemReminder(reminder);
431
400
  }
401
+ /**
402
+ * Mode reminders are enter-announcements with no cancel semantics, so a stale
403
+ * one keeps describing a mode the session left long ago. Shift+Tab cycles
404
+ * through plan on the way to bypassPermissions, which used to leave a live
405
+ * "Plan mode is now ACTIVE" (plus its "call exit_plan_mode" rule) in history
406
+ * for the rest of the session — the model then planned instead of building,
407
+ * and hunted for a tool that is filtered out outside plan mode.
408
+ *
409
+ * Hidden rather than spliced: keeping the array shape intact leaves message
410
+ * indices (usage anchors, compaction bookkeeping) valid, and the transcript
411
+ * still shows what happened. Cache cost is one invalidated prefix per real
412
+ * mode switch — the no-op path above keeps repeat injections free.
413
+ */
414
+ retireStaleModeReminders() {
415
+ for (const message of this.messages) {
416
+ if (message.role !== "meta")
417
+ continue;
418
+ if (message.kind !== "system-reminder")
419
+ continue;
420
+ if (message.includeInLlm === false)
421
+ continue;
422
+ if (!isPermissionModeReminder(message.content))
423
+ continue;
424
+ message.includeInLlm = false;
425
+ }
426
+ }
432
427
  get role() {
433
428
  return this.agentRole;
434
429
  }
@@ -567,19 +562,9 @@ export class Agent {
567
562
  events.push(...hook.events);
568
563
  this.injectHookModelContext(hook.result);
569
564
  this.appendMessage({ role: "user", content: input.content });
570
- // Large-task detector (large-task-delegation design §1): a steer
571
- // redirects the task — stale breadth evidence must not fire a nudge
572
- // about the pre-steer task, and the classifier should reason about
573
- // the redirected one.
574
- hookState.exploredFiles?.clear();
575
- hookState.largeTaskCheckpointDone = false;
576
- // A steer is a new ask: the completion gate must be able to fire
577
- // again for the follow-up's closing pass, or appended requirements
578
- // never get a self-check (codeChanged stays — prior edits are real).
579
- hookState.completionGateFired = false;
580
- if (typeof input.content === "string" && input.content.trim()) {
581
- hookState.taskType = classifyTask(input.content);
582
- }
565
+ // A steer is a new ask: the tests-touched disclosure must be able to
566
+ // fire again for the follow-up's closing pass.
567
+ hookState.testsTouchedDisclosed = false;
583
568
  events.push({
584
569
  type: "input_applied",
585
570
  id: input.id,
@@ -622,7 +607,7 @@ export class Agent {
622
607
  events.push({ type: "input_pending_changed", pending: pendingInputCount() });
623
608
  return events;
624
609
  };
625
- const flushGovernorReminders = () => {
610
+ const flushQueuedReminders = () => {
626
611
  for (const reminder of reminderQueue.splice(0, reminderQueue.length)) {
627
612
  this.injectSystemReminder(reminder);
628
613
  }
@@ -660,9 +645,9 @@ export class Agent {
660
645
  input: userInput,
661
646
  state: hookState,
662
647
  queueReminder,
663
- flushReminders: flushGovernorReminders,
648
+ flushReminders: flushQueuedReminders,
664
649
  });
665
- flushGovernorReminders();
650
+ flushQueuedReminders();
666
651
  let consecutiveOverflowRecoveries = 0;
667
652
  let consecutiveEmptyAssistantRecoveries = 0;
668
653
  let consecutiveStreamInterruptionRetries = 0;
@@ -679,12 +664,14 @@ export class Agent {
679
664
  try {
680
665
  while (true) {
681
666
  throwIfAborted(abortSignal);
682
- flushGovernorReminders();
667
+ flushQueuedReminders();
683
668
  // Background child completions surface before the next inference turn
684
669
  // without requiring a wait_agent call (design §5).
685
- this.flushSubagentIngestions();
670
+ for (const notice of this.subagents.drainIngestionNotices()) {
671
+ this.injectSystemReminder(notice);
672
+ }
686
673
  this.flushWorkflowDeliveries();
687
- for (const update of this.drainSubagentToolUpdates())
674
+ for (const update of this.subagents.drainToolUpdates())
688
675
  yield emit(update);
689
676
  for (const event of await applyPendingInputs())
690
677
  yield emit(event);
@@ -722,14 +709,15 @@ export class Agent {
722
709
  currentAssistantMsg = assistantMsg;
723
710
  currentAssistantAppended = false;
724
711
  let toolEntries = Array.from(this.tools.values())
725
- .filter((t) => !t.deferred || this.unlockedDeferred.has(t.name));
712
+ .filter((t) => !t.deferred || this.unlockedDeferred.has(t.name))
713
+ .filter((t) => t.enabled?.() !== false);
726
714
  const beforeModelCallCtx = {
727
715
  agent: this,
728
716
  cwd,
729
717
  input: userInput,
730
718
  state: hookState,
731
719
  queueReminder,
732
- flushReminders: flushGovernorReminders,
720
+ flushReminders: flushQueuedReminders,
733
721
  toolEntries,
734
722
  disableTools: (reason) => {
735
723
  hookState.forceTextOnlyReason = reason;
@@ -754,7 +742,7 @@ export class Agent {
754
742
  for (const event of preModelHook.events)
755
743
  yield emit(event);
756
744
  this.injectHookModelContext(preModelHook.result);
757
- flushGovernorReminders();
745
+ flushQueuedReminders();
758
746
  const textOnly = !!hookState.forceTextOnlyReason;
759
747
  const toolDefinitions = toolEntries
760
748
  .map((t) => ({
@@ -769,7 +757,6 @@ export class Agent {
769
757
  // algorithmic fallback still kicks in.
770
758
  await this.maybeCompactWithLLM();
771
759
  const bufferedStreamingToolCallIds = new Set();
772
- const discoveryBarrier = hookState.discoveryBarrier;
773
760
  try {
774
761
  markStableCurrentToolResultsForCache(this.messages);
775
762
  const projectedMessages = projectMessages(this.messages, {
@@ -848,10 +835,6 @@ export class Agent {
848
835
  }, traceContext);
849
836
  break;
850
837
  }
851
- if (discoveryBarrier?.isEnabled()
852
- && (bufferedStreamingToolCallIds.has(chunk.id) || discoveryBarrier.shouldBufferStreamingToolCall(chunk.name))) {
853
- bufferedStreamingToolCallIds.add(chunk.id);
854
- }
855
838
  if (chunk.isStart) {
856
839
  streamingToolCalls.set(chunk.id, { id: chunk.id, name: chunk.name, args: "" });
857
840
  if (!bufferedStreamingToolCallIds.has(chunk.id)) {
@@ -916,7 +899,7 @@ export class Agent {
916
899
  }
917
900
  break;
918
901
  }
919
- for (const update of this.drainSubagentToolUpdates())
902
+ for (const update of this.subagents.drainToolUpdates())
920
903
  yield emit(update);
921
904
  }
922
905
  const flushedText = textSanitizer.flush();
@@ -1032,16 +1015,6 @@ export class Agent {
1032
1015
  parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
1033
1016
  }
1034
1017
  }
1035
- const orderedCalls = hookState.discoveryBarrier?.orderToolCalls(parsedCalls) ?? parsedCalls;
1036
- if (orderedCalls !== parsedCalls) {
1037
- parsedCalls.splice(0, parsedCalls.length, ...orderedCalls);
1038
- assistantMsg.toolCalls = parsedCalls.map((tc) => ({
1039
- id: tc.id,
1040
- name: tc.name,
1041
- arguments: tc.arguments,
1042
- ...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
1043
- }));
1044
- }
1045
1018
  const executedResults = [];
1046
1019
  const appendCancelledToolMessages = (startIndex) => {
1047
1020
  for (let pendingIndex = startIndex; pendingIndex < parsedCalls.length; pendingIndex++) {
@@ -1083,7 +1056,7 @@ export class Agent {
1083
1056
  input: userInput,
1084
1057
  state: hookState,
1085
1058
  queueReminder,
1086
- flushReminders: flushGovernorReminders,
1059
+ flushReminders: flushQueuedReminders,
1087
1060
  toolCall: tc,
1088
1061
  blockedResult,
1089
1062
  replaceToolCall: (toolCall) => {
@@ -1129,7 +1102,7 @@ export class Agent {
1129
1102
  name: tc.name,
1130
1103
  arguments: tc.arguments,
1131
1104
  };
1132
- flushGovernorReminders();
1105
+ flushQueuedReminders();
1133
1106
  if (bufferedStreamingToolCallIds.has(tc.id) && !isHiddenToolResult(blockedResult)) {
1134
1107
  yield emit({ type: "tool_call_start", id: tc.id, name: tc.name });
1135
1108
  if (tc.arguments) {
@@ -1151,7 +1124,7 @@ export class Agent {
1151
1124
  input: userInput,
1152
1125
  state: hookState,
1153
1126
  queueReminder,
1154
- flushReminders: flushGovernorReminders,
1127
+ flushReminders: flushQueuedReminders,
1155
1128
  toolCall: tc,
1156
1129
  result,
1157
1130
  replaceResult: (next) => {
@@ -1193,7 +1166,7 @@ export class Agent {
1193
1166
  isError: result.isError,
1194
1167
  });
1195
1168
  executedResults.push(result);
1196
- flushGovernorReminders();
1169
+ flushQueuedReminders();
1197
1170
  continue;
1198
1171
  }
1199
1172
  const toolStartedAt = Date.now();
@@ -1228,20 +1201,20 @@ export class Agent {
1228
1201
  settled = true;
1229
1202
  updateQueue.wake();
1230
1203
  });
1231
- this.subagentUpdateWakers.add(updateQueue.wake);
1204
+ const unsubscribe = this.subagents.subscribe(updateQueue.wake);
1232
1205
  try {
1233
- while (!settled || updateQueue.hasItems() || this.pendingSubagentUpdates.length > 0) {
1206
+ while (!settled || updateQueue.hasItems() || this.subagents.hasPendingUpdates()) {
1234
1207
  for (const update of updateQueue.drain()) {
1235
1208
  yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
1236
1209
  }
1237
- for (const update of this.drainSubagentToolUpdates())
1210
+ for (const update of this.subagents.drainToolUpdates())
1238
1211
  yield emit(update);
1239
1212
  // A wake() that fires while this generator is suspended at a
1240
- // yield above finds no parked waiter and is lost (the queue has
1241
- // no wake latch), so re-check the subagent queue synchronously
1242
- // before parking — otherwise an update pushed during the yield
1243
- // stalls until the next unrelated wake or the tool settles.
1244
- if (!settled && this.pendingSubagentUpdates.length === 0) {
1213
+ // yield above finds no parked waiter. The queue latches such a
1214
+ // wake, and this synchronous re-check covers the same case from
1215
+ // the other side — an update pushed during the yield must not
1216
+ // stall until the next unrelated wake or the tool settles.
1217
+ if (!settled && !this.subagents.hasPendingUpdates()) {
1245
1218
  const waitStatus = await updateQueue.wait(abortSignal);
1246
1219
  if (waitStatus === "aborted" && !settled) {
1247
1220
  cancelledByAbort = true;
@@ -1251,7 +1224,7 @@ export class Agent {
1251
1224
  }
1252
1225
  }
1253
1226
  finally {
1254
- this.subagentUpdateWakers.delete(updateQueue.wake);
1227
+ unsubscribe();
1255
1228
  }
1256
1229
  if (cancelledByAbort) {
1257
1230
  result = cancelledToolResult(tc.name);
@@ -1268,7 +1241,7 @@ export class Agent {
1268
1241
  input: userInput,
1269
1242
  state: hookState,
1270
1243
  queueReminder,
1271
- flushReminders: flushGovernorReminders,
1244
+ flushReminders: flushQueuedReminders,
1272
1245
  toolCall: tc,
1273
1246
  result,
1274
1247
  replaceResult: (next) => {
@@ -1324,11 +1297,11 @@ export class Agent {
1324
1297
  markToolResultCacheStable(tc.name, toolMessage);
1325
1298
  this.appendMessage(toolMessage);
1326
1299
  this.compactResidentHistory();
1327
- flushGovernorReminders();
1300
+ flushQueuedReminders();
1328
1301
  this.onToolResult?.(tc.name, result);
1329
1302
  executedResults.push(result);
1330
1303
  yield emit({ type: "tool_end", id: tc.id, name: tc.name, result });
1331
- for (const update of this.drainSubagentToolUpdates())
1304
+ for (const update of this.subagents.drainToolUpdates())
1332
1305
  yield emit(update);
1333
1306
  if (this._todosVersion !== todosVersionBefore) {
1334
1307
  yield emit({ type: "todos_updated", todos: this.getTodos() });
@@ -1347,14 +1320,14 @@ export class Agent {
1347
1320
  input: userInput,
1348
1321
  state: hookState,
1349
1322
  queueReminder,
1350
- flushReminders: flushGovernorReminders,
1323
+ flushReminders: flushQueuedReminders,
1351
1324
  toolCalls: parsedCalls,
1352
1325
  toolResults: executedResults,
1353
1326
  requestTextOnlyTurn: (reason) => {
1354
1327
  hookState.forceTextOnlyReason = reason;
1355
1328
  },
1356
1329
  });
1357
- flushGovernorReminders();
1330
+ flushQueuedReminders();
1358
1331
  yield emit({ type: "turn_end", usage: turnUsage, willContinue: true });
1359
1332
  // Auto-continue: if we have tool results, the LLM needs to respond to them.
1360
1333
  // Emitting the turn boundary keeps UI renderers aligned with the persisted
@@ -1368,9 +1341,9 @@ export class Agent {
1368
1341
  input: userInput,
1369
1342
  state: hookState,
1370
1343
  queueReminder,
1371
- flushReminders: flushGovernorReminders,
1344
+ flushReminders: flushQueuedReminders,
1372
1345
  });
1373
- flushGovernorReminders();
1346
+ flushQueuedReminders();
1374
1347
  const stopHook = await this.runExternalHook({
1375
1348
  eventName: "Stop",
1376
1349
  cwd,
@@ -1396,7 +1369,7 @@ export class Agent {
1396
1369
  yield emit(event);
1397
1370
  break;
1398
1371
  }
1399
- for (const update of this.drainSubagentToolUpdates())
1372
+ for (const update of this.subagents.drainToolUpdates())
1400
1373
  yield emit(update);
1401
1374
  await stopOwnedAutoServers();
1402
1375
  yield emit({ type: "agent_end" });
@@ -1461,6 +1434,7 @@ export class Agent {
1461
1434
  this.lastInputTokens = null;
1462
1435
  this.lastAnchorMessageCount = null;
1463
1436
  this.fileStateTracker?.invalidateReadHistory();
1437
+ this.persistCompactionSummary(llmResult.summary);
1464
1438
  return before - this.messages.length;
1465
1439
  }
1466
1440
  // Single-turn capable LLM compactor. compactMessagesWithLLM above no-ops
@@ -1476,6 +1450,7 @@ export class Agent {
1476
1450
  this.lastInputTokens = null;
1477
1451
  this.lastAnchorMessageCount = null;
1478
1452
  this.fileStateTracker?.invalidateReadHistory();
1453
+ this.persistCompactionSummary(singleTurnResult.summary);
1479
1454
  return before - this.messages.length;
1480
1455
  }
1481
1456
  const fallback = compactMessages(this.messages, { keepRecentTurns });
@@ -1484,6 +1459,7 @@ export class Agent {
1484
1459
  this.lastInputTokens = null;
1485
1460
  this.lastAnchorMessageCount = null;
1486
1461
  this.fileStateTracker?.invalidateReadHistory();
1462
+ this.persistCompactionSummary(fallback.summary);
1487
1463
  return before - this.messages.length;
1488
1464
  }
1489
1465
  // Codex-style last-resort: drop the single oldest non-protected message
@@ -1531,6 +1507,7 @@ export class Agent {
1531
1507
  this.fileStateTracker?.invalidateReadHistory();
1532
1508
  this.compactionStats.llm += 1;
1533
1509
  this.compactionStats.fired += 1;
1510
+ this.persistCompactionSummary(result.summary);
1534
1511
  traceEvent("compaction_fired", { path: "llm" });
1535
1512
  }
1536
1513
  // If LLM compaction failed for any reason, leave this.messages alone —
@@ -1554,7 +1531,16 @@ export class Agent {
1554
1531
  if (oldMessages.length === 0)
1555
1532
  return "";
1556
1533
  const { buildCompactionPromptMessages } = await import("./context/compact-llm.js");
1557
- const promptMessages = buildCompactionPromptMessages(oldMessages);
1534
+ const { collectCompactionFileOps, isCompactionSummaryMessage, messageText } = await import("./context/compact.js");
1535
+ const { appendFileBlocks, stripFileBlocks } = await import("./context/compaction-files.js");
1536
+ // File blocks stay out of the model round-trip: stripped from the input
1537
+ // here, merged deterministically, re-appended to the returned summary.
1538
+ const priorSummaries = oldMessages.filter(isCompactionSummaryMessage);
1539
+ const fileOps = collectCompactionFileOps(oldMessages.filter((message) => !isCompactionSummaryMessage(message)), priorSummaries);
1540
+ const promptInput = oldMessages.map((message) => isCompactionSummaryMessage(message) && typeof message.content === "string"
1541
+ ? { ...message, content: stripFileBlocks(messageText(message)) }
1542
+ : message);
1543
+ const promptMessages = buildCompactionPromptMessages(promptInput);
1558
1544
  const stream = this.provider.streamChat(promptMessages, {
1559
1545
  model: this.apiModel,
1560
1546
  temperature: 0.2,
@@ -1571,156 +1557,28 @@ export class Agent {
1571
1557
  // Strip any internal reminder markup the summarizer may have reproduced from
1572
1558
  // the transcript: this summary is both displayed in the compaction card and
1573
1559
  // re-injected as a `Previous conversation summary` system message.
1574
- return sanitizeInternalReminderBlocks(full).trim();
1560
+ const summary = sanitizeInternalReminderBlocks(full).trim();
1561
+ if (!summary)
1562
+ return "";
1563
+ return appendFileBlocks(summary, fileOps);
1575
1564
  }
1565
+ // ---- Subagent lifecycle: delegated to SubagentRuntime. ----
1566
+ // These stay on Agent as one-line forwards so every existing caller
1567
+ // (tools/agent-lifecycle.ts, orchestrator/default-hooks.ts) is unchanged.
1576
1568
  async runSubAgent(input, cwd, options) {
1577
- const record = this.createSubagentThreadRecord({
1578
- profile: options.profile,
1579
- task: typeof input === "string" ? input : "(multimodal task)",
1580
- runId: options.runId,
1581
- agentId: options.subAgentId,
1582
- parentToolCallId: options.parentToolCallId,
1583
- parentToolName: "subagent",
1584
- nickname: options.nickname,
1585
- route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
1586
- });
1587
- this.subagentStore.set(record);
1588
- const approval = options.approval ?? options.profile.approval;
1589
- const admissionError = this.admitSubagentProfile(record, approval);
1590
- if (admissionError) {
1591
- this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
1592
- return subagentResultFromThread(record);
1593
- }
1594
- record.promise = this.dispatchSubagentRun(record, input, cwd, {
1595
- approval,
1596
- abortSignal: options.abortSignal,
1597
- forkContext: options.forkContext,
1598
- directEmit: options.emitUpdate,
1599
- });
1600
- await record.promise;
1601
- return subagentResultFromThread(record);
1569
+ return this.subagents.runSubAgent(input, cwd, options);
1602
1570
  }
1603
1571
  async spawnSubAgent(input, cwd, options) {
1604
- const route = options.route
1605
- ?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort });
1606
- // Early validation (design §7): throws reach the model as a tool error it
1607
- // can correct this turn, instead of a late provider-factory failure.
1608
- const routeNote = this.validateRouteForDispatch(route);
1609
- this.noteRoutingDispatch(route);
1610
- const record = this.createSubagentThreadRecord({
1611
- profile: options.profile,
1612
- task: typeof input === "string" ? input : "(multimodal task)",
1613
- parentToolCallId: options.parentToolCallId,
1614
- parentToolName: "spawn_agent",
1615
- route,
1616
- });
1617
- if (routeNote)
1618
- record.toolNotes.push(routeNote);
1619
- this.subagentStore.set(record);
1620
- const approval = options.approval ?? record.profile.approval;
1621
- // Admission validation runs before queueing (design §4.2): a request that
1622
- // would block never consumes a queue slot.
1623
- const admissionError = this.admitSubagentProfile(record, approval);
1624
- if (admissionError) {
1625
- this.finalizeSubagentBlocked(record, admissionError, { queueUpdates: true });
1626
- return this.snapshotSubagent(record);
1627
- }
1628
- this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
1629
- record.promise = this.dispatchSubagentRun(record, input, cwd, {
1630
- approval,
1631
- abortSignal: options.abortSignal,
1632
- forkContext: options.forkContext,
1633
- queueUpdates: true,
1634
- });
1635
- void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1636
- return this.snapshotSubagent(record);
1572
+ return this.subagents.spawnSubAgent(input, cwd, options);
1637
1573
  }
1638
1574
  async waitSubAgents(options = {}) {
1639
- const targets = this.resolveSubagentTargets(options.agentIds);
1640
- if (targets.length === 0)
1641
- return [];
1642
- const completed = targets.filter((record) => isFinalSubagentStatus(record.status));
1643
- if (completed.length > 0) {
1644
- for (const record of completed)
1645
- this.subagentStore.markDelivered(record.agentId);
1646
- return completed.map((record) => this.snapshotSubagent(record));
1647
- }
1648
- const timeoutMs = normalizeWaitTimeout(options.timeoutMs);
1649
- let waiter;
1650
- await Promise.race([
1651
- new Promise((resolve) => {
1652
- waiter = resolve;
1653
- for (const record of targets) {
1654
- record.waiters.add(resolve);
1655
- }
1656
- }).finally(() => {
1657
- if (waiter) {
1658
- for (const record of targets) {
1659
- record.waiters.delete(waiter);
1660
- }
1661
- }
1662
- }),
1663
- new Promise((resolve) => setTimeout(resolve, timeoutMs)),
1664
- ]);
1665
- const finished = targets.filter((record) => isFinalSubagentStatus(record.status));
1666
- for (const record of finished)
1667
- this.subagentStore.markDelivered(record.agentId);
1668
- return (finished.length > 0 ? finished : targets).map((record) => this.snapshotSubagent(record));
1575
+ return this.subagents.waitSubAgents(options);
1669
1576
  }
1670
1577
  async sendSubAgentInput(agentId, input, cwd, options = {}) {
1671
- const record = this.subagentStore.get(agentId);
1672
- if (!record) {
1673
- throw new Error(`Unknown subagent: ${agentId}`);
1674
- }
1675
- if (record.status === "running" || record.status === "queued") {
1676
- if (!options.interrupt) {
1677
- throw new Error(`Subagent ${agentId} is still running. Call wait_agent first or pass interrupt:true.`);
1678
- }
1679
- record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} interrupted.`, "interrupt"));
1680
- await record.promise?.catch(() => undefined);
1681
- record.abortController = new AbortController();
1682
- }
1683
- if (record.status === "closed") {
1684
- throw new Error(`Subagent ${agentId} is closed.`);
1685
- }
1686
- record.parentToolCallId = options.parentToolCallId ?? record.parentToolCallId;
1687
- record.parentToolName = "send_input";
1688
- record.task = typeof input === "string" ? input : "(multimodal task)";
1689
- record.summary = "";
1690
- record.toolNotes = [];
1691
- record.usage = undefined;
1692
- record.error = undefined;
1693
- record.finalReason = undefined;
1694
- record.deliveredAt = undefined;
1695
- record.updatedAt = Date.now();
1696
- // A send_input restart is a launch like any other: it goes through the
1697
- // scheduler's dispatch point and is subject to the same admission limits
1698
- // (design §4.1) — batch-resuming team members cannot bypass concurrency caps.
1699
- record.promise = this.dispatchSubagentRun(record, input, cwd, {
1700
- approval: record.profile.approval,
1701
- abortSignal: options.abortSignal,
1702
- queueUpdates: true,
1703
- reuseAgent: true,
1704
- });
1705
- void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1706
- return this.snapshotSubagent(record);
1578
+ return this.subagents.sendSubAgentInput(agentId, input, cwd, options);
1707
1579
  }
1708
1580
  async closeSubAgent(agentId) {
1709
- const record = this.subagentStore.get(agentId);
1710
- if (!record) {
1711
- throw new Error(`Unknown subagent: ${agentId}`);
1712
- }
1713
- if (!isFinalSubagentStatus(record.status)) {
1714
- record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} closed.`, "user_close"));
1715
- await record.promise?.catch(() => undefined);
1716
- }
1717
- record.status = "closed";
1718
- record.finalReason = record.finalReason ?? "cancelled_user";
1719
- record.updatedAt = Date.now();
1720
- this.queueSubagentUpdate(record, "cancelled", undefined, `${record.nickname} closed`);
1721
- this.subagentStore.persist(record);
1722
- this.subagentStore.notifyWaiters(record);
1723
- return this.snapshotSubagent(record);
1581
+ return this.subagents.closeSubAgent(agentId);
1724
1582
  }
1725
1583
  /**
1726
1584
  * Live (non-final) children only — the delegation-nudge gate
@@ -1729,16 +1587,27 @@ export class Agent {
1729
1587
  * never be used to answer "am I currently delegating?".
1730
1588
  */
1731
1589
  activeSubAgentCount() {
1732
- return this.subagentStore.activeCount();
1590
+ return this.subagents.activeSubAgentCount();
1591
+ }
1592
+ listSubAgents() {
1593
+ return this.subagents.listSubAgents();
1594
+ }
1595
+ /** Marks a child's full summary as delivered to parent context (design §3.3). */
1596
+ markSubagentDelivered(agentId) {
1597
+ this.subagents.markSubagentDelivered(agentId);
1598
+ }
1599
+ /**
1600
+ * The child store, still reachable at its old name. Several tests inspect a
1601
+ * spawned child's record (and its live child Agent) through
1602
+ * `(agent as any).subagentStore`; keeping this forward means the extraction
1603
+ * changed no caller, test or otherwise.
1604
+ */
1605
+ get subagentStore() {
1606
+ return this.subagents.store;
1733
1607
  }
1734
1608
  /** run_workflow children are workflowInternal and invisible to listSubAgents. */
1735
1609
  hasRunningWorkflow() {
1736
- return [...this.workflowRuns.values()].some((record) => record.status === "running");
1737
- }
1738
- listSubAgents() {
1739
- return this.subagentStore.values()
1740
- .filter((record) => !record.workflowInternal)
1741
- .map((record) => this.snapshotSubagent(record));
1610
+ return this.workflowLedger.hasRunning();
1742
1611
  }
1743
1612
  /**
1744
1613
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
@@ -1751,7 +1620,7 @@ export class Agent {
1751
1620
  * completion and returns the result. Background runs go through startWorkflow.
1752
1621
  */
1753
1622
  async runWorkflow(cwd, options) {
1754
- return this.executeWorkflow(cwd, {
1623
+ return this.subagents.executeWorkflow(cwd, {
1755
1624
  script: options.script,
1756
1625
  args: options.args,
1757
1626
  parentToolCallId: options.parentToolCallId,
@@ -1767,692 +1636,78 @@ export class Agent {
1767
1636
  * result is ingested at the next turn. Collect explicitly with waitWorkflow.
1768
1637
  */
1769
1638
  startWorkflow(cwd, options) {
1770
- const runId = randomUUID();
1771
- const abortController = new AbortController();
1772
- const composed = composeAbortSignals([options.abortSignal, abortController.signal]);
1773
- if (composed) {
1774
- composed.addEventListener("abort", () => abortController.abort(composed.reason), { once: true });
1775
- }
1776
- const record = {
1777
- runId,
1778
- title: options.title ?? "workflow",
1779
- status: "running",
1780
- agentCount: 0,
1781
- snapshots: [],
1782
- logs: [],
1783
- abortController,
1784
- waiters: new Set(),
1785
- createdAt: Date.now(),
1786
- parentToolCallId: options.parentToolCallId,
1787
- };
1788
- this.workflowRuns.set(runId, record);
1789
- record.promise = this.executeWorkflow(cwd, {
1790
- script: options.script,
1791
- args: options.args,
1792
- parentToolCallId: options.parentToolCallId,
1793
- abortSignal: abortController.signal,
1794
- queueUpdates: true,
1795
- ensureProfileTrusted: options.ensureProfileTrusted,
1796
- }).then((out) => {
1797
- record.agentCount = out.agentCount;
1798
- record.snapshots = out.snapshots;
1799
- record.logs = out.logs;
1800
- record.result = out.result;
1801
- record.status = out.result.ok ? "completed" : (abortController.signal.aborted ? "cancelled" : "failed");
1802
- if (out.result.ok)
1803
- record.resultPath = persistWorkflowResult(runId, out.result.value);
1804
- }, (error) => {
1805
- record.result = { ok: false, error: error?.message || String(error) };
1806
- record.status = "failed";
1807
- }).finally(() => {
1808
- record.updatedAt = Date.now();
1809
- this.pendingWorkflowDeliveries.add(runId);
1810
- for (const waiter of record.waiters)
1811
- waiter();
1812
- record.waiters.clear();
1813
- });
1814
- return { runId, title: record.title };
1639
+ return this.workflowLedger.start(cwd, options);
1815
1640
  }
1816
1641
  /** Blocks until a background workflow reaches a final state (or times out). */
1817
1642
  async waitWorkflow(runId, timeoutMs) {
1818
- const record = this.workflowRuns.get(runId);
1819
- if (!record)
1820
- return undefined;
1821
- if (record.status === "running") {
1822
- const limit = normalizeWaitTimeout(timeoutMs);
1823
- let waiter;
1824
- await Promise.race([
1825
- new Promise((resolve) => { waiter = resolve; record.waiters.add(resolve); }),
1826
- new Promise((resolve) => setTimeout(resolve, limit)),
1827
- ]).finally(() => { if (waiter)
1828
- record.waiters.delete(waiter); });
1829
- }
1830
- if (record.status !== "running")
1831
- this.pendingWorkflowDeliveries.delete(runId);
1832
- return this.snapshotWorkflow(record);
1643
+ return this.workflowLedger.wait(runId, normalizeWaitTimeout(timeoutMs));
1833
1644
  }
1834
1645
  /** Cancels a running background workflow. */
1835
1646
  closeWorkflow(runId) {
1836
- const record = this.workflowRuns.get(runId);
1837
- if (!record)
1838
- return undefined;
1839
- if (record.status === "running")
1840
- record.abortController.abort(new Error("workflow cancelled"));
1841
- return this.snapshotWorkflow(record);
1647
+ return this.workflowLedger.close(runId);
1842
1648
  }
1843
1649
  listWorkflows() {
1844
- return [...this.workflowRuns.values()].map((record) => this.snapshotWorkflow(record));
1845
- }
1846
- snapshotWorkflow(record) {
1847
- return {
1848
- runId: record.runId,
1849
- title: record.title,
1850
- status: record.status,
1851
- agentCount: record.agentCount,
1852
- result: record.result,
1853
- resultPath: record.resultPath,
1854
- logs: record.logs,
1855
- snapshots: record.snapshots,
1856
- };
1650
+ return this.workflowLedger.list();
1857
1651
  }
1858
1652
  /** Injects completed background-workflow results before the next turn (§5 analog). */
1859
1653
  flushWorkflowDeliveries() {
1860
- if (this.pendingWorkflowDeliveries.size === 0)
1861
- return;
1862
- for (const runId of [...this.pendingWorkflowDeliveries]) {
1863
- this.pendingWorkflowDeliveries.delete(runId);
1864
- const record = this.workflowRuns.get(runId);
1865
- if (!record || record.status === "running" || record.deliveredAt)
1866
- continue;
1867
- record.deliveredAt = Date.now();
1868
- this.injectSystemReminder(buildWorkflowDeliveryNotice(this.snapshotWorkflow(record)));
1869
- }
1870
- }
1871
- async executeWorkflow(cwd, options) {
1872
- const profiles = discoverAgentProfiles(cwd, "both").profiles;
1873
- const runRecords = [];
1874
- const logs = [];
1875
- // Per-run isolation (option C review): a concurrency sub-cap below the
1876
- // global limit so a workflow can't starve interactive subagents.
1877
- const interactiveReserve = 2;
1878
- const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
1879
- const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
1880
- const gate = new WorkflowConcurrencyGate(workflowConcurrency);
1881
- const dispatchAgent = async (spec) => {
1882
- const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
1883
- ?? findAgentProfile(profiles, "default");
1884
- if (!baseProfile)
1885
- return { ok: false, error: "no default subagent profile available" };
1886
- // Workflow agents are readonly-by-default; mode upgrades come only from the
1887
- // profile, never from the script (security invariant).
1888
- const unsupported = baseProfile.mode !== "readonly" && baseProfile.mode !== "write_worktree";
1889
- if (unsupported)
1890
- return { ok: false, error: `profile "${baseProfile.name}" mode ${baseProfile.mode} not supported` };
1891
- // Default-no-network: unattended orchestration of net-capable agents is new
1892
- // authority in aggregate (option C review), so strip web tools unless the
1893
- // script opts in with agentType pointing at a profile that includes them.
1894
- const profile = {
1895
- ...baseProfile,
1896
- tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
1897
- };
1898
- let route;
1899
- let routeNote;
1900
- try {
1901
- route = this.resolveRouteForSubagent(profile, spec.opts.category, {
1902
- model: spec.opts.model,
1903
- effort: parseThinkingLevel(spec.opts.effort),
1904
- });
1905
- // Dispatch-time validation + defaulted-fan-out accounting (§6–7):
1906
- // resolved routes, never script source text.
1907
- routeNote = this.validateRouteForDispatch(route);
1908
- this.noteRoutingDispatch(route);
1909
- }
1910
- catch (error) {
1911
- return { ok: false, error: error?.message || String(error) };
1912
- }
1913
- const baseTask = spec.opts.schema !== undefined
1914
- ? appendOutputSchemaInstructions(spec.prompt, spec.opts.schema)
1915
- : spec.prompt;
1916
- const record = this.createSubagentThreadRecord({
1917
- profile,
1918
- task: baseTask,
1919
- parentToolCallId: options.parentToolCallId,
1920
- parentToolName: "run_workflow",
1921
- route,
1922
- workflowInternal: true,
1923
- });
1924
- record.expectsStructuredOutput = spec.opts.schema !== undefined;
1925
- if (routeNote)
1926
- record.toolNotes.push(routeNote);
1927
- const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
1928
- if (memberLabel)
1929
- record.nickname = memberLabel;
1930
- runRecords.push(record);
1931
- this.subagentStore.set(record);
1932
- // Project-local profiles pass the same first-use trust gate as
1933
- // spawn_agent: a .bubble/agents profile must never gain a side door
1934
- // into execution just because a script named it (Codex review on #58).
1935
- // Checked AFTER the record exists so a rejected member still shows up
1936
- // in the run's counts/snapshots as blocked instead of vanishing.
1937
- if (options.ensureProfileTrusted) {
1938
- const blocked = await options.ensureProfileTrusted(baseProfile);
1939
- if (blocked) {
1940
- const message = typeof blocked.content === "string" ? blocked.content : `profile "${baseProfile.name}" requires user approval`;
1941
- this.finalizeSubagentBlocked(record, message, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
1942
- return { ok: false, error: message };
1943
- }
1944
- }
1945
- const admissionError = this.admitSubagentProfile(record, profile.approval);
1946
- if (admissionError) {
1947
- this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
1948
- return { ok: false, error: admissionError };
1949
- }
1950
- // Leaf-only concurrency permit (option C review M5): held ONLY around this
1951
- // agent's dispatch, never across parallel/pipeline composition.
1952
- await gate.acquire();
1953
- try {
1954
- record.promise = this.dispatchSubagentRun(record, baseTask, cwd, {
1955
- approval: profile.approval,
1956
- abortSignal: options.abortSignal,
1957
- directEmit: options.directEmit,
1958
- queueUpdates: options.queueUpdates,
1959
- });
1960
- await record.promise;
1961
- }
1962
- finally {
1963
- gate.release();
1964
- }
1965
- this.subagentStore.markDelivered(record.agentId);
1966
- if (record.status !== "completed") {
1967
- return { ok: false, error: record.error || `agent ${record.nickname} ended: ${record.finalReason ?? record.status}` };
1968
- }
1969
- if (spec.opts.schema === undefined) {
1970
- return { ok: true, value: record.summary };
1971
- }
1972
- // Structured output: validate, one corrective retry, then fall back to raw.
1973
- // The schema: log lines are the telemetry that decides whether the
1974
- // deferred terminate-style structured-output tool ever gets reopened.
1975
- let validated = validateStructuredSummary(record.summary, spec.opts.schema);
1976
- if (!validated.ok) {
1977
- logs.push(`schema: ${record.nickname} first output failed validation; sending one corrective retry`);
1978
- try {
1979
- await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
1980
- await record.promise?.catch(() => undefined);
1981
- validated = validateStructuredSummary(record.summary, spec.opts.schema);
1982
- }
1983
- catch {
1984
- // resume failed; fall through to raw summary
1985
- }
1986
- logs.push(validated.ok
1987
- ? `schema: ${record.nickname} corrective retry produced valid output`
1988
- : `schema: ${record.nickname} corrective retry still invalid; returning raw summary`);
1989
- }
1990
- return { ok: true, value: validated.ok ? validated.value : record.summary };
1991
- };
1992
- const result = await runWorkflow({
1993
- script: options.script,
1994
- args: options.args,
1995
- dispatchAgent,
1996
- onLog: (message) => logs.push(message),
1997
- onPhase: (title) => logs.push(`— phase: ${title} —`),
1998
- budget: {
1999
- // The ledger is pure accounting (no pool limit); scripts see an
2000
- // unlimited budget unless a future host contract reintroduces one.
2001
- total: null,
2002
- spent: () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0),
2003
- remaining: () => Number.POSITIVE_INFINITY,
2004
- },
2005
- signal: options.abortSignal,
2006
- });
2007
- return {
2008
- result,
2009
- agentCount: runRecords.length,
2010
- logs,
2011
- snapshots: runRecords.map((record) => this.snapshotSubagent(record)),
2012
- };
2013
- }
2014
- /** Marks a child's full summary as delivered to parent context (design §3.3). */
2015
- markSubagentDelivered(agentId) {
2016
- this.subagentStore.markDelivered(agentId);
2017
- }
2018
- snapshotSubagent(record) {
2019
- const snapshot = snapshotSubagentThread(record);
2020
- if (record.status === "queued") {
2021
- const queuePosition = this.subagentScheduler.queuePosition(record.agentId);
2022
- if (queuePosition !== undefined)
2023
- return { ...snapshot, queuePosition };
2024
- }
2025
- return snapshot;
2026
- }
2027
- /** Returns the blocking diagnostic message when the profile cannot run, else undefined. */
2028
- admitSubagentProfile(record, approval) {
2029
- const diagnostics = validateAgentProfileTools([...this.tools.values()], record.profile, approval);
2030
- const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
2031
- if (blocking.length === 0)
2032
- return undefined;
2033
- return blocking.map((diagnostic) => diagnostic.message).join("\n");
2034
- }
2035
- /**
2036
- * Background children (queueUpdates) get their results ingested before the
2037
- * parent's next inference turn (design §5); foreground children (team,
2038
- * legacy task) deliver through their tool result instead.
2039
- */
2040
- maybeEnqueueIngestion(record, options) {
2041
- if (options.queueUpdates) {
2042
- this.resultIntegrator.enqueue(record.agentId);
2043
- }
2044
- }
2045
- flushSubagentIngestions() {
2046
- if (!this.resultIntegrator.hasPending())
2047
- return;
2048
- for (const notice of this.resultIntegrator.drainNotices(this.subagentStore)) {
1654
+ for (const notice of this.workflowLedger.drainDeliveryNotices()) {
2049
1655
  this.injectSystemReminder(notice);
2050
1656
  }
2051
1657
  }
2052
- finalizeSubagentBlocked(record, error, emitOptions) {
2053
- record.status = "blocked";
2054
- record.finalReason = "blocked";
2055
- record.error = error;
2056
- record.updatedAt = Date.now();
2057
- this.emitSubagentLifecycle(record, emitOptions, "blocked", undefined, error);
2058
- this.subagentStore.persist(record);
2059
- this.subagentStore.notifyWaiters(record);
2060
- }
2061
- dispatchSubagentRun(record, input, cwd, options) {
2062
- record.status = "queued";
2063
- record.updatedAt = Date.now();
2064
- const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
2065
- return this.subagentScheduler.dispatch({
2066
- agentId: record.agentId,
2067
- category: record.category,
2068
- signal: queueSignal,
2069
- run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
2070
- onCancelledWhileQueued: (reason) => {
2071
- record.status = "cancelled";
2072
- record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
2073
- record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
2074
- record.updatedAt = Date.now();
2075
- // The run never started, so no SubagentStart fired and no SubagentStop follows.
2076
- this.emitSubagentLifecycle(record, options, "cancelled", undefined, record.error);
2077
- this.subagentStore.persist(record);
2078
- this.subagentStore.notifyWaiters(record);
2079
- this.maybeEnqueueIngestion(record, options);
2080
- },
2081
- onRateLimitExhausted: (attempts) => {
2082
- record.status = "failed";
2083
- record.finalReason = "rate_limited_exhausted";
2084
- record.error = `Provider rate limit persisted after ${attempts} attempts.`;
2085
- record.updatedAt = Date.now();
2086
- void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
2087
- this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
2088
- this.subagentStore.persist(record);
2089
- this.subagentStore.notifyWaiters(record);
2090
- this.maybeEnqueueIngestion(record, options);
2091
- },
2092
- onTransportRetryExhausted: (attempts) => {
2093
- record.status = "failed";
2094
- // failed_transient stays resumable, so the parent can still send_input
2095
- // to recover the child with its context intact.
2096
- record.finalReason = "failed_transient";
2097
- record.error = `Provider transport error persisted after ${attempts} attempts.`;
2098
- record.updatedAt = Date.now();
2099
- void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
2100
- this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
2101
- this.subagentStore.persist(record);
2102
- this.subagentStore.notifyWaiters(record);
2103
- this.maybeEnqueueIngestion(record, options);
2104
- },
2105
- });
2106
- }
2107
- emitSubagentLifecycle(record, options, status, event, message) {
2108
- const update = this.buildSubagentUpdate(record, status, event, message);
2109
- options.directEmit?.(update);
2110
- if (options.queueUpdates) {
2111
- this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
2112
- this.wakeSubagentUpdateWaiters();
2113
- }
2114
- }
2115
- /** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
2116
- wakeSubagentUpdateWaiters() {
2117
- for (const wake of this.subagentUpdateWakers)
2118
- wake();
2119
- }
2120
- async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
2121
- try {
2122
- await this.runExternalHook({
2123
- eventName,
2124
- cwd,
2125
- runId: record.runId,
2126
- target: record.profile.name,
2127
- payload: {
2128
- agentId: record.agentId,
2129
- nickname: record.nickname,
2130
- profile: record.profile.name,
2131
- status,
2132
- error,
2133
- },
2134
- }, abortSignal);
2135
- }
2136
- catch {
2137
- // Subagent lifecycle hooks are observe-only; never fail the subagent.
2138
- }
2139
- }
2140
1658
  /**
2141
1659
  * Resolves a child's model route. Priority, highest first (design v2 §1.1):
2142
1660
  * call-site override (model/effort) > profile.model > category > inherit parent.
2143
1661
  * The call-site override is what lets the model say "opus for this reviewer,
2144
1662
  * haiku for these twenty scouts" per spawn/batch member at request time.
2145
1663
  */
1664
+ /** Kept as a thin delegator: routing decisions now live in SubagentRouter. */
2146
1665
  resolveRouteForSubagent(profile, category, override) {
2147
- const parentRoute = {
2148
- providerId: this.providerId,
2149
- model: this.apiModel,
2150
- thinkingLevel: this.thinkingLevel,
2151
- };
2152
- const snapshot = this.currentRoutingSnapshot();
2153
- const resolved = resolveSubagentRoute(category ?? profile.category, { ...parentRoute }, this.agentCategories, snapshot ? tierContextFromSnapshot(snapshot, this.agentRouting) : undefined);
2154
- if ("error" in resolved) {
2155
- throw new Error(resolved.error);
2156
- }
2157
- let route = resolved.route;
2158
- // modelSource is assigned while applying the chain (design §3.4): an
2159
- // explicit layer naming the parent's own model is indistinguishable from
2160
- // inherit by final-value comparison, yet carries different authority.
2161
- let modelSource = route.categoryModelSource ?? "inherit";
2162
- if (profile.model && profile.model !== "inherit") {
2163
- const model = resolveModelRoute(profile.model, parentRoute.providerId);
2164
- if (model.model !== "inherit") {
2165
- route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2166
- modelSource = "profile";
2167
- }
2168
- }
2169
- // Call-site override beats profile and category. Bare names resolve
2170
- // against the PARENT provider per the tool contract (design §3.5) — not
2171
- // against a provider that category/profile may already have switched.
2172
- if (override?.model) {
2173
- const model = resolveModelRoute(override.model, parentRoute.providerId);
2174
- if (model.model !== "inherit") {
2175
- route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2176
- modelSource = "callsite";
2177
- }
2178
- }
2179
- const supportedLevels = getAvailableThinkingLevels(route.providerId, route.model);
2180
- const modelMetadata = getBuiltinModel(route.providerId, route.model);
2181
- const hasTrustedEffortMetadata = !!modelMetadata
2182
- && modelMetadata.reasoningLevels.some((level) => level !== "off");
2183
- if (override?.effort) {
2184
- // A call-site effort is explicit user/model intent: preserve the existing
2185
- // value for legacy/unknown models, and downward-clamp only when the
2186
- // catalog declares real effort capabilities (for example Luna ultra -> max).
2187
- route = {
2188
- ...route,
2189
- thinkingLevel: hasTrustedEffortMetadata
2190
- ? normalizeThinkingLevel(override.effort, supportedLevels)
2191
- : override.effort,
2192
- inherited: false,
2193
- };
2194
- }
2195
- else if (hasTrustedEffortMetadata) {
2196
- const categoryThinkingLevel = route.category
2197
- ? mergeAgentCategories(this.agentCategories)[route.category]?.thinkingLevel
2198
- : undefined;
2199
- // A category's thinkingLevel is calibrated for the model the CATEGORY
2200
- // resolved. When a later layer (profile/call-site) replaced the model,
2201
- // that level is no longer explicit intent for the final model: keep it
2202
- // only if supported, else use the final model's own default — never
2203
- // downward-clamp a thinking-default model to "off" (v3.6; live case:
2204
- // explore's "low" + call-site glm-5.2 [high/max/off] silently landed
2205
- // on "off").
2206
- const modelReplacedAfterCategory = modelSource === "profile" || modelSource === "callsite";
2207
- route = {
2208
- ...route,
2209
- thinkingLevel: categoryThinkingLevel
2210
- ? (modelReplacedAfterCategory && !supportedLevels.includes(route.thinkingLevel)
2211
- ? getDefaultThinkingLevel(route.providerId, route.model)
2212
- : normalizeThinkingLevel(route.thinkingLevel, supportedLevels))
2213
- : normalizeInheritedThinkingLevel(route.providerId, route.model, route.thinkingLevel),
2214
- };
2215
- }
2216
- return {
2217
- ...route,
2218
- modelSource,
2219
- modelInherited: route.providerId === parentRoute.providerId && route.model === parentRoute.model,
2220
- };
2221
- }
2222
- /** Live snapshot for the CURRENT parent route; undefined when no accessor is wired. */
2223
- currentRoutingSnapshot() {
2224
- if (!this.routingSnapshotAccessor)
2225
- return undefined;
2226
- try {
2227
- return this.routingSnapshotAccessor({ providerId: this.providerId, model: this.apiModel });
2228
- }
2229
- catch {
2230
- // Catalog data is an enhancement, never a spawn blocker.
2231
- return undefined;
2232
- }
2233
- }
2234
- /**
2235
- * Early route validation at dispatch time (design §7). Throws with an
2236
- * actionable message so the model self-corrects in the same turn. The
2237
- * cross-provider lock (§7.2.1) is snapshot-independent; catalog checks
2238
- * degrade silently when no snapshot is available (§7.0).
2239
- */
2240
- validateRouteForDispatch(route) {
2241
- const snapshot = this.currentRoutingSnapshot();
2242
- const crossProvider = !!route.providerId && route.providerId !== this.providerId;
2243
- if (crossProvider) {
2244
- // §7.2.1 — the lock. Profile/user-category routes are standing user
2245
- // authorization and always pass; only call-site routes are lockable.
2246
- if (!this.agentRouting.allowCrossProvider && route.modelSource === "callsite") {
2247
- throw new Error("Cross-provider routing is disabled in this session's config (agentRouting.allowCrossProvider). "
2248
- + "Use a model from the parent provider, or ask the user to unlock cross-provider routing.");
2249
- }
2250
- // §7.2.2 — credentials, when a snapshot is available.
2251
- if (snapshot && !snapshot.runnableProviderIds.includes(route.providerId)) {
2252
- throw new Error(`Provider "${route.providerId}" is not configured with active credentials. `
2253
- + `Available: ${snapshot.runnableProviderIds.join(", ") || "(none)"}.`);
2254
- }
2255
- // §7.2.3 amended (v3.6): the provider stays the authority — no hard
2256
- // catalog rejection — but a near-match against the target provider's
2257
- // local catalog is positive evidence of a mistyped id (a Grok parent
2258
- // invented "openai:gpt-5.6" for gpt-5.6-sol), so soft-reject with the
2259
- // correction and let the model fix it this turn. Genuinely unknown ids
2260
- // (no near candidates) still pass through with a note.
2261
- const targetCatalog = this.routableModelIndex?.()
2262
- .filter((entry) => entry.providerId === route.providerId) ?? [];
2263
- if (targetCatalog.length > 0 && !targetCatalog.some((entry) => entry.id === route.model)) {
2264
- const near = nearModelMatches(route.model, targetCatalog, { mode: "truncation" });
2265
- if (near.length > 0) {
2266
- throw new Error(`Unknown model "${route.model}" for provider "${route.providerId}". Did you mean: ${near.join(", ")}?`);
2267
- }
2268
- return `model ${route.providerId}:${route.model} is not in the local catalog; the provider validates it`;
2269
- }
2270
- return `model ${route.providerId}:${route.model} is not locally verifiable; the provider validates it`;
2271
- }
2272
- // §7.1 — same-provider unknown model, resolved-provider based (never
2273
- // input-syntax based; qualified same-provider ids land here too).
2274
- if (snapshot && route.modelSource === "callsite") {
2275
- const known = snapshot.models.some((model) => model.id === route.model);
2276
- if (!known) {
2277
- if (snapshot.authoritative) {
2278
- const available = snapshot.models
2279
- .map((model) => (model.tier ? `${model.id} (${model.tier})` : model.id))
2280
- .join(", ");
2281
- throw new Error(`Unknown model "${route.model}" for provider "${this.providerId}". Available: ${available}.`);
2282
- }
2283
- const near = nearModelMatches(route.model, snapshot.models.map((model) => ({ providerId: this.providerId, id: model.id, name: model.name })));
2284
- return near.length > 0
2285
- ? `model id "${route.model}" is unrecognized locally (did you mean: ${near.join(", ")}?); the provider validates it`
2286
- : `model id "${route.model}" is unrecognized locally; the provider validates it`;
2287
- }
2288
- }
2289
- return undefined;
1666
+ return this.router.resolve(profile, category, override);
2290
1667
  }
2291
1668
  /** Routable catalog across runnable providers (design v3.6); undefined when unwired. */
2292
1669
  listRoutableModels() {
2293
- try {
2294
- return this.routableModelIndex?.();
2295
- }
2296
- catch {
2297
- return undefined;
2298
- }
1670
+ return this.router.listRoutableModels();
2299
1671
  }
2300
1672
  /**
2301
- * Decision-point detector (design §6): counts dispatches whose model was
2302
- * decided by NO routing layer (modelSource "inherit") under a strong-tier
2303
- * parent. Fires once per session at the Nth qualifying dispatch; the
2304
- * reminder rides the same channel as the lifecycle reminder.
1673
+ * The ONE place a child Agent is constructed. The runtime hands over what it
1674
+ * resolved (provider/route/tools/prompt); everything inherited from the
1675
+ * parent is filled in here, which is also what keeps the deliberately
1676
+ * ABSENT arguments absent — `onMessageAppend` (a child must not append to
1677
+ * the parent's session log), `sessionID` (a child must not stop the
1678
+ * parent's auto servers) and the routing catalog (children route by
1679
+ * default, not from the parent's menu).
2305
1680
  */
2306
- noteRoutingDispatch(route) {
2307
- if (this.routingReminderFired)
2308
- return;
2309
- if (route.modelSource !== "inherit")
2310
- return;
2311
- const snapshot = this.currentRoutingSnapshot();
2312
- if (snapshot?.parent.tier !== "strong")
2313
- return;
2314
- this.defaultedRoutingStreak++;
2315
- if (this.defaultedRoutingStreak >= ROUTING_REMINDER_THRESHOLD) {
2316
- this.routingReminderFired = true;
2317
- this.pendingRoutingReminder = [
2318
- `Routing note: ${this.defaultedRoutingStreak} children in this fan-out defaulted to the parent's`,
2319
- "strong-tier model (no model/category given). If any of these tasks are mechanical",
2320
- "(scan / summarize / search / extract), route them with category \"quick\"/\"explore\" or a",
2321
- "fast-tier model next time. If they genuinely need this model, ignore this note.",
2322
- ].join(" ");
2323
- }
2324
- }
2325
- /** Consumed by the turn hooks; also closes the counting window (§6). */
2326
- consumePendingRoutingReminder() {
2327
- const reminder = this.pendingRoutingReminder;
2328
- this.pendingRoutingReminder = undefined;
2329
- if (!this.routingReminderFired)
2330
- this.defaultedRoutingStreak = 0;
2331
- return reminder;
2332
- }
2333
- createSubagentThreadRecord(options) {
2334
- const now = Date.now();
2335
- const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
2336
- return {
2337
- agentId: options.agentId ?? randomUUID(),
2338
- runId: options.runId ?? randomUUID(),
2339
- nickname,
2340
- profile: options.profile,
2341
- category: options.route?.category,
2342
- route: options.route,
2343
- workflowInternal: options.workflowInternal,
2344
- parentToolCallId: options.parentToolCallId,
2345
- parentToolName: options.parentToolName,
2346
- status: "queued",
2347
- task: options.task,
2348
- summary: "",
2349
- toolNotes: [],
2350
- createdAt: now,
2351
- updatedAt: now,
2352
- abortController: new AbortController(),
2353
- waiters: new Set(),
2354
- };
2355
- }
2356
- runSubagentThread(record, input, cwd, options) {
2357
- return this.childRunner.run(record, input, cwd, options);
2358
- }
2359
- async createSubAgentInstance(record, tools, cwd, forkContext) {
2360
- let childCwd = cwd;
2361
- let childMode = "plan";
2362
- if (record.profile.mode === "write_worktree") {
2363
- // Write children work in a runtime-allocated worktree with fresh tool
2364
- // instances bound to it (design §8): the parent tree is never touched,
2365
- // and the tools' own workspace fence enforces containment in code.
2366
- if (!record.worktree) {
2367
- record.worktree = createSubagentWorktree(cwd, record.agentId);
2368
- }
2369
- childCwd = record.worktree.path;
2370
- childMode = "default";
2371
- tools = createWorktreeChildTools(childCwd, record.profile.tools.include);
2372
- }
2373
- else {
2374
- // Readonly children share the parent's tool instances; isolate the only
2375
- // one with mutable file state (read → its FileStateTracker) so concurrent
2376
- // fan-out members never race shared tool state (design v2 §2).
2377
- tools = isolateReadonlyChildFileTools(tools);
2378
- }
2379
- const childToolNames = tools.map((tool) => tool.name);
2380
- const route = record.route ?? {
2381
- providerId: this.providerId,
2382
- model: this.apiModel,
2383
- thinkingLevel: this.thinkingLevel,
2384
- inherited: true,
2385
- };
2386
- const provider = await this.resolveProviderForRoute(route);
2387
- const childSystemPrompt = buildSystemPrompt({
2388
- agentName: "Bubble",
2389
- configuredProvider: route.providerId || "none",
2390
- configuredModel: route.model || "none",
2391
- configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
2392
- thinkingLevel: route.thinkingLevel,
2393
- mode: childMode,
2394
- workingDir: childCwd,
2395
- ...buildToolPromptOptions(tools),
2396
- memoryPrompt: childToolNames.some((name) => name === "memory")
2397
- ? this.memoryPrompt
2398
- : undefined,
2399
- agentProfilePrompt: [
2400
- `You are subagent ${record.nickname}. Your agent profile is ${record.profile.name}.`,
2401
- record.profile.mode === "write_worktree"
2402
- ? [
2403
- "You work inside an isolated git worktree; the parent reviews your diff after you finish.",
2404
- "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.",
2405
- "Do not commit, push, or touch anything outside this worktree.",
2406
- ].join(" ")
2407
- : "",
2408
- record.profile.prompt,
2409
- ].filter(Boolean).join("\n\n"),
2410
- });
2411
- const subAgent = new Agent({
2412
- provider,
2413
- providerId: route.providerId,
2414
- model: route.model,
2415
- tools,
1681
+ createChildAgent(spec) {
1682
+ const child = new Agent({
1683
+ provider: spec.provider,
1684
+ providerId: spec.providerId,
1685
+ model: spec.model,
1686
+ tools: spec.tools,
2416
1687
  temperature: this.temperature,
2417
- thinkingLevel: route.thinkingLevel,
2418
- mode: childMode,
2419
- maxTurns: record.profile.maxTurns,
1688
+ thinkingLevel: spec.thinkingLevel,
1689
+ mode: spec.mode,
1690
+ maxTurns: spec.maxTurns,
2420
1691
  budgetLedger: this.budgetLedger,
2421
- budgetSource: { runId: record.runId, subAgentId: record.agentId },
2422
- systemPrompt: childSystemPrompt,
1692
+ budgetSource: spec.budgetSource,
1693
+ systemPrompt: spec.systemPrompt,
2423
1694
  hooks: this.hookDefinitions,
2424
1695
  externalHooks: this.externalHooks,
2425
1696
  agentRole: "subagent",
2426
- subAgentId: record.agentId,
1697
+ subAgentId: spec.subAgentId,
2427
1698
  agentCategories: this.agentCategories,
2428
1699
  providerFactory: this.providerFactory,
2429
1700
  // The scheduler owns 429 backoff for children; the transport must not
2430
1701
  // stack its own retries on top (design §4.5).
2431
1702
  rateLimitPolicy: "defer",
2432
1703
  });
2433
- if (record.messages && record.messages.length > 0) {
2434
- // Cross-restart resume (design §7): rebuild the child from its
2435
- // persisted history — including its original system prompt — so
2436
- // send_input continues with context intact.
2437
- subAgent.messages = record.messages.map((message) => ({ ...message }));
2438
- record.messages = undefined;
1704
+ if (spec.resumeMessages && spec.resumeMessages.length > 0) {
1705
+ child.messages = spec.resumeMessages;
2439
1706
  }
2440
- else if (forkContext) {
2441
- subAgent.messages = this.forkMessagesForSubagent(childSystemPrompt);
1707
+ else if (spec.forkContext) {
1708
+ child.messages = this.forkMessagesForSubagent(spec.systemPrompt);
2442
1709
  }
2443
- return subAgent;
2444
- }
2445
- async resolveProviderForRoute(route) {
2446
- if (!route.providerId || route.providerId === this.providerId) {
2447
- return this.provider;
2448
- }
2449
- if (!this.providerFactory) {
2450
- throw new Error([
2451
- `Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
2452
- `but the parent agent only has provider "${this.providerId || "none"}" and no provider factory is configured.`,
2453
- ].join(" "));
2454
- }
2455
- return this.providerFactory(route);
1710
+ return child;
2456
1711
  }
2457
1712
  forkMessagesForSubagent(childSystemPrompt) {
2458
1713
  const forked = this.messages
@@ -2470,74 +1725,6 @@ export class Agent {
2470
1725
  .slice(-20);
2471
1726
  return [{ role: "system", content: childSystemPrompt }, ...forked];
2472
1727
  }
2473
- buildSubagentUpdate(record, status, event, message) {
2474
- return {
2475
- type: "subagent_update",
2476
- parentToolCallId: record.parentToolCallId,
2477
- runId: record.runId,
2478
- subAgentId: record.agentId,
2479
- agentName: record.profile.name,
2480
- nickname: record.nickname,
2481
- category: record.category,
2482
- route: record.route,
2483
- status,
2484
- childEvent: event,
2485
- summaryDelta: event?.type === "text_delta" ? event.content : undefined,
2486
- toolName: "name" in (event ?? {}) ? event.name : undefined,
2487
- toolCallId: "id" in (event ?? {}) ? event.id : undefined,
2488
- message,
2489
- metadata: {
2490
- kind: "subagent",
2491
- runId: record.runId,
2492
- subagents: [{
2493
- subAgentId: record.agentId,
2494
- agentName: record.profile.name,
2495
- nickname: record.nickname,
2496
- category: record.category,
2497
- route: record.route,
2498
- status,
2499
- profileSource: record.profile.source,
2500
- task: record.task,
2501
- summary: record.summary,
2502
- toolNotes: record.toolNotes,
2503
- usage: record.usage,
2504
- error: record.error,
2505
- }],
2506
- },
2507
- };
2508
- }
2509
- queueSubagentUpdate(record, status, event, message) {
2510
- this.pendingSubagentUpdates.push({
2511
- id: record.parentToolCallId,
2512
- name: record.parentToolName,
2513
- update: this.buildSubagentUpdate(record, status, event, message),
2514
- });
2515
- this.wakeSubagentUpdateWaiters();
2516
- }
2517
- drainSubagentToolUpdates() {
2518
- return this.pendingSubagentUpdates.splice(0, this.pendingSubagentUpdates.length)
2519
- .map((pending) => ({
2520
- type: "tool_update",
2521
- id: pending.id,
2522
- name: pending.name,
2523
- update: pending.update,
2524
- }));
2525
- }
2526
- activeSubagentNicknames() {
2527
- return this.subagentStore.active().map((record) => record.nickname);
2528
- }
2529
- resolveSubagentTargets(agentIds) {
2530
- if (!agentIds || agentIds.length === 0) {
2531
- return this.subagentStore.values().filter((record) => record.status !== "closed" && !record.workflowInternal);
2532
- }
2533
- return agentIds.map((id) => {
2534
- const record = this.subagentStore.get(id);
2535
- if (!record) {
2536
- throw new Error(`Unknown subagent: ${id}`);
2537
- }
2538
- return record;
2539
- });
2540
- }
2541
1728
  maybeCompactResidentHistory() {
2542
1729
  if (this.messages.length === 0) {
2543
1730
  return;
@@ -2550,24 +1737,28 @@ export class Agent {
2550
1737
  ? getContextBudget(this.providerId, this.apiModel, candidate)
2551
1738
  : undefined;
2552
1739
  const heapUsed = getCurrentHeapUsed();
2553
- const residentChars = estimateResidentChars(candidate);
2554
1740
  const keepRecentTurns = countUserTurns(candidate) > 10
2555
1741
  ? 2
2556
1742
  : RESIDENT_HISTORY_KEEP_RECENT_TURNS;
2557
- const shouldAggressivelyPrune = residentChars >= RESIDENT_HISTORY_CHAR_HARD_LIMIT
2558
- || heapUsed >= RESIDENT_HISTORY_HEAP_HARD_LIMIT;
2559
- const shouldCompact = !!budget?.shouldCompact
2560
- || candidate.length >= RESIDENT_HISTORY_MESSAGE_LIMIT
2561
- || residentChars >= RESIDENT_HISTORY_CHAR_SOFT_LIMIT;
1743
+ // Cliff-edge only (docs/harness-thinning.md, wave 2): history is the
1744
+ // model's working memory and is NEVER trimmed early. The message-count
1745
+ // and char-size triggers that used to fire here mid-run created re-read
1746
+ // loops - the model lost what it had read and read it again. Compaction
1747
+ // now waits for the context-window budget itself; the heap guard stays
1748
+ // as process life-support, not policy.
1749
+ const shouldAggressivelyPrune = heapUsed >= RESIDENT_HISTORY_HEAP_HARD_LIMIT;
1750
+ const shouldCompact = !!budget?.shouldCompact;
2562
1751
  if (shouldAggressivelyPrune) {
2563
1752
  candidate = aggressivePruneMessages(candidate);
2564
1753
  }
2565
1754
  let compactedPath;
1755
+ let residentSummary;
2566
1756
  if (shouldCompact) {
2567
1757
  const compacted = compactMessages(candidate, { keepRecentTurns });
2568
1758
  if (compacted.compacted && compacted.messages) {
2569
1759
  candidate = compacted.messages;
2570
1760
  compactedPath = "resident";
1761
+ residentSummary = compacted.summary;
2571
1762
  traceEvent("compaction_fired", { path: "resident", droppedEntries: compacted.droppedEntries });
2572
1763
  }
2573
1764
  else {
@@ -2595,12 +1786,28 @@ export class Agent {
2595
1786
  this.lastInputTokens = null;
2596
1787
  this.lastAnchorMessageCount = null;
2597
1788
  this.fileStateTracker?.invalidateReadHistory();
2598
- if (compactedPath === "resident")
1789
+ if (compactedPath === "resident") {
2599
1790
  this.compactionStats.resident += 1;
1791
+ this.persistCompactionSummary(residentSummary);
1792
+ }
2600
1793
  if (compactedPath === "subturn")
2601
1794
  this.compactionStats.subturn += 1;
2602
1795
  }
2603
1796
  }
1797
+ /**
1798
+ * Hand a multi-turn compaction summary to the host for session persistence.
1799
+ * Failure is swallowed: losing persistence must never break the run itself.
1800
+ */
1801
+ persistCompactionSummary(summary) {
1802
+ if (!summary?.trim())
1803
+ return;
1804
+ try {
1805
+ this.onCompactionApplied?.(summary);
1806
+ }
1807
+ catch {
1808
+ // ignore — persistence is best-effort
1809
+ }
1810
+ }
2604
1811
  appendMessage(message) {
2605
1812
  if (message.role === "assistant" && message.content) {
2606
1813
  message.content = sanitizeInternalReminderBlocks(message.content);
@@ -2920,10 +2127,18 @@ function cancelledToolResult(toolName) {
2920
2127
  metadata: { reason: "cancelled" },
2921
2128
  };
2922
2129
  }
2923
- function createUpdateQueue() {
2130
+ /** Exported for tests: the wake latch below is easiest to pin directly. */
2131
+ export function createUpdateQueue() {
2924
2132
  const items = [];
2925
2133
  let waiter;
2926
2134
  let abortCleanup;
2135
+ // Latch for wakes that arrive with no parked waiter — a wake() fired while
2136
+ // the consuming generator is suspended at a yield would otherwise be lost.
2137
+ // Callers today each pair their wake with a synchronously-checked flag
2138
+ // (items / settled / pendingSubagentUpdates), so no wake is currently
2139
+ // dropped; the latch keeps that correctness inside the queue instead of
2140
+ // depending on every future caller repeating the pairing.
2141
+ let signaled = false;
2927
2142
  return {
2928
2143
  push(item) {
2929
2144
  items.push(item);
@@ -2938,6 +2153,10 @@ function createUpdateQueue() {
2938
2153
  wait(signal) {
2939
2154
  if (items.length > 0)
2940
2155
  return Promise.resolve("woken");
2156
+ if (signaled) {
2157
+ signaled = false;
2158
+ return Promise.resolve("woken");
2159
+ }
2941
2160
  if (signal?.aborted)
2942
2161
  return Promise.resolve("aborted");
2943
2162
  return new Promise((resolve) => {
@@ -2961,10 +2180,14 @@ function createUpdateQueue() {
2961
2180
  },
2962
2181
  wake() {
2963
2182
  const resolve = waiter;
2183
+ if (!resolve) {
2184
+ signaled = true;
2185
+ return;
2186
+ }
2964
2187
  waiter = undefined;
2965
2188
  abortCleanup?.();
2966
2189
  abortCleanup = undefined;
2967
- resolve?.("woken");
2190
+ resolve("woken");
2968
2191
  },
2969
2192
  };
2970
2193
  }
@@ -2990,35 +2213,6 @@ function getCurrentHeapUsed() {
2990
2213
  return 0;
2991
2214
  }
2992
2215
  }
2993
- function isFinalSubagentStatus(status) {
2994
- return status === "completed"
2995
- || status === "failed"
2996
- || status === "blocked"
2997
- || status === "cancelled"
2998
- || status === "closed";
2999
- }
3000
- /**
3001
- * Persists the full rendered workflow result so the inline preview can stay
3002
- * bounded without losing data (the parent reads the file selectively when the
3003
- * preview was cut). A write failure only costs the pointer, never the run.
3004
- */
3005
- function persistWorkflowResult(runId, value) {
3006
- try {
3007
- const dir = join(getBubbleHome(), "workflows");
3008
- mkdirSync(dir, { recursive: true });
3009
- const path = join(dir, `${runId}.result.txt`);
3010
- writeFileSync(path, renderWorkflowResultValue(value));
3011
- return path;
3012
- }
3013
- catch {
3014
- return undefined;
3015
- }
3016
- }
3017
- function normalizeWaitTimeout(value) {
3018
- if (typeof value !== "number" || !Number.isFinite(value))
3019
- return 30_000;
3020
- return Math.max(100, Math.min(3_600_000, Math.floor(value)));
3021
- }
3022
2216
  function isSubagentLifecycleTool(name) {
3023
2217
  return name === "subagent"
3024
2218
  || name === "spawn_agent"