@bubblebrain-ai/bubble 0.0.34 → 0.0.36

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 (168) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/abort-errors.d.ts +2 -2
  3. package/dist/agent/budget-ledger.d.ts +4 -45
  4. package/dist/agent/budget-ledger.js +5 -75
  5. package/dist/agent/categories.d.ts +66 -2
  6. package/dist/agent/categories.js +120 -19
  7. package/dist/agent/child-runner.d.ts +1 -3
  8. package/dist/agent/child-runner.js +6 -37
  9. package/dist/agent/discovery-barrier.js +1 -1
  10. package/dist/agent/profiles.d.ts +1 -2
  11. package/dist/agent/profiles.js +25 -14
  12. package/dist/agent/routing-catalog.d.ts +113 -0
  13. package/dist/agent/routing-catalog.js +240 -0
  14. package/dist/agent/subagent-control.d.ts +5 -12
  15. package/dist/agent/subagent-control.js +0 -2
  16. package/dist/agent/subagent-store.d.ts +1 -1
  17. package/dist/agent/subagent-store.js +1 -3
  18. package/dist/agent/workflow/control.d.ts +21 -0
  19. package/dist/agent/workflow/control.js +50 -8
  20. package/dist/agent/workflow/runtime.d.ts +15 -2
  21. package/dist/agent/workflow/runtime.js +112 -30
  22. package/dist/agent.d.ts +82 -48
  23. package/dist/agent.js +360 -232
  24. package/dist/approval/controller.js +28 -0
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +21 -1
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +2 -1
  29. package/dist/config.d.ts +5 -3
  30. package/dist/config.js +10 -5
  31. package/dist/context/compact-llm.d.ts +2 -2
  32. package/dist/external-runtime/grok-acp.d.ts +56 -0
  33. package/dist/external-runtime/grok-acp.js +386 -0
  34. package/dist/external-runtime/grok-binary.d.ts +25 -0
  35. package/dist/external-runtime/grok-binary.js +108 -0
  36. package/dist/external-runtime/grok-errors.d.ts +7 -0
  37. package/dist/external-runtime/grok-errors.js +15 -0
  38. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  39. package/dist/external-runtime/grok-input-policy.js +52 -0
  40. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  41. package/dist/external-runtime/grok-ndjson.js +193 -0
  42. package/dist/external-runtime/grok-network.d.ts +15 -0
  43. package/dist/external-runtime/grok-network.js +81 -0
  44. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  45. package/dist/external-runtime/grok-preflight.js +194 -0
  46. package/dist/external-runtime/grok-process.d.ts +26 -0
  47. package/dist/external-runtime/grok-process.js +184 -0
  48. package/dist/external-runtime/grok-profile.d.ts +32 -0
  49. package/dist/external-runtime/grok-profile.js +419 -0
  50. package/dist/external-runtime/grok-provider.d.ts +31 -0
  51. package/dist/external-runtime/grok-provider.js +41 -0
  52. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  53. package/dist/external-runtime/grok-runtime.js +882 -0
  54. package/dist/external-runtime/index.d.ts +13 -0
  55. package/dist/external-runtime/index.js +13 -0
  56. package/dist/external-runtime/session-policy.d.ts +11 -0
  57. package/dist/external-runtime/session-policy.js +30 -0
  58. package/dist/external-runtime/types.d.ts +65 -0
  59. package/dist/external-runtime/types.js +1 -0
  60. package/dist/feishu/agent-host/approval-card.js +21 -0
  61. package/dist/feishu/agent-host/run-driver.js +19 -3
  62. package/dist/feishu/serve.js +1 -0
  63. package/dist/goal/tools.d.ts +6 -3
  64. package/dist/goal/tools.js +7 -19
  65. package/dist/main.js +114 -28
  66. package/dist/memory/db.d.ts +2 -0
  67. package/dist/memory/db.js +10 -0
  68. package/dist/memory/index.d.ts +1 -0
  69. package/dist/memory/index.js +1 -0
  70. package/dist/memory/phase1.js +14 -2
  71. package/dist/memory/phase2.js +21 -2
  72. package/dist/memory/prompts.js +1 -1
  73. package/dist/memory/session-policy.d.ts +25 -0
  74. package/dist/memory/session-policy.js +55 -0
  75. package/dist/memory/storage.d.ts +1 -0
  76. package/dist/memory/storage.js +13 -0
  77. package/dist/model-catalog.d.ts +18 -0
  78. package/dist/model-catalog.js +92 -52
  79. package/dist/model-config.d.ts +3 -1
  80. package/dist/model-config.js +11 -0
  81. package/dist/oauth/grok.d.ts +25 -0
  82. package/dist/oauth/grok.js +218 -0
  83. package/dist/oauth/index.d.ts +1 -0
  84. package/dist/oauth/index.js +1 -0
  85. package/dist/oauth/storage.d.ts +9 -0
  86. package/dist/oauth/storage.js +25 -0
  87. package/dist/orchestrator/default-hooks.js +23 -7
  88. package/dist/prompt/compose.d.ts +3 -0
  89. package/dist/prompt/compose.js +3 -0
  90. package/dist/prompt/delegation.d.ts +1 -1
  91. package/dist/prompt/delegation.js +41 -8
  92. package/dist/prompt/environment.js +0 -4
  93. package/dist/prompt/reminders.d.ts +0 -1
  94. package/dist/prompt/reminders.js +1 -11
  95. package/dist/prompt/routing.d.ts +14 -0
  96. package/dist/prompt/routing.js +71 -0
  97. package/dist/prompt/runtime.js +1 -1
  98. package/dist/prompt/task-reminders.d.ts +20 -0
  99. package/dist/prompt/task-reminders.js +73 -1
  100. package/dist/provider-ai-sdk.js +2 -2
  101. package/dist/provider-grok.d.ts +26 -0
  102. package/dist/provider-grok.js +59 -0
  103. package/dist/provider-openai-codex.d.ts +17 -1
  104. package/dist/provider-openai-codex.js +101 -29
  105. package/dist/provider-registry.d.ts +72 -0
  106. package/dist/provider-registry.js +336 -59
  107. package/dist/provider-transform.d.ts +1 -1
  108. package/dist/provider-transform.js +35 -17
  109. package/dist/provider.d.ts +3 -0
  110. package/dist/provider.js +14 -0
  111. package/dist/session-types.d.ts +19 -1
  112. package/dist/session.d.ts +1 -0
  113. package/dist/session.js +4 -0
  114. package/dist/slash-commands/commands.js +224 -14
  115. package/dist/slash-commands/types.d.ts +14 -1
  116. package/dist/system-prompt.d.ts +2 -0
  117. package/dist/tools/agent-lifecycle.d.ts +1 -9
  118. package/dist/tools/agent-lifecycle.js +42 -272
  119. package/dist/tools/bash.js +1 -1
  120. package/dist/tools/child-tools.js +2 -0
  121. package/dist/tools/index.d.ts +2 -2
  122. package/dist/tools/index.js +7 -5
  123. package/dist/tools/lsp.js +1 -0
  124. package/dist/tools/memory.d.ts +7 -2
  125. package/dist/tools/memory.js +28 -43
  126. package/dist/tools/server.js +4 -0
  127. package/dist/tools/tool-search.d.ts +1 -1
  128. package/dist/tools/tool-search.js +23 -3
  129. package/dist/tui/model-switch.d.ts +7 -0
  130. package/dist/tui/model-switch.js +15 -5
  131. package/dist/tui-ink/app.d.ts +3 -1
  132. package/dist/tui-ink/app.js +672 -243
  133. package/dist/tui-ink/approval/approval-dialog.js +37 -1
  134. package/dist/tui-ink/approval/diff-view.js +4 -4
  135. package/dist/tui-ink/approval/select.js +2 -2
  136. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  137. package/dist/tui-ink/composer-buffer.js +317 -0
  138. package/dist/tui-ink/feedback-dialog.js +2 -2
  139. package/dist/tui-ink/feishu-setup-picker.js +3 -3
  140. package/dist/tui-ink/footer.d.ts +1 -0
  141. package/dist/tui-ink/footer.js +7 -2
  142. package/dist/tui-ink/input-box.d.ts +36 -7
  143. package/dist/tui-ink/input-box.js +229 -157
  144. package/dist/tui-ink/markdown.js +11 -9
  145. package/dist/tui-ink/message-list.d.ts +5 -1
  146. package/dist/tui-ink/message-list.js +15 -2
  147. package/dist/tui-ink/model-picker.d.ts +61 -4
  148. package/dist/tui-ink/model-picker.js +464 -128
  149. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  150. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  151. package/dist/tui-ink/plan-confirm.js +1 -1
  152. package/dist/tui-ink/run.d.ts +3 -0
  153. package/dist/tui-ink/run.js +1 -1
  154. package/dist/tui-ink/subagent-view.d.ts +34 -1
  155. package/dist/tui-ink/subagent-view.js +82 -1
  156. package/dist/tui-ink/theme.d.ts +19 -2
  157. package/dist/tui-ink/theme.js +25 -8
  158. package/dist/tui-ink/welcome.d.ts +3 -2
  159. package/dist/tui-ink/welcome.js +10 -6
  160. package/dist/types.d.ts +8 -31
  161. package/dist/types.js +1 -1
  162. package/dist/variant/thinking-level.d.ts +2 -2
  163. package/dist/variant/thinking-level.js +2 -1
  164. package/dist/variant/variant-resolver.d.ts +22 -0
  165. package/dist/variant/variant-resolver.js +43 -0
  166. package/package.json +6 -2
  167. package/dist/tools/task.d.ts +0 -2
  168. package/dist/tools/task.js +0 -58
package/dist/agent.js CHANGED
@@ -4,6 +4,9 @@
4
4
  */
5
5
  import { compactMessages } 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";
7
10
  import { compactMessagesWithLLM } from "./context/compact-llm.js";
8
11
  import { estimateContextTokens, getContextBudget } from "./context/budget.js";
9
12
  import { buildContextUsageSnapshot } from "./context/usage.js";
@@ -17,12 +20,15 @@ import { HookBus } from "./orchestrator/hooks.js";
17
20
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
18
21
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
19
22
  import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
23
+ import { DEFAULT_AGENT_ROUTING, nearModelMatches, sanitizeAgentRouting, tierContextFromSnapshot } from "./agent/routing-catalog.js";
24
+ import { buildModelRoutingPrompt } from "./prompt/routing.js";
25
+ import { getBuiltinModel } from "./model-catalog.js";
26
+ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeInheritedThinkingLevel, normalizeThinkingLevel } from "./variant/variant-resolver.js";
20
27
  import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateStructuredSummary } from "./agent/structured-output.js";
21
28
  import { runWorkflow, WorkflowConcurrencyGate } from "./agent/workflow/runtime.js";
22
- import { buildWorkflowDeliveryNotice } from "./agent/workflow/control.js";
23
- import { getSubtaskPolicy } from "./agent/subtask-policy.js";
24
- import { composeAbortSignals, computeChildTokenCap, DEFAULT_CHILD_TOKEN_CAP, PARENT_POOL_RESERVE_RATIO } from "./agent/budget-ledger.js";
25
- import { assignAgentNickname, builtinAgentProfiles, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
29
+ import { buildWorkflowDeliveryNotice, renderWorkflowResultValue } from "./agent/workflow/control.js";
30
+ import { composeAbortSignals } from "./agent/budget-ledger.js";
31
+ import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
26
32
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
27
33
  import { SubagentStore } from "./agent/subagent-store.js";
28
34
  import { SubagentScheduler } from "./agent/subagent-scheduler.js";
@@ -86,6 +92,10 @@ function agentEventFromHookProgress(event) {
86
92
  error: event.error ?? "Hook failed.",
87
93
  };
88
94
  }
95
+ /** Detector threshold N (design §6): 1–2 strong-model children are often a
96
+ * deliberate deep side-investigation; at 3+ uniform defaults the odds that
97
+ * all need frontier capability drop sharply. Revisit against live traces. */
98
+ const ROUTING_REMINDER_THRESHOLD = 3;
89
99
  export class Agent {
90
100
  messages = [];
91
101
  provider;
@@ -116,6 +126,13 @@ export class Agent {
116
126
  memoryPrompt;
117
127
  fileStateTracker;
118
128
  agentCategories;
129
+ agentRouting;
130
+ routingSnapshotAccessor;
131
+ routableModelIndex;
132
+ /** Detector state (design §6): once per session, absolute defaulted count. */
133
+ routingReminderFired = false;
134
+ defaultedRoutingStreak = 0;
135
+ pendingRoutingReminder;
119
136
  providerFactory;
120
137
  subagentStore;
121
138
  subagentScheduler;
@@ -128,6 +145,14 @@ export class Agent {
128
145
  subagentsConfig;
129
146
  rateLimitPolicy;
130
147
  pendingSubagentUpdates = [];
148
+ /**
149
+ * Wakers for tool-execution update loops currently awaiting updates. A
150
+ * blocking lifecycle tool (wait_workflow / wait_agent) produces no updates of
151
+ * its own, so without this wake the background children's queued lifecycle
152
+ * updates sit undrained until the tool settles — the UI would only show
153
+ * subagent traces after the whole team finished.
154
+ */
155
+ subagentUpdateWakers = new Set();
131
156
  lastInputTokens = null;
132
157
  lastAnchorMessageCount = null;
133
158
  constructor(options) {
@@ -155,6 +180,11 @@ export class Agent {
155
180
  this.memoryPrompt = options.memoryPrompt;
156
181
  this.fileStateTracker = options.fileStateTracker;
157
182
  this.agentCategories = options.agentCategories ?? {};
183
+ this.agentRouting = options.agentRouting
184
+ ? sanitizeAgentRouting(options.agentRouting)
185
+ : { ...DEFAULT_AGENT_ROUTING };
186
+ this.routingSnapshotAccessor = options.routingSnapshot;
187
+ this.routableModelIndex = options.routableModels;
158
188
  this.providerFactory = options.providerFactory;
159
189
  this.subagentsConfig = options.subagents ?? {};
160
190
  this.rateLimitPolicy = options.rateLimitPolicy;
@@ -179,7 +209,6 @@ export class Agent {
179
209
  });
180
210
  this.childRunner = new ChildRunner({
181
211
  allTools: () => [...this.tools.values()],
182
- budgetLedger: () => this.budgetLedger,
183
212
  emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
184
213
  runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
185
214
  finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
@@ -215,6 +244,16 @@ export class Agent {
215
244
  }
216
245
  // Advertise any deferred tools so the model knows they exist and how to
217
246
  // reach them. Keeps the per-turn tool list small; schemas load on demand.
247
+ this.injectDeferredToolsReminder();
248
+ }
249
+ /**
250
+ * Re-inject the deferred-tools advertisement. Hosts that reassign
251
+ * agent.messages (session resume, Feishu conversation rebuild) drop the
252
+ * constructor-injected meta reminder — without re-injection the model has
253
+ * no way to discover deferred tools, since they are also filtered out of
254
+ * the prompt's tool list.
255
+ */
256
+ injectDeferredToolsReminder() {
218
257
  const deferredNames = [...this.tools.values()]
219
258
  .filter((t) => t.deferred)
220
259
  .map((t) => t.name);
@@ -269,7 +308,34 @@ export class Agent {
269
308
  return [...this.tools.values()].filter((t) => t.deferred);
270
309
  }
271
310
  getSystemPromptToolOptions() {
272
- return buildToolPromptOptions(this.getActiveToolEntries());
311
+ return {
312
+ ...buildToolPromptOptions(this.getActiveToolEntries()),
313
+ // Rendered through the live accessor (design §1.5), so every host-
314
+ // triggered prompt rebuild picks up the current catalog.
315
+ modelRoutingPrompt: this.buildModelRoutingPromptSection(),
316
+ };
317
+ }
318
+ /** Current routing menu (design §4); undefined when no accessor is wired. */
319
+ buildModelRoutingPromptSection() {
320
+ const snapshot = this.currentRoutingSnapshot();
321
+ if (!snapshot)
322
+ return undefined;
323
+ return buildModelRoutingPrompt(snapshot, this.agentRouting);
324
+ }
325
+ /**
326
+ * Routing menu rendered for a prospective parent route — used by model-
327
+ * switch transactions to build the NEXT prompt before mutating the agent
328
+ * (design §1.5).
329
+ */
330
+ renderModelRoutingPromptFor(parent) {
331
+ if (!this.routingSnapshotAccessor)
332
+ return undefined;
333
+ try {
334
+ return buildModelRoutingPrompt(this.routingSnapshotAccessor(parent), this.agentRouting);
335
+ }
336
+ catch {
337
+ return undefined;
338
+ }
273
339
  }
274
340
  getContextUsageSnapshot() {
275
341
  return buildContextUsageSnapshot({
@@ -388,7 +454,7 @@ export class Agent {
388
454
  this.messages.unshift(systemMessage);
389
455
  }
390
456
  async *run(userInput, cwd, options = {}) {
391
- const abortSignal = composeAbortSignals([options.abortSignal, this.budgetLedger?.signal]);
457
+ const abortSignal = options.abortSignal;
392
458
  const inputController = options.inputController;
393
459
  const traceContext = {
394
460
  cwd,
@@ -699,6 +765,18 @@ export class Agent {
699
765
  appendProviderContentBlock(assistantMsg, chunk.provider, chunk.block);
700
766
  break;
701
767
  case "tool_call":
768
+ // `toolChoice: "none"` is a governance boundary, not merely a
769
+ // provider hint. Ignore any provider-side violation so a forced
770
+ // text-only turn can never reach the tool execution path.
771
+ if (textOnly) {
772
+ traceEvent("text_only_tool_call_ignored", {
773
+ id: chunk.id,
774
+ name: chunk.name,
775
+ isStart: chunk.isStart,
776
+ isEnd: chunk.isEnd,
777
+ }, traceContext);
778
+ break;
779
+ }
702
780
  if (discoveryBarrier?.isEnabled()
703
781
  && (bufferedStreamingToolCallIds.has(chunk.id) || discoveryBarrier.shouldBufferStreamingToolCall(chunk.name))) {
704
782
  bufferedStreamingToolCallIds.add(chunk.id);
@@ -911,14 +989,14 @@ export class Agent {
911
989
  }
912
990
  let tc = parsedCalls[index];
913
991
  let blockedResult;
914
- // agent_team must be the only tool call in its response (design
915
- // §1.2): concurrent calls race the scheduler and the aggregated
916
- // reply; the rejection teaches the model how to re-issue.
917
- if (tc.name === "agent_team" && parsedCalls.length > 1) {
992
+ // run_workflow must be the only tool call in its response: it starts
993
+ // a background orchestration whose result lands next turn, and
994
+ // sibling calls racing it defeat the serialized fan-out contract.
995
+ if (tc.name === "run_workflow" && parsedCalls.length > 1) {
918
996
  blockedResult = {
919
997
  content: [
920
- "agent_team must be the only tool call in your response.",
921
- "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.",
998
+ "run_workflow must be the only tool call in your response.",
999
+ "Re-issue run_workflow alone — one call, nothing else in the same message — and run other tools after it returns.",
922
1000
  ].join(" "),
923
1001
  isError: true,
924
1002
  status: "blocked",
@@ -1075,20 +1153,31 @@ export class Agent {
1075
1153
  settled = true;
1076
1154
  updateQueue.wake();
1077
1155
  });
1078
- while (!settled || updateQueue.hasItems()) {
1079
- for (const update of updateQueue.drain()) {
1080
- yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
1081
- }
1082
- for (const update of this.drainSubagentToolUpdates())
1083
- yield emit(update);
1084
- if (!settled) {
1085
- const waitStatus = await updateQueue.wait(abortSignal);
1086
- if (waitStatus === "aborted" && !settled) {
1087
- cancelledByAbort = true;
1088
- break;
1156
+ this.subagentUpdateWakers.add(updateQueue.wake);
1157
+ try {
1158
+ while (!settled || updateQueue.hasItems() || this.pendingSubagentUpdates.length > 0) {
1159
+ for (const update of updateQueue.drain()) {
1160
+ yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
1161
+ }
1162
+ for (const update of this.drainSubagentToolUpdates())
1163
+ yield emit(update);
1164
+ // A wake() that fires while this generator is suspended at a
1165
+ // yield above finds no parked waiter and is lost (the queue has
1166
+ // no wake latch), so re-check the subagent queue synchronously
1167
+ // before parking — otherwise an update pushed during the yield
1168
+ // stalls until the next unrelated wake or the tool settles.
1169
+ if (!settled && this.pendingSubagentUpdates.length === 0) {
1170
+ const waitStatus = await updateQueue.wait(abortSignal);
1171
+ if (waitStatus === "aborted" && !settled) {
1172
+ cancelledByAbort = true;
1173
+ break;
1174
+ }
1089
1175
  }
1090
1176
  }
1091
1177
  }
1178
+ finally {
1179
+ this.subagentUpdateWakers.delete(updateQueue.wake);
1180
+ }
1092
1181
  if (cancelledByAbort) {
1093
1182
  result = cancelledToolResult(tc.name);
1094
1183
  }
@@ -1398,42 +1487,6 @@ export class Agent {
1398
1487
  // re-injected as a `Previous conversation summary` system message.
1399
1488
  return sanitizeInternalReminderBlocks(full).trim();
1400
1489
  }
1401
- async runSubtask(input, cwd, options) {
1402
- const subtaskType = options?.subtaskType;
1403
- const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
1404
- ?? builtinAgentProfiles().find((item) => item.subtaskType === "general_readonly");
1405
- const run = await this.runSubAgent(input, cwd, {
1406
- profile,
1407
- runId: randomUUID(),
1408
- subAgentId: randomUUID(),
1409
- parentToolCallId: "task",
1410
- route: this.resolveRouteForSubagent(profile, undefined),
1411
- description: options?.description,
1412
- });
1413
- const lines = [
1414
- "Note: task is deprecated. Use spawn_agent with a named profile instead.",
1415
- `Subtask type: ${profile.subtaskType ?? "general_readonly"}`,
1416
- ];
1417
- if (options?.description) {
1418
- lines.push(`Subtask description: ${options.description}`);
1419
- }
1420
- if (run.summary) {
1421
- lines.push("", "Subtask summary:", run.summary);
1422
- }
1423
- if (run.toolNotes.length > 0) {
1424
- lines.push("", "Subtask tools:", ...run.toolNotes.slice(0, 8).map((note) => `- ${note}`));
1425
- }
1426
- return {
1427
- content: lines.join("\n"),
1428
- status: getSubtaskPolicy(subtaskType).resultStatus,
1429
- isError: run.status !== "completed",
1430
- metadata: {
1431
- kind: "subagent",
1432
- reason: `Subtask (${profile.subtaskType ?? "general_readonly"}) investigation completed.`,
1433
- subagents: [run],
1434
- },
1435
- };
1436
- }
1437
1490
  async runSubAgent(input, cwd, options) {
1438
1491
  const record = this.createSubagentThreadRecord({
1439
1492
  profile: options.profile,
@@ -1462,13 +1515,21 @@ export class Agent {
1462
1515
  return subagentResultFromThread(record);
1463
1516
  }
1464
1517
  async spawnSubAgent(input, cwd, options) {
1518
+ const route = options.route
1519
+ ?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort });
1520
+ // Early validation (design §7): throws reach the model as a tool error it
1521
+ // can correct this turn, instead of a late provider-factory failure.
1522
+ const routeNote = this.validateRouteForDispatch(route);
1523
+ this.noteRoutingDispatch(route);
1465
1524
  const record = this.createSubagentThreadRecord({
1466
1525
  profile: options.profile,
1467
1526
  task: typeof input === "string" ? input : "(multimodal task)",
1468
1527
  parentToolCallId: options.parentToolCallId,
1469
1528
  parentToolName: "spawn_agent",
1470
- route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort }),
1529
+ route,
1471
1530
  });
1531
+ if (routeNote)
1532
+ record.toolNotes.push(routeNote);
1472
1533
  this.subagentStore.set(record);
1473
1534
  const approval = options.approval ?? record.profile.approval;
1474
1535
  // Admission validation runs before queueing (design §4.2): a request that
@@ -1580,141 +1641,6 @@ export class Agent {
1580
1641
  .filter((record) => !record.workflowInternal)
1581
1642
  .map((record) => this.snapshotSubagent(record));
1582
1643
  }
1583
- /**
1584
- * Homogeneous map fan-out (design §1.2): one profile, one template, N items.
1585
- * Every member goes through the same admission and the same scheduler
1586
- * dispatch as spawn_agent; the tool blocks until all members are final.
1587
- */
1588
- async runAgentTeam(cwd, options) {
1589
- // Team budget pre-check (§6): items × per-member cap must fit in what
1590
- // remains of a limited pool after the parent's reserve. On limit-free
1591
- // hosts the only bound is the items cap enforced by the tool.
1592
- const limit = this.budgetLedger?.poolLimit;
1593
- if (this.budgetLedger && limit !== undefined) {
1594
- const reserve = Math.floor(limit * PARENT_POOL_RESERVE_RATIO);
1595
- const available = Math.max(0, (this.budgetLedger.remaining() ?? 0) - reserve);
1596
- const memberCap = Math.min(this.subagentsConfig.childTokenCap ?? DEFAULT_CHILD_TOKEN_CAP, options.profile.maxTokens ?? Number.POSITIVE_INFINITY);
1597
- const affordable = Math.floor(available / memberCap);
1598
- if (options.items.length > affordable) {
1599
- throw new Error([
1600
- `agent_team rejected: the remaining token budget affords at most ${affordable} member${affordable === 1 ? "" : "s"}`,
1601
- `but ${options.items.length} were requested. Reduce items or run smaller batches sequentially.`,
1602
- ].join(" "));
1603
- }
1604
- }
1605
- const approval = options.approval ?? options.profile.approval;
1606
- const route = this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort });
1607
- const records = options.items.map((item) => this.createSubagentThreadRecord({
1608
- profile: options.profile,
1609
- task: options.promptTemplate.split("{{item}}").join(item),
1610
- parentToolCallId: options.parentToolCallId,
1611
- parentToolName: "agent_team",
1612
- route,
1613
- }));
1614
- const promises = records.map((record) => {
1615
- this.subagentStore.set(record);
1616
- const admissionError = this.admitSubagentProfile(record, approval);
1617
- if (admissionError) {
1618
- this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
1619
- return Promise.resolve();
1620
- }
1621
- // Member events flow through the team tool's own emitUpdate
1622
- // (directEmit): while a foreground tool runs, the parent loop blocks in
1623
- // updateQueue.wait and the queued channel is not drained until the tool
1624
- // settles — queueUpdates would freeze the TUI for the whole team (§1.2).
1625
- record.promise = this.dispatchSubagentRun(record, record.task, cwd, {
1626
- approval,
1627
- abortSignal: options.abortSignal,
1628
- directEmit: options.emitUpdate,
1629
- });
1630
- void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1631
- return record.promise;
1632
- });
1633
- await Promise.all(promises);
1634
- // The aggregated reply carries every member's full summary.
1635
- for (const record of records)
1636
- this.subagentStore.markDelivered(record.agentId);
1637
- return records.map((record) => this.snapshotSubagent(record));
1638
- }
1639
- /**
1640
- * Heterogeneous fan-out (design v2 §1.3): N independent specs, each with its
1641
- * own task, profile, and per-call model/effort, dispatched concurrently as a
1642
- * SINGLE tool call. Unlike runAgentTeam (one template over N items), members
1643
- * differ. Like the team, every member goes through the same scheduler
1644
- * dispatch and the tool blocks until all are final, returning in spec order.
1645
- * Keeping fan-out inside one tool call (rather than N parallel spawn_agent
1646
- * tool_calls) avoids the provider parallel-tool_call bug (Kimi 400 / lost
1647
- * responses).
1648
- */
1649
- async runAgentBatch(cwd, options) {
1650
- // Budget pre-check mirrors runAgentTeam: members × per-member cap must fit
1651
- // in what remains of a limited pool after the parent's reserve.
1652
- const limit = this.budgetLedger?.poolLimit;
1653
- if (this.budgetLedger && limit !== undefined) {
1654
- const reserve = Math.floor(limit * PARENT_POOL_RESERVE_RATIO);
1655
- const available = Math.max(0, (this.budgetLedger.remaining() ?? 0) - reserve);
1656
- const memberCap = this.subagentsConfig.childTokenCap ?? DEFAULT_CHILD_TOKEN_CAP;
1657
- const affordable = Math.floor(available / memberCap);
1658
- if (options.specs.length > affordable) {
1659
- throw new Error([
1660
- `agent_batch rejected: the remaining token budget affords at most ${affordable} member${affordable === 1 ? "" : "s"}`,
1661
- `but ${options.specs.length} were requested. Reduce specs or run smaller batches sequentially.`,
1662
- ].join(" "));
1663
- }
1664
- }
1665
- const records = options.specs.map((spec) => {
1666
- const approval = options.approval ?? spec.profile.approval;
1667
- const task = spec.outputSchema !== undefined
1668
- ? appendOutputSchemaInstructions(spec.task, spec.outputSchema)
1669
- : spec.task;
1670
- const record = this.createSubagentThreadRecord({
1671
- profile: spec.profile,
1672
- task,
1673
- parentToolCallId: options.parentToolCallId,
1674
- parentToolName: "agent_batch",
1675
- route: this.resolveRouteForSubagent(spec.profile, spec.category, { model: spec.model, effort: spec.effort }),
1676
- });
1677
- return { record, approval, spec };
1678
- });
1679
- const promises = records.map(({ record, approval }) => {
1680
- this.subagentStore.set(record);
1681
- const admissionError = this.admitSubagentProfile(record, approval);
1682
- if (admissionError) {
1683
- this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
1684
- return Promise.resolve();
1685
- }
1686
- record.promise = this.dispatchSubagentRun(record, record.task, cwd, {
1687
- approval,
1688
- abortSignal: options.abortSignal,
1689
- directEmit: options.emitUpdate,
1690
- });
1691
- void record.promise.finally(() => this.subagentStore.notifyWaiters(record));
1692
- return record.promise;
1693
- });
1694
- await Promise.all(promises);
1695
- // Structured-output validation + one corrective retry (design v2 §1.2):
1696
- // a member whose schema'd summary does not validate gets a single
1697
- // send_input correction, reusing the existing resume path.
1698
- for (const { record, spec } of records) {
1699
- if (spec.outputSchema === undefined)
1700
- continue;
1701
- if (record.status !== "completed")
1702
- continue;
1703
- if (validateStructuredSummary(record.summary, spec.outputSchema).ok)
1704
- continue;
1705
- const correction = buildSchemaCorrectionPrompt(spec.outputSchema, record.summary);
1706
- try {
1707
- await this.sendSubAgentInput(record.agentId, correction, cwd, { abortSignal: options.abortSignal });
1708
- await record.promise?.catch(() => undefined);
1709
- }
1710
- catch {
1711
- // resume failed; leave the original summary and surface the mismatch below
1712
- }
1713
- }
1714
- for (const { record } of records)
1715
- this.subagentStore.markDelivered(record.agentId);
1716
- return records.map(({ record }) => this.snapshotSubagent(record));
1717
- }
1718
1644
  /**
1719
1645
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
1720
1646
  * a QuickJS sandbox. Each agent() call in the script becomes a real scheduled
@@ -1732,6 +1658,7 @@ export class Agent {
1732
1658
  parentToolCallId: options.parentToolCallId,
1733
1659
  abortSignal: options.abortSignal,
1734
1660
  directEmit: options.emitUpdate,
1661
+ ensureProfileTrusted: options.ensureProfileTrusted,
1735
1662
  });
1736
1663
  }
1737
1664
  /**
@@ -1766,12 +1693,15 @@ export class Agent {
1766
1693
  parentToolCallId: options.parentToolCallId,
1767
1694
  abortSignal: abortController.signal,
1768
1695
  queueUpdates: true,
1696
+ ensureProfileTrusted: options.ensureProfileTrusted,
1769
1697
  }).then((out) => {
1770
1698
  record.agentCount = out.agentCount;
1771
1699
  record.snapshots = out.snapshots;
1772
1700
  record.logs = out.logs;
1773
1701
  record.result = out.result;
1774
1702
  record.status = out.result.ok ? "completed" : (abortController.signal.aborted ? "cancelled" : "failed");
1703
+ if (out.result.ok)
1704
+ record.resultPath = persistWorkflowResult(runId, out.result.value);
1775
1705
  }, (error) => {
1776
1706
  record.result = { ok: false, error: error?.message || String(error) };
1777
1707
  record.status = "failed";
@@ -1821,6 +1751,7 @@ export class Agent {
1821
1751
  status: record.status,
1822
1752
  agentCount: record.agentCount,
1823
1753
  result: record.result,
1754
+ resultPath: record.resultPath,
1824
1755
  logs: record.logs,
1825
1756
  snapshots: record.snapshots,
1826
1757
  };
@@ -1842,22 +1773,13 @@ export class Agent {
1842
1773
  const profiles = discoverAgentProfiles(cwd, "both").profiles;
1843
1774
  const runRecords = [];
1844
1775
  const logs = [];
1845
- // Per-run isolation (option C review): a token ceiling that aborts only this
1846
- // run (never the parent) and a concurrency sub-cap below the global limit so
1847
- // a workflow can't starve interactive subagents.
1848
- const poolLimit = this.budgetLedger?.poolLimit;
1849
- const runTokenCeiling = poolLimit !== undefined
1850
- ? Math.max(1, Math.floor(poolLimit * (1 - PARENT_POOL_RESERVE_RATIO)))
1851
- : Number.POSITIVE_INFINITY;
1852
- const runSpent = () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0);
1776
+ // Per-run isolation (option C review): a concurrency sub-cap below the
1777
+ // global limit so a workflow can't starve interactive subagents.
1853
1778
  const interactiveReserve = 2;
1854
1779
  const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
1855
1780
  const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
1856
1781
  const gate = new WorkflowConcurrencyGate(workflowConcurrency);
1857
1782
  const dispatchAgent = async (spec) => {
1858
- if (runSpent() >= runTokenCeiling) {
1859
- return { ok: false, error: "workflow token ceiling reached; not launching more agents" };
1860
- }
1861
1783
  const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
1862
1784
  ?? findAgentProfile(profiles, "default");
1863
1785
  if (!baseProfile)
@@ -1875,11 +1797,16 @@ export class Agent {
1875
1797
  tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
1876
1798
  };
1877
1799
  let route;
1800
+ let routeNote;
1878
1801
  try {
1879
1802
  route = this.resolveRouteForSubagent(profile, spec.opts.category, {
1880
1803
  model: spec.opts.model,
1881
1804
  effort: parseThinkingLevel(spec.opts.effort),
1882
1805
  });
1806
+ // Dispatch-time validation + defaulted-fan-out accounting (§6–7):
1807
+ // resolved routes, never script source text.
1808
+ routeNote = this.validateRouteForDispatch(route);
1809
+ this.noteRoutingDispatch(route);
1883
1810
  }
1884
1811
  catch (error) {
1885
1812
  return { ok: false, error: error?.message || String(error) };
@@ -1895,8 +1822,27 @@ export class Agent {
1895
1822
  route,
1896
1823
  workflowInternal: true,
1897
1824
  });
1825
+ record.expectsStructuredOutput = spec.opts.schema !== undefined;
1826
+ if (routeNote)
1827
+ record.toolNotes.push(routeNote);
1828
+ const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
1829
+ if (memberLabel)
1830
+ record.nickname = memberLabel;
1898
1831
  runRecords.push(record);
1899
1832
  this.subagentStore.set(record);
1833
+ // Project-local profiles pass the same first-use trust gate as
1834
+ // spawn_agent: a .bubble/agents profile must never gain a side door
1835
+ // into execution just because a script named it (Codex review on #58).
1836
+ // Checked AFTER the record exists so a rejected member still shows up
1837
+ // in the run's counts/snapshots as blocked instead of vanishing.
1838
+ if (options.ensureProfileTrusted) {
1839
+ const blocked = await options.ensureProfileTrusted(baseProfile);
1840
+ if (blocked) {
1841
+ const message = typeof blocked.content === "string" ? blocked.content : `profile "${baseProfile.name}" requires user approval`;
1842
+ this.finalizeSubagentBlocked(record, message, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
1843
+ return { ok: false, error: message };
1844
+ }
1845
+ }
1900
1846
  const admissionError = this.admitSubagentProfile(record, profile.approval);
1901
1847
  if (admissionError) {
1902
1848
  this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
@@ -1925,8 +1871,11 @@ export class Agent {
1925
1871
  return { ok: true, value: record.summary };
1926
1872
  }
1927
1873
  // Structured output: validate, one corrective retry, then fall back to raw.
1874
+ // The schema: log lines are the telemetry that decides whether the
1875
+ // deferred terminate-style structured-output tool ever gets reopened.
1928
1876
  let validated = validateStructuredSummary(record.summary, spec.opts.schema);
1929
1877
  if (!validated.ok) {
1878
+ logs.push(`schema: ${record.nickname} first output failed validation; sending one corrective retry`);
1930
1879
  try {
1931
1880
  await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
1932
1881
  await record.promise?.catch(() => undefined);
@@ -1935,6 +1884,9 @@ export class Agent {
1935
1884
  catch {
1936
1885
  // resume failed; fall through to raw summary
1937
1886
  }
1887
+ logs.push(validated.ok
1888
+ ? `schema: ${record.nickname} corrective retry produced valid output`
1889
+ : `schema: ${record.nickname} corrective retry still invalid; returning raw summary`);
1938
1890
  }
1939
1891
  return { ok: true, value: validated.ok ? validated.value : record.summary };
1940
1892
  };
@@ -1945,14 +1897,11 @@ export class Agent {
1945
1897
  onLog: (message) => logs.push(message),
1946
1898
  onPhase: (title) => logs.push(`— phase: ${title} —`),
1947
1899
  budget: {
1948
- total: this.budgetLedger?.poolLimit ?? null,
1900
+ // The ledger is pure accounting (no pool limit); scripts see an
1901
+ // unlimited budget unless a future host contract reintroduces one.
1902
+ total: null,
1949
1903
  spent: () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0),
1950
- remaining: () => {
1951
- const limit = this.budgetLedger?.poolLimit;
1952
- if (limit === undefined)
1953
- return Number.POSITIVE_INFINITY;
1954
- return Math.max(0, (this.budgetLedger?.remaining() ?? 0));
1955
- },
1904
+ remaining: () => Number.POSITIVE_INFINITY,
1956
1905
  },
1957
1906
  signal: options.abortSignal,
1958
1907
  });
@@ -2013,13 +1962,6 @@ export class Agent {
2013
1962
  dispatchSubagentRun(record, input, cwd, options) {
2014
1963
  record.status = "queued";
2015
1964
  record.updatedAt = Date.now();
2016
- record.tokenCap = computeChildTokenCap({
2017
- ledger: this.budgetLedger,
2018
- subAgentId: record.agentId,
2019
- activeChildren: this.subagentScheduler.activeCount(),
2020
- configCap: this.subagentsConfig.childTokenCap,
2021
- profileMaxTokens: record.profile.maxTokens,
2022
- });
2023
1965
  const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
2024
1966
  return this.subagentScheduler.dispatch({
2025
1967
  agentId: record.agentId,
@@ -2028,7 +1970,7 @@ export class Agent {
2028
1970
  run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
2029
1971
  onCancelledWhileQueued: (reason) => {
2030
1972
  record.status = "cancelled";
2031
- record.finalReason = classifySubagentAbortReason(reason, options.abortSignal, this.budgetLedger);
1973
+ record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
2032
1974
  record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
2033
1975
  record.updatedAt = Date.now();
2034
1976
  // The run never started, so no SubagentStart fired and no SubagentStop follows.
@@ -2068,8 +2010,14 @@ export class Agent {
2068
2010
  options.directEmit?.(update);
2069
2011
  if (options.queueUpdates) {
2070
2012
  this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
2013
+ this.wakeSubagentUpdateWaiters();
2071
2014
  }
2072
2015
  }
2016
+ /** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
2017
+ wakeSubagentUpdateWaiters() {
2018
+ for (const wake of this.subagentUpdateWakers)
2019
+ wake();
2020
+ }
2073
2021
  async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
2074
2022
  try {
2075
2023
  await this.runExternalHook({
@@ -2102,30 +2050,186 @@ export class Agent {
2102
2050
  model: this.apiModel,
2103
2051
  thinkingLevel: this.thinkingLevel,
2104
2052
  };
2105
- const resolved = resolveSubagentRoute(category ?? profile.category, {
2106
- ...parentRoute,
2107
- }, this.agentCategories);
2053
+ const snapshot = this.currentRoutingSnapshot();
2054
+ const resolved = resolveSubagentRoute(category ?? profile.category, { ...parentRoute }, this.agentCategories, snapshot ? tierContextFromSnapshot(snapshot, this.agentRouting) : undefined);
2108
2055
  if ("error" in resolved) {
2109
2056
  throw new Error(resolved.error);
2110
2057
  }
2111
2058
  let route = resolved.route;
2059
+ // modelSource is assigned while applying the chain (design §3.4): an
2060
+ // explicit layer naming the parent's own model is indistinguishable from
2061
+ // inherit by final-value comparison, yet carries different authority.
2062
+ let modelSource = route.categoryModelSource ?? "inherit";
2112
2063
  if (profile.model && profile.model !== "inherit") {
2113
2064
  const model = resolveModelRoute(profile.model, parentRoute.providerId);
2114
2065
  if (model.model !== "inherit") {
2115
2066
  route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2067
+ modelSource = "profile";
2116
2068
  }
2117
2069
  }
2118
- // Call-site override beats profile and category.
2070
+ // Call-site override beats profile and category. Bare names resolve
2071
+ // against the PARENT provider per the tool contract (design §3.5) — not
2072
+ // against a provider that category/profile may already have switched.
2119
2073
  if (override?.model) {
2120
- const model = resolveModelRoute(override.model, route.providerId);
2074
+ const model = resolveModelRoute(override.model, parentRoute.providerId);
2121
2075
  if (model.model !== "inherit") {
2122
2076
  route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2077
+ modelSource = "callsite";
2123
2078
  }
2124
2079
  }
2080
+ const supportedLevels = getAvailableThinkingLevels(route.providerId, route.model);
2081
+ const modelMetadata = getBuiltinModel(route.providerId, route.model);
2082
+ const hasTrustedEffortMetadata = !!modelMetadata
2083
+ && modelMetadata.reasoningLevels.some((level) => level !== "off");
2125
2084
  if (override?.effort) {
2126
- route = { ...route, thinkingLevel: override.effort, inherited: false };
2085
+ // A call-site effort is explicit user/model intent: preserve the existing
2086
+ // value for legacy/unknown models, and downward-clamp only when the
2087
+ // catalog declares real effort capabilities (for example Luna ultra -> max).
2088
+ route = {
2089
+ ...route,
2090
+ thinkingLevel: hasTrustedEffortMetadata
2091
+ ? normalizeThinkingLevel(override.effort, supportedLevels)
2092
+ : override.effort,
2093
+ inherited: false,
2094
+ };
2095
+ }
2096
+ else if (hasTrustedEffortMetadata) {
2097
+ const categoryThinkingLevel = route.category
2098
+ ? mergeAgentCategories(this.agentCategories)[route.category]?.thinkingLevel
2099
+ : undefined;
2100
+ // A category's thinkingLevel is calibrated for the model the CATEGORY
2101
+ // resolved. When a later layer (profile/call-site) replaced the model,
2102
+ // that level is no longer explicit intent for the final model: keep it
2103
+ // only if supported, else use the final model's own default — never
2104
+ // downward-clamp a thinking-default model to "off" (v3.6; live case:
2105
+ // explore's "low" + call-site glm-5.2 [high/max/off] silently landed
2106
+ // on "off").
2107
+ const modelReplacedAfterCategory = modelSource === "profile" || modelSource === "callsite";
2108
+ route = {
2109
+ ...route,
2110
+ thinkingLevel: categoryThinkingLevel
2111
+ ? (modelReplacedAfterCategory && !supportedLevels.includes(route.thinkingLevel)
2112
+ ? getDefaultThinkingLevel(route.providerId, route.model)
2113
+ : normalizeThinkingLevel(route.thinkingLevel, supportedLevels))
2114
+ : normalizeInheritedThinkingLevel(route.providerId, route.model, route.thinkingLevel),
2115
+ };
2116
+ }
2117
+ return {
2118
+ ...route,
2119
+ modelSource,
2120
+ modelInherited: route.providerId === parentRoute.providerId && route.model === parentRoute.model,
2121
+ };
2122
+ }
2123
+ /** Live snapshot for the CURRENT parent route; undefined when no accessor is wired. */
2124
+ currentRoutingSnapshot() {
2125
+ if (!this.routingSnapshotAccessor)
2126
+ return undefined;
2127
+ try {
2128
+ return this.routingSnapshotAccessor({ providerId: this.providerId, model: this.apiModel });
2129
+ }
2130
+ catch {
2131
+ // Catalog data is an enhancement, never a spawn blocker.
2132
+ return undefined;
2133
+ }
2134
+ }
2135
+ /**
2136
+ * Early route validation at dispatch time (design §7). Throws with an
2137
+ * actionable message so the model self-corrects in the same turn. The
2138
+ * cross-provider lock (§7.2.1) is snapshot-independent; catalog checks
2139
+ * degrade silently when no snapshot is available (§7.0).
2140
+ */
2141
+ validateRouteForDispatch(route) {
2142
+ const snapshot = this.currentRoutingSnapshot();
2143
+ const crossProvider = !!route.providerId && route.providerId !== this.providerId;
2144
+ if (crossProvider) {
2145
+ // §7.2.1 — the lock. Profile/user-category routes are standing user
2146
+ // authorization and always pass; only call-site routes are lockable.
2147
+ if (!this.agentRouting.allowCrossProvider && route.modelSource === "callsite") {
2148
+ throw new Error("Cross-provider routing is disabled in this session's config (agentRouting.allowCrossProvider). "
2149
+ + "Use a model from the parent provider, or ask the user to unlock cross-provider routing.");
2150
+ }
2151
+ // §7.2.2 — credentials, when a snapshot is available.
2152
+ if (snapshot && !snapshot.runnableProviderIds.includes(route.providerId)) {
2153
+ throw new Error(`Provider "${route.providerId}" is not configured with active credentials. `
2154
+ + `Available: ${snapshot.runnableProviderIds.join(", ") || "(none)"}.`);
2155
+ }
2156
+ // §7.2.3 amended (v3.6): the provider stays the authority — no hard
2157
+ // catalog rejection — but a near-match against the target provider's
2158
+ // local catalog is positive evidence of a mistyped id (a Grok parent
2159
+ // invented "openai:gpt-5.6" for gpt-5.6-sol), so soft-reject with the
2160
+ // correction and let the model fix it this turn. Genuinely unknown ids
2161
+ // (no near candidates) still pass through with a note.
2162
+ const targetCatalog = this.routableModelIndex?.()
2163
+ .filter((entry) => entry.providerId === route.providerId) ?? [];
2164
+ if (targetCatalog.length > 0 && !targetCatalog.some((entry) => entry.id === route.model)) {
2165
+ const near = nearModelMatches(route.model, targetCatalog, { mode: "truncation" });
2166
+ if (near.length > 0) {
2167
+ throw new Error(`Unknown model "${route.model}" for provider "${route.providerId}". Did you mean: ${near.join(", ")}?`);
2168
+ }
2169
+ return `model ${route.providerId}:${route.model} is not in the local catalog; the provider validates it`;
2170
+ }
2171
+ return `model ${route.providerId}:${route.model} is not locally verifiable; the provider validates it`;
2172
+ }
2173
+ // §7.1 — same-provider unknown model, resolved-provider based (never
2174
+ // input-syntax based; qualified same-provider ids land here too).
2175
+ if (snapshot && route.modelSource === "callsite") {
2176
+ const known = snapshot.models.some((model) => model.id === route.model);
2177
+ if (!known) {
2178
+ if (snapshot.authoritative) {
2179
+ const available = snapshot.models
2180
+ .map((model) => (model.tier ? `${model.id} (${model.tier})` : model.id))
2181
+ .join(", ");
2182
+ throw new Error(`Unknown model "${route.model}" for provider "${this.providerId}". Available: ${available}.`);
2183
+ }
2184
+ const near = nearModelMatches(route.model, snapshot.models.map((model) => ({ providerId: this.providerId, id: model.id, name: model.name })));
2185
+ return near.length > 0
2186
+ ? `model id "${route.model}" is unrecognized locally (did you mean: ${near.join(", ")}?); the provider validates it`
2187
+ : `model id "${route.model}" is unrecognized locally; the provider validates it`;
2188
+ }
2189
+ }
2190
+ return undefined;
2191
+ }
2192
+ /** Routable catalog across runnable providers (design v3.6); undefined when unwired. */
2193
+ listRoutableModels() {
2194
+ try {
2195
+ return this.routableModelIndex?.();
2196
+ }
2197
+ catch {
2198
+ return undefined;
2127
2199
  }
2128
- return route;
2200
+ }
2201
+ /**
2202
+ * Decision-point detector (design §6): counts dispatches whose model was
2203
+ * decided by NO routing layer (modelSource "inherit") under a strong-tier
2204
+ * parent. Fires once per session at the Nth qualifying dispatch; the
2205
+ * reminder rides the same channel as the lifecycle reminder.
2206
+ */
2207
+ noteRoutingDispatch(route) {
2208
+ if (this.routingReminderFired)
2209
+ return;
2210
+ if (route.modelSource !== "inherit")
2211
+ return;
2212
+ const snapshot = this.currentRoutingSnapshot();
2213
+ if (snapshot?.parent.tier !== "strong")
2214
+ return;
2215
+ this.defaultedRoutingStreak++;
2216
+ if (this.defaultedRoutingStreak >= ROUTING_REMINDER_THRESHOLD) {
2217
+ this.routingReminderFired = true;
2218
+ this.pendingRoutingReminder = [
2219
+ `Routing note: ${this.defaultedRoutingStreak} children in this fan-out defaulted to the parent's`,
2220
+ "strong-tier model (no model/category given). If any of these tasks are mechanical",
2221
+ "(scan / summarize / search / extract), route them with category \"quick\"/\"explore\" or a",
2222
+ "fast-tier model next time. If they genuinely need this model, ignore this note.",
2223
+ ].join(" ");
2224
+ }
2225
+ }
2226
+ /** Consumed by the turn hooks; also closes the counting window (§6). */
2227
+ consumePendingRoutingReminder() {
2228
+ const reminder = this.pendingRoutingReminder;
2229
+ this.pendingRoutingReminder = undefined;
2230
+ if (!this.routingReminderFired)
2231
+ this.defaultedRoutingStreak = 0;
2232
+ return reminder;
2129
2233
  }
2130
2234
  createSubagentThreadRecord(options) {
2131
2235
  const now = Date.now();
@@ -2190,7 +2294,7 @@ export class Agent {
2190
2294
  mode: childMode,
2191
2295
  workingDir: childCwd,
2192
2296
  ...buildToolPromptOptions(tools),
2193
- memoryPrompt: childToolNames.some((name) => name === "memory_search" || name === "memory_read_summary")
2297
+ memoryPrompt: childToolNames.some((name) => name === "memory")
2194
2298
  ? this.memoryPrompt
2195
2299
  : undefined,
2196
2300
  agentProfilePrompt: [
@@ -2309,6 +2413,7 @@ export class Agent {
2309
2413
  name: record.parentToolName,
2310
2414
  update: this.buildSubagentUpdate(record, status, event, message),
2311
2415
  });
2416
+ this.wakeSubagentUpdateWaiters();
2312
2417
  }
2313
2418
  drainSubagentToolUpdates() {
2314
2419
  return this.pendingSubagentUpdates.splice(0, this.pendingSubagentUpdates.length)
@@ -2441,7 +2546,7 @@ export class Agent {
2441
2546
  if (this._mode === "plan" && !tool.readOnly) {
2442
2547
  return {
2443
2548
  content: `Error: Tool "${toolCall.name}" is not allowed in plan mode. ` +
2444
- `In plan mode you may only use read-only tools (read, glob, grep, lsp, web_search, web_fetch, spawn_agent, wait_agent, send_input, close_agent, skill_search, skill, todo_write, tool_search, question, exit_plan_mode). ` +
2549
+ `In plan mode you may only use read-only tools (read, glob, grep, web_search, web_fetch, spawn_agent, wait_agent, send_input, skill_search, skill, todo_write, tool_search, question, exit_plan_mode). ` +
2445
2550
  `To modify files or run commands, present your proposal and call exit_plan_mode so the user can review and approve it.`,
2446
2551
  isError: true,
2447
2552
  };
@@ -2768,6 +2873,23 @@ function isFinalSubagentStatus(status) {
2768
2873
  || status === "cancelled"
2769
2874
  || status === "closed";
2770
2875
  }
2876
+ /**
2877
+ * Persists the full rendered workflow result so the inline preview can stay
2878
+ * bounded without losing data (the parent reads the file selectively when the
2879
+ * preview was cut). A write failure only costs the pointer, never the run.
2880
+ */
2881
+ function persistWorkflowResult(runId, value) {
2882
+ try {
2883
+ const dir = join(getBubbleHome(), "workflows");
2884
+ mkdirSync(dir, { recursive: true });
2885
+ const path = join(dir, `${runId}.result.txt`);
2886
+ writeFileSync(path, renderWorkflowResultValue(value));
2887
+ return path;
2888
+ }
2889
+ catch {
2890
+ return undefined;
2891
+ }
2892
+ }
2771
2893
  function normalizeWaitTimeout(value) {
2772
2894
  if (typeof value !== "number" || !Number.isFinite(value))
2773
2895
  return 30_000;
@@ -2780,5 +2902,11 @@ function isSubagentLifecycleTool(name) {
2780
2902
  || name === "send_input"
2781
2903
  || name === "close_agent"
2782
2904
  || name === "list_agents"
2783
- || name === "agent_team";
2905
+ || name === "run_workflow"
2906
+ || name === "wait_workflow"
2907
+ // Legacy names: still present in transcripts recorded before the tools
2908
+ // were removed (2026-07-06); forked children must not inherit their
2909
+ // dangling tool_calls either.
2910
+ || name === "agent_team"
2911
+ || name === "agent_batch";
2784
2912
  }