@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.
- package/README.md +27 -7
- package/dist/agent/abort-errors.d.ts +2 -2
- package/dist/agent/budget-ledger.d.ts +4 -45
- package/dist/agent/budget-ledger.js +5 -75
- package/dist/agent/categories.d.ts +66 -2
- package/dist/agent/categories.js +120 -19
- package/dist/agent/child-runner.d.ts +1 -3
- package/dist/agent/child-runner.js +6 -37
- package/dist/agent/discovery-barrier.js +1 -1
- package/dist/agent/profiles.d.ts +1 -2
- package/dist/agent/profiles.js +25 -14
- package/dist/agent/routing-catalog.d.ts +113 -0
- package/dist/agent/routing-catalog.js +240 -0
- package/dist/agent/subagent-control.d.ts +5 -12
- package/dist/agent/subagent-control.js +0 -2
- package/dist/agent/subagent-store.d.ts +1 -1
- package/dist/agent/subagent-store.js +1 -3
- package/dist/agent/workflow/control.d.ts +21 -0
- package/dist/agent/workflow/control.js +50 -8
- package/dist/agent/workflow/runtime.d.ts +15 -2
- package/dist/agent/workflow/runtime.js +112 -30
- package/dist/agent.d.ts +82 -48
- package/dist/agent.js +360 -232
- package/dist/approval/controller.js +28 -0
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +21 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +2 -1
- package/dist/config.d.ts +5 -3
- package/dist/config.js +10 -5
- package/dist/context/compact-llm.d.ts +2 -2
- package/dist/external-runtime/grok-acp.d.ts +56 -0
- package/dist/external-runtime/grok-acp.js +386 -0
- package/dist/external-runtime/grok-binary.d.ts +25 -0
- package/dist/external-runtime/grok-binary.js +108 -0
- package/dist/external-runtime/grok-errors.d.ts +7 -0
- package/dist/external-runtime/grok-errors.js +15 -0
- package/dist/external-runtime/grok-input-policy.d.ts +58 -0
- package/dist/external-runtime/grok-input-policy.js +52 -0
- package/dist/external-runtime/grok-ndjson.d.ts +14 -0
- package/dist/external-runtime/grok-ndjson.js +193 -0
- package/dist/external-runtime/grok-network.d.ts +15 -0
- package/dist/external-runtime/grok-network.js +81 -0
- package/dist/external-runtime/grok-preflight.d.ts +28 -0
- package/dist/external-runtime/grok-preflight.js +194 -0
- package/dist/external-runtime/grok-process.d.ts +26 -0
- package/dist/external-runtime/grok-process.js +184 -0
- package/dist/external-runtime/grok-profile.d.ts +32 -0
- package/dist/external-runtime/grok-profile.js +419 -0
- package/dist/external-runtime/grok-provider.d.ts +31 -0
- package/dist/external-runtime/grok-provider.js +41 -0
- package/dist/external-runtime/grok-runtime.d.ts +91 -0
- package/dist/external-runtime/grok-runtime.js +882 -0
- package/dist/external-runtime/index.d.ts +13 -0
- package/dist/external-runtime/index.js +13 -0
- package/dist/external-runtime/session-policy.d.ts +11 -0
- package/dist/external-runtime/session-policy.js +30 -0
- package/dist/external-runtime/types.d.ts +65 -0
- package/dist/external-runtime/types.js +1 -0
- package/dist/feishu/agent-host/approval-card.js +21 -0
- package/dist/feishu/agent-host/run-driver.js +19 -3
- package/dist/feishu/serve.js +1 -0
- package/dist/goal/tools.d.ts +6 -3
- package/dist/goal/tools.js +7 -19
- package/dist/main.js +114 -28
- package/dist/memory/db.d.ts +2 -0
- package/dist/memory/db.js +10 -0
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.js +1 -0
- package/dist/memory/phase1.js +14 -2
- package/dist/memory/phase2.js +21 -2
- package/dist/memory/prompts.js +1 -1
- package/dist/memory/session-policy.d.ts +25 -0
- package/dist/memory/session-policy.js +55 -0
- package/dist/memory/storage.d.ts +1 -0
- package/dist/memory/storage.js +13 -0
- package/dist/model-catalog.d.ts +18 -0
- package/dist/model-catalog.js +92 -52
- package/dist/model-config.d.ts +3 -1
- package/dist/model-config.js +11 -0
- package/dist/oauth/grok.d.ts +25 -0
- package/dist/oauth/grok.js +218 -0
- package/dist/oauth/index.d.ts +1 -0
- package/dist/oauth/index.js +1 -0
- package/dist/oauth/storage.d.ts +9 -0
- package/dist/oauth/storage.js +25 -0
- package/dist/orchestrator/default-hooks.js +23 -7
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/delegation.d.ts +1 -1
- package/dist/prompt/delegation.js +41 -8
- package/dist/prompt/environment.js +0 -4
- package/dist/prompt/reminders.d.ts +0 -1
- package/dist/prompt/reminders.js +1 -11
- package/dist/prompt/routing.d.ts +14 -0
- package/dist/prompt/routing.js +71 -0
- package/dist/prompt/runtime.js +1 -1
- package/dist/prompt/task-reminders.d.ts +20 -0
- package/dist/prompt/task-reminders.js +73 -1
- package/dist/provider-ai-sdk.js +2 -2
- package/dist/provider-grok.d.ts +26 -0
- package/dist/provider-grok.js +59 -0
- package/dist/provider-openai-codex.d.ts +17 -1
- package/dist/provider-openai-codex.js +101 -29
- package/dist/provider-registry.d.ts +72 -0
- package/dist/provider-registry.js +336 -59
- package/dist/provider-transform.d.ts +1 -1
- package/dist/provider-transform.js +35 -17
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +14 -0
- package/dist/session-types.d.ts +19 -1
- package/dist/session.d.ts +1 -0
- package/dist/session.js +4 -0
- package/dist/slash-commands/commands.js +224 -14
- package/dist/slash-commands/types.d.ts +14 -1
- package/dist/system-prompt.d.ts +2 -0
- package/dist/tools/agent-lifecycle.d.ts +1 -9
- package/dist/tools/agent-lifecycle.js +42 -272
- package/dist/tools/bash.js +1 -1
- package/dist/tools/child-tools.js +2 -0
- package/dist/tools/index.d.ts +2 -2
- package/dist/tools/index.js +7 -5
- package/dist/tools/lsp.js +1 -0
- package/dist/tools/memory.d.ts +7 -2
- package/dist/tools/memory.js +28 -43
- package/dist/tools/server.js +4 -0
- package/dist/tools/tool-search.d.ts +1 -1
- package/dist/tools/tool-search.js +23 -3
- package/dist/tui/model-switch.d.ts +7 -0
- package/dist/tui/model-switch.js +15 -5
- package/dist/tui-ink/app.d.ts +3 -1
- package/dist/tui-ink/app.js +672 -243
- package/dist/tui-ink/approval/approval-dialog.js +37 -1
- package/dist/tui-ink/approval/diff-view.js +4 -4
- package/dist/tui-ink/approval/select.js +2 -2
- package/dist/tui-ink/composer-buffer.d.ts +55 -0
- package/dist/tui-ink/composer-buffer.js +317 -0
- package/dist/tui-ink/feedback-dialog.js +2 -2
- package/dist/tui-ink/feishu-setup-picker.js +3 -3
- package/dist/tui-ink/footer.d.ts +1 -0
- package/dist/tui-ink/footer.js +7 -2
- package/dist/tui-ink/input-box.d.ts +36 -7
- package/dist/tui-ink/input-box.js +229 -157
- package/dist/tui-ink/markdown.js +11 -9
- package/dist/tui-ink/message-list.d.ts +5 -1
- package/dist/tui-ink/message-list.js +15 -2
- package/dist/tui-ink/model-picker.d.ts +61 -4
- package/dist/tui-ink/model-picker.js +464 -128
- package/dist/tui-ink/paste-operation-tracker.d.ts +13 -0
- package/dist/tui-ink/paste-operation-tracker.js +25 -0
- package/dist/tui-ink/plan-confirm.js +1 -1
- package/dist/tui-ink/run.d.ts +3 -0
- package/dist/tui-ink/run.js +1 -1
- package/dist/tui-ink/subagent-view.d.ts +34 -1
- package/dist/tui-ink/subagent-view.js +82 -1
- package/dist/tui-ink/theme.d.ts +19 -2
- package/dist/tui-ink/theme.js +25 -8
- package/dist/tui-ink/welcome.d.ts +3 -2
- package/dist/tui-ink/welcome.js +10 -6
- package/dist/types.d.ts +8 -31
- package/dist/types.js +1 -1
- package/dist/variant/thinking-level.d.ts +2 -2
- package/dist/variant/thinking-level.js +2 -1
- package/dist/variant/variant-resolver.d.ts +22 -0
- package/dist/variant/variant-resolver.js +43 -0
- package/package.json +6 -2
- package/dist/tools/task.d.ts +0 -2
- 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 {
|
|
24
|
-
import {
|
|
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
|
|
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 =
|
|
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
|
-
//
|
|
915
|
-
//
|
|
916
|
-
//
|
|
917
|
-
if (tc.name === "
|
|
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
|
-
"
|
|
921
|
-
"Re-issue
|
|
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
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
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
|
|
1846
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
2106
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 === "
|
|
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,
|
|
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 === "
|
|
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
|
}
|