@bubblebrain-ai/bubble 0.0.35 → 0.0.37

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 (144) hide show
  1. package/README.md +27 -7
  2. package/dist/agent/categories.d.ts +66 -2
  3. package/dist/agent/categories.js +120 -19
  4. package/dist/agent/discovery-barrier.js +1 -1
  5. package/dist/agent/profiles.d.ts +1 -0
  6. package/dist/agent/profiles.js +25 -11
  7. package/dist/agent/routing-catalog.d.ts +113 -0
  8. package/dist/agent/routing-catalog.js +240 -0
  9. package/dist/agent/workflow/control.d.ts +14 -0
  10. package/dist/agent/workflow/control.js +30 -9
  11. package/dist/agent.d.ts +75 -5
  12. package/dist/agent.js +313 -61
  13. package/dist/approval/controller.js +28 -0
  14. package/dist/approval/tool-helper.js +2 -0
  15. package/dist/approval/types.d.ts +21 -1
  16. package/dist/cli.d.ts +1 -1
  17. package/dist/cli.js +2 -1
  18. package/dist/config.d.ts +4 -0
  19. package/dist/config.js +4 -0
  20. package/dist/context/compact-llm.d.ts +2 -2
  21. package/dist/external-runtime/grok-acp.d.ts +56 -0
  22. package/dist/external-runtime/grok-acp.js +386 -0
  23. package/dist/external-runtime/grok-binary.d.ts +25 -0
  24. package/dist/external-runtime/grok-binary.js +108 -0
  25. package/dist/external-runtime/grok-errors.d.ts +7 -0
  26. package/dist/external-runtime/grok-errors.js +15 -0
  27. package/dist/external-runtime/grok-input-policy.d.ts +58 -0
  28. package/dist/external-runtime/grok-input-policy.js +52 -0
  29. package/dist/external-runtime/grok-ndjson.d.ts +14 -0
  30. package/dist/external-runtime/grok-ndjson.js +193 -0
  31. package/dist/external-runtime/grok-network.d.ts +15 -0
  32. package/dist/external-runtime/grok-network.js +81 -0
  33. package/dist/external-runtime/grok-preflight.d.ts +28 -0
  34. package/dist/external-runtime/grok-preflight.js +194 -0
  35. package/dist/external-runtime/grok-process.d.ts +26 -0
  36. package/dist/external-runtime/grok-process.js +184 -0
  37. package/dist/external-runtime/grok-profile.d.ts +32 -0
  38. package/dist/external-runtime/grok-profile.js +419 -0
  39. package/dist/external-runtime/grok-provider.d.ts +31 -0
  40. package/dist/external-runtime/grok-provider.js +41 -0
  41. package/dist/external-runtime/grok-runtime.d.ts +91 -0
  42. package/dist/external-runtime/grok-runtime.js +882 -0
  43. package/dist/external-runtime/index.d.ts +13 -0
  44. package/dist/external-runtime/index.js +13 -0
  45. package/dist/external-runtime/session-policy.d.ts +11 -0
  46. package/dist/external-runtime/session-policy.js +30 -0
  47. package/dist/external-runtime/types.d.ts +65 -0
  48. package/dist/external-runtime/types.js +1 -0
  49. package/dist/feishu/agent-host/approval-card.js +21 -0
  50. package/dist/feishu/agent-host/run-driver.js +19 -3
  51. package/dist/feishu/serve.js +1 -0
  52. package/dist/goal/tools.d.ts +6 -3
  53. package/dist/goal/tools.js +7 -19
  54. package/dist/main.js +105 -27
  55. package/dist/memory/db.d.ts +2 -0
  56. package/dist/memory/db.js +10 -0
  57. package/dist/memory/index.d.ts +1 -0
  58. package/dist/memory/index.js +1 -0
  59. package/dist/memory/phase1.js +14 -2
  60. package/dist/memory/phase2.js +21 -2
  61. package/dist/memory/prompts.js +1 -1
  62. package/dist/memory/session-policy.d.ts +25 -0
  63. package/dist/memory/session-policy.js +55 -0
  64. package/dist/memory/storage.d.ts +1 -0
  65. package/dist/memory/storage.js +13 -0
  66. package/dist/model-catalog.d.ts +18 -0
  67. package/dist/model-catalog.js +87 -41
  68. package/dist/model-config.d.ts +3 -1
  69. package/dist/model-config.js +11 -0
  70. package/dist/oauth/grok.d.ts +25 -0
  71. package/dist/oauth/grok.js +218 -0
  72. package/dist/oauth/index.d.ts +1 -0
  73. package/dist/oauth/index.js +1 -0
  74. package/dist/oauth/storage.d.ts +9 -0
  75. package/dist/oauth/storage.js +25 -0
  76. package/dist/orchestrator/default-hooks.js +17 -7
  77. package/dist/prompt/compose.d.ts +3 -0
  78. package/dist/prompt/compose.js +3 -0
  79. package/dist/prompt/delegation.js +22 -3
  80. package/dist/prompt/environment.js +0 -4
  81. package/dist/prompt/reminders.d.ts +0 -1
  82. package/dist/prompt/reminders.js +1 -11
  83. package/dist/prompt/routing.d.ts +14 -0
  84. package/dist/prompt/routing.js +71 -0
  85. package/dist/prompt/runtime.js +1 -1
  86. package/dist/prompt/task-reminders.d.ts +19 -0
  87. package/dist/prompt/task-reminders.js +49 -0
  88. package/dist/provider-ai-sdk.js +2 -2
  89. package/dist/provider-grok.d.ts +26 -0
  90. package/dist/provider-grok.js +59 -0
  91. package/dist/provider-openai-codex.d.ts +17 -1
  92. package/dist/provider-openai-codex.js +101 -29
  93. package/dist/provider-registry.d.ts +72 -0
  94. package/dist/provider-registry.js +336 -59
  95. package/dist/provider-transform.d.ts +1 -1
  96. package/dist/provider-transform.js +28 -5
  97. package/dist/provider.d.ts +3 -0
  98. package/dist/provider.js +14 -0
  99. package/dist/session-types.d.ts +19 -1
  100. package/dist/session.d.ts +1 -0
  101. package/dist/session.js +4 -0
  102. package/dist/slash-commands/commands.js +224 -14
  103. package/dist/slash-commands/types.d.ts +14 -1
  104. package/dist/system-prompt.d.ts +2 -0
  105. package/dist/tools/agent-lifecycle.js +9 -11
  106. package/dist/tools/bash.js +1 -1
  107. package/dist/tools/child-tools.js +2 -0
  108. package/dist/tools/index.d.ts +2 -2
  109. package/dist/tools/index.js +7 -5
  110. package/dist/tools/lsp.js +1 -0
  111. package/dist/tools/memory.d.ts +7 -2
  112. package/dist/tools/memory.js +28 -43
  113. package/dist/tools/server.js +4 -0
  114. package/dist/tools/tool-search.d.ts +1 -1
  115. package/dist/tools/tool-search.js +23 -3
  116. package/dist/tui/model-switch.d.ts +7 -0
  117. package/dist/tui/model-switch.js +15 -5
  118. package/dist/tui-ink/app.d.ts +3 -1
  119. package/dist/tui-ink/app.js +649 -233
  120. package/dist/tui-ink/approval/approval-dialog.js +36 -0
  121. package/dist/tui-ink/composer-buffer.d.ts +55 -0
  122. package/dist/tui-ink/composer-buffer.js +317 -0
  123. package/dist/tui-ink/footer.d.ts +1 -0
  124. package/dist/tui-ink/footer.js +7 -2
  125. package/dist/tui-ink/input-box.d.ts +35 -1
  126. package/dist/tui-ink/input-box.js +215 -122
  127. package/dist/tui-ink/message-list.js +5 -5
  128. package/dist/tui-ink/model-picker.d.ts +61 -4
  129. package/dist/tui-ink/model-picker.js +486 -133
  130. package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
  131. package/dist/tui-ink/paste-operation-tracker.js +25 -0
  132. package/dist/tui-ink/run.d.ts +3 -0
  133. package/dist/tui-ink/run.js +1 -1
  134. package/dist/tui-ink/subagent-view.d.ts +31 -0
  135. package/dist/tui-ink/subagent-view.js +82 -1
  136. package/dist/types.d.ts +5 -5
  137. package/dist/types.js +1 -1
  138. package/dist/variant/thinking-level.d.ts +2 -2
  139. package/dist/variant/thinking-level.js +2 -1
  140. package/dist/variant/variant-resolver.d.ts +9 -0
  141. package/dist/variant/variant-resolver.js +17 -0
  142. package/package.json +2 -1
  143. package/dist/tools/task.d.ts +0 -2
  144. 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";
29
+ import { buildWorkflowDeliveryNotice, renderWorkflowResultValue } from "./agent/workflow/control.js";
24
30
  import { composeAbortSignals } from "./agent/budget-ledger.js";
25
- import { assignAgentNickname, builtinAgentProfiles, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.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;
@@ -214,6 +244,16 @@ export class Agent {
214
244
  }
215
245
  // Advertise any deferred tools so the model knows they exist and how to
216
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() {
217
257
  const deferredNames = [...this.tools.values()]
218
258
  .filter((t) => t.deferred)
219
259
  .map((t) => t.name);
@@ -268,7 +308,34 @@ export class Agent {
268
308
  return [...this.tools.values()].filter((t) => t.deferred);
269
309
  }
270
310
  getSystemPromptToolOptions() {
271
- 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
+ }
272
339
  }
273
340
  getContextUsageSnapshot() {
274
341
  return buildContextUsageSnapshot({
@@ -698,6 +765,18 @@ export class Agent {
698
765
  appendProviderContentBlock(assistantMsg, chunk.provider, chunk.block);
699
766
  break;
700
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
+ }
701
780
  if (discoveryBarrier?.isEnabled()
702
781
  && (bufferedStreamingToolCallIds.has(chunk.id) || discoveryBarrier.shouldBufferStreamingToolCall(chunk.name))) {
703
782
  bufferedStreamingToolCallIds.add(chunk.id);
@@ -1074,20 +1153,31 @@ export class Agent {
1074
1153
  settled = true;
1075
1154
  updateQueue.wake();
1076
1155
  });
1077
- while (!settled || updateQueue.hasItems()) {
1078
- for (const update of updateQueue.drain()) {
1079
- yield emit({ type: "tool_update", id: tc.id, name: tc.name, update });
1080
- }
1081
- for (const update of this.drainSubagentToolUpdates())
1082
- yield emit(update);
1083
- if (!settled) {
1084
- const waitStatus = await updateQueue.wait(abortSignal);
1085
- if (waitStatus === "aborted" && !settled) {
1086
- cancelledByAbort = true;
1087
- 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
+ }
1088
1175
  }
1089
1176
  }
1090
1177
  }
1178
+ finally {
1179
+ this.subagentUpdateWakers.delete(updateQueue.wake);
1180
+ }
1091
1181
  if (cancelledByAbort) {
1092
1182
  result = cancelledToolResult(tc.name);
1093
1183
  }
@@ -1397,42 +1487,6 @@ export class Agent {
1397
1487
  // re-injected as a `Previous conversation summary` system message.
1398
1488
  return sanitizeInternalReminderBlocks(full).trim();
1399
1489
  }
1400
- async runSubtask(input, cwd, options) {
1401
- const subtaskType = options?.subtaskType;
1402
- const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
1403
- ?? builtinAgentProfiles().find((item) => item.subtaskType === "general_readonly");
1404
- const run = await this.runSubAgent(input, cwd, {
1405
- profile,
1406
- runId: randomUUID(),
1407
- subAgentId: randomUUID(),
1408
- parentToolCallId: "task",
1409
- route: this.resolveRouteForSubagent(profile, undefined),
1410
- description: options?.description,
1411
- });
1412
- const lines = [
1413
- "Note: task is deprecated. Use spawn_agent with a named profile instead.",
1414
- `Subtask type: ${profile.subtaskType ?? "general_readonly"}`,
1415
- ];
1416
- if (options?.description) {
1417
- lines.push(`Subtask description: ${options.description}`);
1418
- }
1419
- if (run.summary) {
1420
- lines.push("", "Subtask summary:", run.summary);
1421
- }
1422
- if (run.toolNotes.length > 0) {
1423
- lines.push("", "Subtask tools:", ...run.toolNotes.slice(0, 8).map((note) => `- ${note}`));
1424
- }
1425
- return {
1426
- content: lines.join("\n"),
1427
- status: getSubtaskPolicy(subtaskType).resultStatus,
1428
- isError: run.status !== "completed",
1429
- metadata: {
1430
- kind: "subagent",
1431
- reason: `Subtask (${profile.subtaskType ?? "general_readonly"}) investigation completed.`,
1432
- subagents: [run],
1433
- },
1434
- };
1435
- }
1436
1490
  async runSubAgent(input, cwd, options) {
1437
1491
  const record = this.createSubagentThreadRecord({
1438
1492
  profile: options.profile,
@@ -1461,13 +1515,21 @@ export class Agent {
1461
1515
  return subagentResultFromThread(record);
1462
1516
  }
1463
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);
1464
1524
  const record = this.createSubagentThreadRecord({
1465
1525
  profile: options.profile,
1466
1526
  task: typeof input === "string" ? input : "(multimodal task)",
1467
1527
  parentToolCallId: options.parentToolCallId,
1468
1528
  parentToolName: "spawn_agent",
1469
- route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort }),
1529
+ route,
1470
1530
  });
1531
+ if (routeNote)
1532
+ record.toolNotes.push(routeNote);
1471
1533
  this.subagentStore.set(record);
1472
1534
  const approval = options.approval ?? record.profile.approval;
1473
1535
  // Admission validation runs before queueing (design §4.2): a request that
@@ -1638,6 +1700,8 @@ export class Agent {
1638
1700
  record.logs = out.logs;
1639
1701
  record.result = out.result;
1640
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);
1641
1705
  }, (error) => {
1642
1706
  record.result = { ok: false, error: error?.message || String(error) };
1643
1707
  record.status = "failed";
@@ -1687,6 +1751,7 @@ export class Agent {
1687
1751
  status: record.status,
1688
1752
  agentCount: record.agentCount,
1689
1753
  result: record.result,
1754
+ resultPath: record.resultPath,
1690
1755
  logs: record.logs,
1691
1756
  snapshots: record.snapshots,
1692
1757
  };
@@ -1732,11 +1797,16 @@ export class Agent {
1732
1797
  tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
1733
1798
  };
1734
1799
  let route;
1800
+ let routeNote;
1735
1801
  try {
1736
1802
  route = this.resolveRouteForSubagent(profile, spec.opts.category, {
1737
1803
  model: spec.opts.model,
1738
1804
  effort: parseThinkingLevel(spec.opts.effort),
1739
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);
1740
1810
  }
1741
1811
  catch (error) {
1742
1812
  return { ok: false, error: error?.message || String(error) };
@@ -1753,6 +1823,8 @@ export class Agent {
1753
1823
  workflowInternal: true,
1754
1824
  });
1755
1825
  record.expectsStructuredOutput = spec.opts.schema !== undefined;
1826
+ if (routeNote)
1827
+ record.toolNotes.push(routeNote);
1756
1828
  const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
1757
1829
  if (memberLabel)
1758
1830
  record.nickname = memberLabel;
@@ -1938,8 +2010,14 @@ export class Agent {
1938
2010
  options.directEmit?.(update);
1939
2011
  if (options.queueUpdates) {
1940
2012
  this.pendingSubagentUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
2013
+ this.wakeSubagentUpdateWaiters();
1941
2014
  }
1942
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
+ }
1943
2021
  async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
1944
2022
  try {
1945
2023
  await this.runExternalHook({
@@ -1972,30 +2050,186 @@ export class Agent {
1972
2050
  model: this.apiModel,
1973
2051
  thinkingLevel: this.thinkingLevel,
1974
2052
  };
1975
- const resolved = resolveSubagentRoute(category ?? profile.category, {
1976
- ...parentRoute,
1977
- }, this.agentCategories);
2053
+ const snapshot = this.currentRoutingSnapshot();
2054
+ const resolved = resolveSubagentRoute(category ?? profile.category, { ...parentRoute }, this.agentCategories, snapshot ? tierContextFromSnapshot(snapshot, this.agentRouting) : undefined);
1978
2055
  if ("error" in resolved) {
1979
2056
  throw new Error(resolved.error);
1980
2057
  }
1981
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";
1982
2063
  if (profile.model && profile.model !== "inherit") {
1983
2064
  const model = resolveModelRoute(profile.model, parentRoute.providerId);
1984
2065
  if (model.model !== "inherit") {
1985
2066
  route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2067
+ modelSource = "profile";
1986
2068
  }
1987
2069
  }
1988
- // 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.
1989
2073
  if (override?.model) {
1990
- const model = resolveModelRoute(override.model, route.providerId);
2074
+ const model = resolveModelRoute(override.model, parentRoute.providerId);
1991
2075
  if (model.model !== "inherit") {
1992
2076
  route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2077
+ modelSource = "callsite";
1993
2078
  }
1994
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");
1995
2084
  if (override?.effort) {
1996
- 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;
1997
2133
  }
1998
- return route;
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;
2199
+ }
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;
1999
2233
  }
2000
2234
  createSubagentThreadRecord(options) {
2001
2235
  const now = Date.now();
@@ -2060,7 +2294,7 @@ export class Agent {
2060
2294
  mode: childMode,
2061
2295
  workingDir: childCwd,
2062
2296
  ...buildToolPromptOptions(tools),
2063
- memoryPrompt: childToolNames.some((name) => name === "memory_search" || name === "memory_read_summary")
2297
+ memoryPrompt: childToolNames.some((name) => name === "memory")
2064
2298
  ? this.memoryPrompt
2065
2299
  : undefined,
2066
2300
  agentProfilePrompt: [
@@ -2179,6 +2413,7 @@ export class Agent {
2179
2413
  name: record.parentToolName,
2180
2414
  update: this.buildSubagentUpdate(record, status, event, message),
2181
2415
  });
2416
+ this.wakeSubagentUpdateWaiters();
2182
2417
  }
2183
2418
  drainSubagentToolUpdates() {
2184
2419
  return this.pendingSubagentUpdates.splice(0, this.pendingSubagentUpdates.length)
@@ -2311,7 +2546,7 @@ export class Agent {
2311
2546
  if (this._mode === "plan" && !tool.readOnly) {
2312
2547
  return {
2313
2548
  content: `Error: Tool "${toolCall.name}" is not allowed in plan mode. ` +
2314
- `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). ` +
2315
2550
  `To modify files or run commands, present your proposal and call exit_plan_mode so the user can review and approve it.`,
2316
2551
  isError: true,
2317
2552
  };
@@ -2638,6 +2873,23 @@ function isFinalSubagentStatus(status) {
2638
2873
  || status === "cancelled"
2639
2874
  || status === "closed";
2640
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
+ }
2641
2893
  function normalizeWaitTimeout(value) {
2642
2894
  if (typeof value !== "number" || !Number.isFinite(value))
2643
2895
  return 30_000;
@@ -87,6 +87,17 @@ export class PermissionAwareApprovalController {
87
87
  return { tool: "Lsp", path: req.path, cwd: this.options.cwd };
88
88
  case "agent_profile":
89
89
  return { tool: "AgentProfile" };
90
+ case "external_tool": {
91
+ const command = externalCommand(req.rawInput);
92
+ if (req.kind === "execute" && command !== undefined) {
93
+ return { tool: "Bash", command };
94
+ }
95
+ const path = req.locations?.[0]?.path;
96
+ if ((req.kind === "edit" || req.kind === "delete" || req.kind === "move") && path) {
97
+ return { tool: "Edit", path, cwd: this.options.cwd };
98
+ }
99
+ return { tool: req.title.trim() || req.kind.trim() || "ExternalTool" };
100
+ }
90
101
  }
91
102
  }
92
103
  checkRequestRules(req) {
@@ -176,6 +187,8 @@ function approvalTarget(req) {
176
187
  return "Lsp";
177
188
  case "agent_profile":
178
189
  return "AgentProfile";
190
+ case "external_tool":
191
+ return req.title.trim() || req.kind.trim() || "ExternalTool";
179
192
  }
180
193
  }
181
194
  function summarizeApprovalRequest(req) {
@@ -192,5 +205,20 @@ function summarizeApprovalRequest(req) {
192
205
  return { type: req.type, path: req.path, operation: req.operation };
193
206
  case "agent_profile":
194
207
  return { type: req.type, name: req.name, path: req.path, contentHash: req.contentHash };
208
+ case "external_tool":
209
+ return {
210
+ type: req.type,
211
+ toolCallId: req.toolCallId,
212
+ title: req.title,
213
+ kind: req.kind,
214
+ locations: req.locations,
215
+ rawInput: req.rawInput,
216
+ };
195
217
  }
196
218
  }
219
+ function externalCommand(rawInput) {
220
+ if (typeof rawInput !== "object" || rawInput === null || Array.isArray(rawInput))
221
+ return undefined;
222
+ const command = rawInput.command;
223
+ return typeof command === "string" ? command : undefined;
224
+ }
@@ -32,5 +32,7 @@ function approvalRequestLabel(req) {
32
32
  return `LSP ${req.operation} on ${req.path}`;
33
33
  case "agent_profile":
34
34
  return `Project agent profile "${req.name}"`;
35
+ case "external_tool":
36
+ return `External tool "${req.title}"`;
35
37
  }
36
38
  }
@@ -61,7 +61,27 @@ export interface AgentProfileApprovalRequest {
61
61
  /** First lines of the profile prompt so the user can judge it. */
62
62
  promptPreview: string;
63
63
  }
64
- export type ApprovalRequest = EditApprovalRequest | WriteApprovalRequest | PatchApprovalRequest | BashApprovalRequest | LspApprovalRequest | AgentProfileApprovalRequest;
64
+ /**
65
+ * Permission gate for a tool exposed by an external ACP runtime.
66
+ *
67
+ * Keep this structural rather than importing ACP SDK types so the approval
68
+ * layer remains provider-agnostic. External runtimes are responsible for
69
+ * copying the relevant, user-visible fields from the protocol request.
70
+ */
71
+ export interface ExternalToolApprovalRequest {
72
+ type: "external_tool";
73
+ toolCallId: string;
74
+ /** Human-readable tool name supplied by the external runtime. */
75
+ title: string;
76
+ /** ACP tool category (for example execute, edit, read, or other). */
77
+ kind: string;
78
+ rawInput?: unknown;
79
+ locations?: Array<{
80
+ path: string;
81
+ line?: number | null;
82
+ }>;
83
+ }
84
+ export type ApprovalRequest = EditApprovalRequest | WriteApprovalRequest | PatchApprovalRequest | BashApprovalRequest | LspApprovalRequest | AgentProfileApprovalRequest | ExternalToolApprovalRequest;
65
85
  export type ApprovalDecision = {
66
86
  action: "approve";
67
87
  feedback?: string;
package/dist/cli.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * CLI argument parsing.
3
3
  */
4
- import type { PermissionMode, ThinkingLevel } from "./types.js";
4
+ import { type PermissionMode, type ThinkingLevel } from "./types.js";
5
5
  export type CliCommand = "default" | "serve" | "update";
6
6
  export interface CliArgs {
7
7
  command: CliCommand;