@bubblebrain-ai/bubble 0.0.46 → 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/child-runner.js +11 -0
- package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
- package/dist/agent/internal-reminder-sanitizer.js +13 -0
- package/dist/agent/subagent/router.d.ts +65 -0
- package/dist/agent/subagent/router.js +198 -0
- package/dist/agent/subagent/runtime.d.ts +203 -0
- package/dist/agent/subagent/runtime.js +789 -0
- package/dist/agent/subagent-control.d.ts +5 -0
- package/dist/agent/subagent-store.d.ts +18 -2
- package/dist/agent/subagent-store.js +32 -1
- package/dist/agent/tool-intent.d.ts +10 -0
- package/dist/agent/tool-intent.js +18 -0
- package/dist/agent/tool-visibility.d.ts +10 -0
- package/dist/agent/tool-visibility.js +6 -0
- package/dist/agent/workflow/runs.d.ts +72 -0
- package/dist/agent/workflow/runs.js +157 -0
- package/dist/agent.d.ts +78 -96
- package/dist/agent.js +266 -1072
- package/dist/context/compact-llm.js +14 -5
- package/dist/context/compact.d.ts +9 -0
- package/dist/context/compact.js +36 -10
- package/dist/context/compaction-files.d.ts +44 -0
- package/dist/context/compaction-files.js +148 -0
- package/dist/context/llm-compactor.js +24 -5
- package/dist/feishu/agent-host/run-driver.js +3 -0
- package/dist/feishu/serve.js +13 -9
- package/dist/goal/tools.js +12 -3
- package/dist/main.js +24 -19
- package/dist/model-catalog.js +36 -2
- package/dist/model-config.d.ts +2 -0
- package/dist/model-data.generated.d.ts +16 -0
- package/dist/model-data.generated.js +275 -0
- package/dist/model-pricing.d.ts +11 -7
- package/dist/model-pricing.js +0 -0
- package/dist/oauth/grok.d.ts +1 -1
- package/dist/oauth/grok.js +5 -2
- package/dist/oauth/storage.d.ts +9 -2
- package/dist/oauth/storage.js +16 -9
- package/dist/orchestrator/default-hooks.d.ts +20 -0
- package/dist/orchestrator/default-hooks.js +47 -295
- package/dist/orchestrator/hooks.d.ts +6 -39
- package/dist/permission/mode.js +1 -1
- package/dist/prompt/reminders.d.ts +9 -35
- package/dist/prompt/reminders.js +30 -136
- package/dist/prompt/task-reminders.d.ts +0 -31
- package/dist/prompt/task-reminders.js +0 -87
- package/dist/provider-ai-sdk.d.ts +2 -0
- package/dist/provider-ai-sdk.js +1 -0
- package/dist/provider-anthropic.d.ts +2 -0
- package/dist/provider-anthropic.js +3 -1
- package/dist/provider-ark-responses.d.ts +2 -0
- package/dist/provider-ark-responses.js +1 -0
- package/dist/provider-grok.d.ts +26 -0
- package/dist/provider-grok.js +39 -0
- package/dist/provider-registry.d.ts +14 -1
- package/dist/provider-registry.js +131 -3
- package/dist/provider-transform.js +37 -1
- package/dist/provider.d.ts +7 -0
- package/dist/provider.js +13 -1
- package/dist/sdk/index.d.ts +152 -0
- package/dist/sdk/index.js +400 -0
- package/dist/session.js +9 -1
- package/dist/slash-commands/commands.js +33 -19
- package/dist/tools/exit-plan-mode.js +8 -0
- package/dist/tui/detect-theme.d.ts +1 -0
- package/dist/tui/detect-theme.js +11 -5
- package/dist/tui-ink/app.d.ts +2 -6
- package/dist/tui-ink/app.js +11 -18
- package/dist/tui-ink/display-history.d.ts +0 -2
- package/dist/tui-ink/display-history.js +0 -52
- package/dist/tui-ink/footer.js +2 -2
- package/dist/tui-ink/input-box.js +18 -6
- package/dist/tui-ink/message-list.js +62 -23
- package/dist/tui-ink/run.js +10 -5
- package/dist/tui-ink/sync-output.d.ts +17 -0
- package/dist/tui-ink/sync-output.js +35 -0
- package/dist/tui-ink/theme.d.ts +0 -1
- package/dist/tui-ink/theme.js +0 -2
- package/dist/tui-ink/trace-groups.js +1 -1
- package/dist/types.d.ts +21 -1
- package/package.json +16 -2
- package/dist/agent/discovery-barrier.d.ts +0 -21
- package/dist/agent/discovery-barrier.js +0 -173
- package/dist/agent/evidence-tracker.d.ts +0 -15
- package/dist/agent/evidence-tracker.js +0 -93
- package/dist/agent/execution-governor.d.ts +0 -36
- package/dist/agent/execution-governor.js +0 -218
- package/dist/agent/task-classifier.d.ts +0 -3
- package/dist/agent/task-classifier.js +0 -114
- package/dist/agent/task-size.d.ts +0 -9
- package/dist/agent/task-size.js +0 -33
- package/dist/agent/tool-arbiter.d.ts +0 -7
- package/dist/agent/tool-arbiter.js +0 -33
- package/dist/orchestrator/workflow.d.ts +0 -10
- package/dist/orchestrator/workflow.js +0 -22
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent thread lifecycle + dynamic-workflow execution.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from Agent: spawn/wait/send/close, the scheduler dispatch and its
|
|
5
|
+
* terminal callbacks, admission, ingestion, worktree-backed child construction
|
|
6
|
+
* and the QuickJS workflow driver all lived on the Agent class alongside the
|
|
7
|
+
* conversation loop, sharing its private fields.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep the boundary honest:
|
|
10
|
+
* - Everything the runtime needs from the parent arrives through
|
|
11
|
+
* `SubagentRuntimeParent` as a LIVE accessor, never a construction-time
|
|
12
|
+
* snapshot: `/model` reassigns the parent's provider/model/thinking
|
|
13
|
+
* mid-session and children must inherit the current values.
|
|
14
|
+
* - Nothing is pushed back into the parent. Tool updates and ingestion
|
|
15
|
+
* notices are PULLED (`drainToolUpdates` / `drainIngestionNotices`) at the
|
|
16
|
+
* points in Agent.run() that previously called the private flush methods,
|
|
17
|
+
* matching ResultIntegrator.drainNotices.
|
|
18
|
+
*
|
|
19
|
+
* `new Agent(...)` stays on Agent, reached through `parent.createChild`. That
|
|
20
|
+
* is the only genuine call back into the parent, and keeping it there is what
|
|
21
|
+
* guarantees the child-construction argument set — including the fields that
|
|
22
|
+
* must stay ABSENT (onMessageAppend, sessionID, routing catalog) — has exactly
|
|
23
|
+
* one definition site.
|
|
24
|
+
*/
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
import { buildSystemPrompt } from "../../system-prompt.js";
|
|
27
|
+
import { buildToolPromptOptions } from "../../tools/index.js";
|
|
28
|
+
import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateStructuredSummary } from "../structured-output.js";
|
|
29
|
+
import { runWorkflow, WorkflowConcurrencyGate } from "../workflow/runtime.js";
|
|
30
|
+
import { assignAgentNickname, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "../profiles.js";
|
|
31
|
+
import { snapshotSubagentThread, subagentResultFromThread } from "../subagent-control.js";
|
|
32
|
+
import { SubagentStore } from "../subagent-store.js";
|
|
33
|
+
import { SubagentScheduler } from "../subagent-scheduler.js";
|
|
34
|
+
import { ChildRunner, classifySubagentAbortReason } from "../child-runner.js";
|
|
35
|
+
import { ResultIntegrator } from "../result-integrator.js";
|
|
36
|
+
import { SubagentAbortError } from "../abort-errors.js";
|
|
37
|
+
import { createSubagentWorktree, finalizeSubagentWorktree } from "../worktree.js";
|
|
38
|
+
import { createWorktreeChildTools, isolateReadonlyChildFileTools } from "../../tools/child-tools.js";
|
|
39
|
+
import { mergeAgentCategories, parseThinkingLevel } from "../categories.js";
|
|
40
|
+
import { composeAbortSignals } from "../budget-ledger.js";
|
|
41
|
+
export class SubagentRuntime {
|
|
42
|
+
parent;
|
|
43
|
+
router;
|
|
44
|
+
categories;
|
|
45
|
+
subagentsConfig;
|
|
46
|
+
/** Public so Agent can expose the same private-field access tests rely on. */
|
|
47
|
+
store;
|
|
48
|
+
scheduler;
|
|
49
|
+
childRunner;
|
|
50
|
+
resultIntegrator = new ResultIntegrator();
|
|
51
|
+
pendingUpdates = [];
|
|
52
|
+
updateWakers = new Set();
|
|
53
|
+
constructor(deps) {
|
|
54
|
+
this.parent = deps.parent;
|
|
55
|
+
this.router = deps.router;
|
|
56
|
+
this.categories = deps.categories;
|
|
57
|
+
this.subagentsConfig = deps.config;
|
|
58
|
+
// Eager, with loadPersisted() in the constructor: list_agents must return
|
|
59
|
+
// persisted children before any spawn happens this process.
|
|
60
|
+
this.store = new SubagentStore(deps.persistDir);
|
|
61
|
+
this.store.loadPersisted();
|
|
62
|
+
this.scheduler = new SubagentScheduler({
|
|
63
|
+
maxActiveSubagents: this.subagentsConfig.maxActiveSubagents,
|
|
64
|
+
launchBurst: this.subagentsConfig.launchBurst,
|
|
65
|
+
launchIntervalMs: this.subagentsConfig.launchIntervalMs,
|
|
66
|
+
rateLimitMaxAttempts: this.subagentsConfig.rateLimitMaxAttempts,
|
|
67
|
+
rateLimitBackoffMs: this.subagentsConfig.rateLimitBackoffMs,
|
|
68
|
+
transportRetryMaxAttempts: this.subagentsConfig.transportRetryMaxAttempts,
|
|
69
|
+
transportRetryBackoffMs: this.subagentsConfig.transportRetryBackoffMs,
|
|
70
|
+
getCategoryLimit: (category) => mergeAgentCategories(this.categories)[category]?.maxConcurrent,
|
|
71
|
+
});
|
|
72
|
+
this.childRunner = new ChildRunner({
|
|
73
|
+
allTools: () => this.parent.allTools(),
|
|
74
|
+
emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
|
|
75
|
+
runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
|
|
76
|
+
finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
|
|
77
|
+
createInstance: (record, tools, cwd, forkContext) => this.createSubAgentInstance(record, tools, cwd, forkContext),
|
|
78
|
+
notifyWaiters: (record) => this.store.notifyWaiters(record),
|
|
79
|
+
onFinal: (record, options) => {
|
|
80
|
+
this.reclaimWorktree(record);
|
|
81
|
+
// Workflow-internal agents are not persisted (they never re-import into
|
|
82
|
+
// the store on restart) and never ingest into parent context (option C).
|
|
83
|
+
if (!record.workflowInternal) {
|
|
84
|
+
this.store.persist(record);
|
|
85
|
+
this.maybeEnqueueIngestion(record, options);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
// ---- parent-facing view: everything Agent.run() pulls ----
|
|
91
|
+
/** True when a background child has queued an update the loop should drain. */
|
|
92
|
+
hasPendingUpdates() {
|
|
93
|
+
return this.pendingUpdates.length > 0;
|
|
94
|
+
}
|
|
95
|
+
/** Follows a session switch: evicts the old session's final children and loads the new one's. */
|
|
96
|
+
repointPersistDir(persistDir) {
|
|
97
|
+
this.store.repoint(persistDir);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Inspects and cleans up a child's worktree at ANY terminal outcome:
|
|
101
|
+
* unchanged → removed; changed → kept for the parent to review, with a
|
|
102
|
+
* diff stat in the handoff (§8). Called from ChildRunner's onFinal and
|
|
103
|
+
* from all three scheduler-terminal callbacks — the scheduler paths used
|
|
104
|
+
* to skip reclamation entirely, leaking the directory and its git
|
|
105
|
+
* registration whenever a write child exhausted its retries
|
|
106
|
+
* (known-defects #1).
|
|
107
|
+
*
|
|
108
|
+
* When the directory was removed (= the child left no changes, so there
|
|
109
|
+
* is no file state to lose), the record's worktree reference is cleared:
|
|
110
|
+
* a kept reference would send a later resume into a deleted directory —
|
|
111
|
+
* clearing it makes the resume path rebuild a fresh worktree instead
|
|
112
|
+
* (see ChildRunner's reuse guard).
|
|
113
|
+
*/
|
|
114
|
+
reclaimWorktree(record) {
|
|
115
|
+
if (!record.worktree)
|
|
116
|
+
return;
|
|
117
|
+
// finalizeSubagentWorktree is idempotent (already-finalized worktrees
|
|
118
|
+
// return unchanged); only the call that actually performed finalization
|
|
119
|
+
// may push the handoff note, or re-finalization would duplicate it.
|
|
120
|
+
const alreadyFinalized = record.worktree.changed !== undefined;
|
|
121
|
+
finalizeSubagentWorktree(record.worktree);
|
|
122
|
+
if (!alreadyFinalized && record.worktree.changed) {
|
|
123
|
+
record.toolNotes.push(`worktree: changes left in ${record.worktree.path} — review the diff before applying`);
|
|
124
|
+
}
|
|
125
|
+
if (record.worktree.removed)
|
|
126
|
+
record.worktree = undefined;
|
|
127
|
+
}
|
|
128
|
+
/** Registers a waker so a blocked tool-execution loop learns about updates. */
|
|
129
|
+
subscribe(wake) {
|
|
130
|
+
this.updateWakers.add(wake);
|
|
131
|
+
return () => this.updateWakers.delete(wake);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Completed background children whose summaries should reach parent context
|
|
135
|
+
* before the next inference turn (design §5). Pulled, not injected.
|
|
136
|
+
*/
|
|
137
|
+
drainIngestionNotices() {
|
|
138
|
+
if (!this.resultIntegrator.hasPending())
|
|
139
|
+
return [];
|
|
140
|
+
return this.resultIntegrator.drainNotices(this.store);
|
|
141
|
+
}
|
|
142
|
+
async runSubAgent(input, cwd, options) {
|
|
143
|
+
const record = this.createSubagentThreadRecord({
|
|
144
|
+
profile: options.profile,
|
|
145
|
+
task: typeof input === "string" ? input : "(multimodal task)",
|
|
146
|
+
runId: options.runId,
|
|
147
|
+
agentId: options.subAgentId,
|
|
148
|
+
parentToolCallId: options.parentToolCallId,
|
|
149
|
+
parentToolName: "subagent",
|
|
150
|
+
nickname: options.nickname,
|
|
151
|
+
route: options.route ?? this.router.resolve(options.profile, options.category),
|
|
152
|
+
});
|
|
153
|
+
this.store.set(record);
|
|
154
|
+
const approval = options.approval ?? options.profile.approval;
|
|
155
|
+
const admissionError = this.admitSubagentProfile(record, approval);
|
|
156
|
+
if (admissionError) {
|
|
157
|
+
this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.emitUpdate });
|
|
158
|
+
return subagentResultFromThread(record);
|
|
159
|
+
}
|
|
160
|
+
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
161
|
+
approval,
|
|
162
|
+
abortSignal: options.abortSignal,
|
|
163
|
+
forkContext: options.forkContext,
|
|
164
|
+
directEmit: options.emitUpdate,
|
|
165
|
+
});
|
|
166
|
+
await record.promise;
|
|
167
|
+
return subagentResultFromThread(record);
|
|
168
|
+
}
|
|
169
|
+
async spawnSubAgent(input, cwd, options) {
|
|
170
|
+
const route = options.route
|
|
171
|
+
?? this.router.resolve(options.profile, options.category, { model: options.model, effort: options.effort });
|
|
172
|
+
// Early validation (design §7): throws reach the model as a tool error it
|
|
173
|
+
// can correct this turn, instead of a late provider-factory failure.
|
|
174
|
+
const routeNote = this.router.validateForDispatch(route);
|
|
175
|
+
const record = this.createSubagentThreadRecord({
|
|
176
|
+
profile: options.profile,
|
|
177
|
+
task: typeof input === "string" ? input : "(multimodal task)",
|
|
178
|
+
parentToolCallId: options.parentToolCallId,
|
|
179
|
+
parentToolName: "spawn_agent",
|
|
180
|
+
route,
|
|
181
|
+
});
|
|
182
|
+
if (routeNote)
|
|
183
|
+
record.toolNotes.push(routeNote);
|
|
184
|
+
this.store.set(record);
|
|
185
|
+
const approval = options.approval ?? record.profile.approval;
|
|
186
|
+
// Admission validation runs before queueing (design §4.2): a request that
|
|
187
|
+
// would block never consumes a queue slot.
|
|
188
|
+
const admissionError = this.admitSubagentProfile(record, approval);
|
|
189
|
+
if (admissionError) {
|
|
190
|
+
this.finalizeSubagentBlocked(record, admissionError, { queueUpdates: true });
|
|
191
|
+
return this.snapshotSubagent(record);
|
|
192
|
+
}
|
|
193
|
+
this.queueSubagentUpdate(record, "queued", undefined, `Queued ${record.nickname} (${record.profile.name})`);
|
|
194
|
+
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
195
|
+
approval,
|
|
196
|
+
abortSignal: options.abortSignal,
|
|
197
|
+
forkContext: options.forkContext,
|
|
198
|
+
queueUpdates: true,
|
|
199
|
+
});
|
|
200
|
+
void record.promise.finally(() => this.store.notifyWaiters(record));
|
|
201
|
+
return this.snapshotSubagent(record);
|
|
202
|
+
}
|
|
203
|
+
async waitSubAgents(options = {}) {
|
|
204
|
+
const targets = this.resolveSubagentTargets(options.agentIds);
|
|
205
|
+
if (targets.length === 0)
|
|
206
|
+
return [];
|
|
207
|
+
const completed = targets.filter((record) => isFinalSubagentStatus(record.status));
|
|
208
|
+
if (completed.length > 0) {
|
|
209
|
+
for (const record of completed)
|
|
210
|
+
this.store.markDelivered(record.agentId);
|
|
211
|
+
return completed.map((record) => this.snapshotSubagent(record));
|
|
212
|
+
}
|
|
213
|
+
const timeoutMs = normalizeWaitTimeout(options.timeoutMs);
|
|
214
|
+
let waiter;
|
|
215
|
+
await Promise.race([
|
|
216
|
+
new Promise((resolve) => {
|
|
217
|
+
waiter = resolve;
|
|
218
|
+
for (const record of targets) {
|
|
219
|
+
record.waiters.add(resolve);
|
|
220
|
+
}
|
|
221
|
+
}).finally(() => {
|
|
222
|
+
if (waiter) {
|
|
223
|
+
for (const record of targets) {
|
|
224
|
+
record.waiters.delete(waiter);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
228
|
+
new Promise((resolve) => setTimeout(resolve, timeoutMs)),
|
|
229
|
+
]);
|
|
230
|
+
const finished = targets.filter((record) => isFinalSubagentStatus(record.status));
|
|
231
|
+
for (const record of finished)
|
|
232
|
+
this.store.markDelivered(record.agentId);
|
|
233
|
+
return (finished.length > 0 ? finished : targets).map((record) => this.snapshotSubagent(record));
|
|
234
|
+
}
|
|
235
|
+
async sendSubAgentInput(agentId, input, cwd, options = {}) {
|
|
236
|
+
const record = this.store.get(agentId);
|
|
237
|
+
if (!record) {
|
|
238
|
+
throw new Error(`Unknown subagent: ${agentId}`);
|
|
239
|
+
}
|
|
240
|
+
if (record.status === "running" || record.status === "queued") {
|
|
241
|
+
if (!options.interrupt) {
|
|
242
|
+
throw new Error(`Subagent ${agentId} is still running. Call wait_agent first or pass interrupt:true.`);
|
|
243
|
+
}
|
|
244
|
+
record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} interrupted.`, "interrupt"));
|
|
245
|
+
await record.promise?.catch(() => undefined);
|
|
246
|
+
record.abortController = new AbortController();
|
|
247
|
+
}
|
|
248
|
+
if (record.status === "closed") {
|
|
249
|
+
throw new Error(`Subagent ${agentId} is closed.`);
|
|
250
|
+
}
|
|
251
|
+
record.parentToolCallId = options.parentToolCallId ?? record.parentToolCallId;
|
|
252
|
+
record.parentToolName = "send_input";
|
|
253
|
+
record.task = typeof input === "string" ? input : "(multimodal task)";
|
|
254
|
+
record.summary = "";
|
|
255
|
+
record.toolNotes = [];
|
|
256
|
+
record.usage = undefined;
|
|
257
|
+
record.error = undefined;
|
|
258
|
+
record.finalReason = undefined;
|
|
259
|
+
record.deliveredAt = undefined;
|
|
260
|
+
record.updatedAt = Date.now();
|
|
261
|
+
if (record.worktree) {
|
|
262
|
+
// A kept (changed) worktree carries changed/diffStat frozen at the
|
|
263
|
+
// previous terminal outcome; reset them so the finalize after THIS
|
|
264
|
+
// run recomputes instead of reporting stale state.
|
|
265
|
+
record.worktree.changed = undefined;
|
|
266
|
+
record.worktree.diffStat = undefined;
|
|
267
|
+
}
|
|
268
|
+
// A send_input restart is a launch like any other: it goes through the
|
|
269
|
+
// scheduler's dispatch point and is subject to the same admission limits
|
|
270
|
+
// (design §4.1) — batch-resuming team members cannot bypass concurrency caps.
|
|
271
|
+
record.promise = this.dispatchSubagentRun(record, input, cwd, {
|
|
272
|
+
approval: record.profile.approval,
|
|
273
|
+
abortSignal: options.abortSignal,
|
|
274
|
+
queueUpdates: true,
|
|
275
|
+
reuseAgent: true,
|
|
276
|
+
});
|
|
277
|
+
void record.promise.finally(() => this.store.notifyWaiters(record));
|
|
278
|
+
return this.snapshotSubagent(record);
|
|
279
|
+
}
|
|
280
|
+
async closeSubAgent(agentId) {
|
|
281
|
+
const record = this.store.get(agentId);
|
|
282
|
+
if (!record) {
|
|
283
|
+
throw new Error(`Unknown subagent: ${agentId}`);
|
|
284
|
+
}
|
|
285
|
+
if (!isFinalSubagentStatus(record.status)) {
|
|
286
|
+
record.abortController.abort(new SubagentAbortError(`Subagent ${agentId} closed.`, "user_close"));
|
|
287
|
+
await record.promise?.catch(() => undefined);
|
|
288
|
+
}
|
|
289
|
+
record.status = "closed";
|
|
290
|
+
record.finalReason = record.finalReason ?? "cancelled_user";
|
|
291
|
+
record.updatedAt = Date.now();
|
|
292
|
+
this.queueSubagentUpdate(record, "cancelled", undefined, `${record.nickname} closed`);
|
|
293
|
+
this.store.persist(record);
|
|
294
|
+
this.store.notifyWaiters(record);
|
|
295
|
+
return this.snapshotSubagent(record);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Live (non-final) children only — the delegation-nudge gate
|
|
299
|
+
* (large-task-delegation design §2). listSubAgents() is a grows-only
|
|
300
|
+
* session history (finished + resumed children stay forever), so it must
|
|
301
|
+
* never be used to answer "am I currently delegating?".
|
|
302
|
+
*/
|
|
303
|
+
activeSubAgentCount() {
|
|
304
|
+
return this.store.activeCount();
|
|
305
|
+
}
|
|
306
|
+
listSubAgents() {
|
|
307
|
+
return this.store.values()
|
|
308
|
+
.filter((record) => !record.workflowInternal)
|
|
309
|
+
.map((record) => this.snapshotSubagent(record));
|
|
310
|
+
}
|
|
311
|
+
async executeWorkflow(cwd, options) {
|
|
312
|
+
const profiles = discoverAgentProfiles(cwd, "both").profiles;
|
|
313
|
+
const runRecords = [];
|
|
314
|
+
const logs = [];
|
|
315
|
+
// Per-run isolation (option C review): a concurrency sub-cap below the
|
|
316
|
+
// global limit so a workflow can't starve interactive subagents.
|
|
317
|
+
const interactiveReserve = 2;
|
|
318
|
+
const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
|
|
319
|
+
const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
|
|
320
|
+
const gate = new WorkflowConcurrencyGate(workflowConcurrency);
|
|
321
|
+
const dispatchAgent = async (spec) => {
|
|
322
|
+
const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
|
|
323
|
+
?? findAgentProfile(profiles, "default");
|
|
324
|
+
if (!baseProfile)
|
|
325
|
+
return { ok: false, error: "no default subagent profile available" };
|
|
326
|
+
// Workflow agents are readonly-by-default; mode upgrades come only from the
|
|
327
|
+
// profile, never from the script (security invariant).
|
|
328
|
+
const unsupported = baseProfile.mode !== "readonly" && baseProfile.mode !== "write_worktree";
|
|
329
|
+
if (unsupported)
|
|
330
|
+
return { ok: false, error: `profile "${baseProfile.name}" mode ${baseProfile.mode} not supported` };
|
|
331
|
+
// Default-no-network: unattended orchestration of net-capable agents is new
|
|
332
|
+
// authority in aggregate (option C review), so strip web tools unless the
|
|
333
|
+
// script opts in with agentType pointing at a profile that includes them.
|
|
334
|
+
const profile = {
|
|
335
|
+
...baseProfile,
|
|
336
|
+
tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
|
|
337
|
+
};
|
|
338
|
+
let route;
|
|
339
|
+
let routeNote;
|
|
340
|
+
try {
|
|
341
|
+
route = this.router.resolve(profile, spec.opts.category, {
|
|
342
|
+
model: spec.opts.model,
|
|
343
|
+
effort: parseThinkingLevel(spec.opts.effort),
|
|
344
|
+
});
|
|
345
|
+
// Dispatch-time validation (§7): resolved routes, never script source text.
|
|
346
|
+
routeNote = this.router.validateForDispatch(route);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
return { ok: false, error: error?.message || String(error) };
|
|
350
|
+
}
|
|
351
|
+
const baseTask = spec.opts.schema !== undefined
|
|
352
|
+
? appendOutputSchemaInstructions(spec.prompt, spec.opts.schema)
|
|
353
|
+
: spec.prompt;
|
|
354
|
+
const record = this.createSubagentThreadRecord({
|
|
355
|
+
profile,
|
|
356
|
+
task: baseTask,
|
|
357
|
+
parentToolCallId: options.parentToolCallId,
|
|
358
|
+
parentToolName: "run_workflow",
|
|
359
|
+
route,
|
|
360
|
+
workflowInternal: true,
|
|
361
|
+
});
|
|
362
|
+
record.expectsStructuredOutput = spec.opts.schema !== undefined;
|
|
363
|
+
if (routeNote)
|
|
364
|
+
record.toolNotes.push(routeNote);
|
|
365
|
+
const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
|
|
366
|
+
if (memberLabel)
|
|
367
|
+
record.nickname = memberLabel;
|
|
368
|
+
runRecords.push(record);
|
|
369
|
+
this.store.set(record);
|
|
370
|
+
// Project-local profiles pass the same first-use trust gate as
|
|
371
|
+
// spawn_agent: a .bubble/agents profile must never gain a side door
|
|
372
|
+
// into execution just because a script named it (Codex review on #58).
|
|
373
|
+
// Checked AFTER the record exists so a rejected member still shows up
|
|
374
|
+
// in the run's counts/snapshots as blocked instead of vanishing.
|
|
375
|
+
if (options.ensureProfileTrusted) {
|
|
376
|
+
const blocked = await options.ensureProfileTrusted(baseProfile);
|
|
377
|
+
if (blocked) {
|
|
378
|
+
const message = typeof blocked.content === "string" ? blocked.content : `profile "${baseProfile.name}" requires user approval`;
|
|
379
|
+
this.finalizeSubagentBlocked(record, message, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
|
|
380
|
+
return { ok: false, error: message };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const admissionError = this.admitSubagentProfile(record, profile.approval);
|
|
384
|
+
if (admissionError) {
|
|
385
|
+
this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
|
|
386
|
+
return { ok: false, error: admissionError };
|
|
387
|
+
}
|
|
388
|
+
// Leaf-only concurrency permit (option C review M5): held ONLY around this
|
|
389
|
+
// agent's dispatch, never across parallel/pipeline composition.
|
|
390
|
+
await gate.acquire();
|
|
391
|
+
try {
|
|
392
|
+
record.promise = this.dispatchSubagentRun(record, baseTask, cwd, {
|
|
393
|
+
approval: profile.approval,
|
|
394
|
+
abortSignal: options.abortSignal,
|
|
395
|
+
directEmit: options.directEmit,
|
|
396
|
+
queueUpdates: options.queueUpdates,
|
|
397
|
+
});
|
|
398
|
+
await record.promise;
|
|
399
|
+
}
|
|
400
|
+
finally {
|
|
401
|
+
gate.release();
|
|
402
|
+
}
|
|
403
|
+
this.store.markDelivered(record.agentId);
|
|
404
|
+
if (record.status !== "completed") {
|
|
405
|
+
return { ok: false, error: record.error || `agent ${record.nickname} ended: ${record.finalReason ?? record.status}` };
|
|
406
|
+
}
|
|
407
|
+
if (spec.opts.schema === undefined) {
|
|
408
|
+
return { ok: true, value: record.summary };
|
|
409
|
+
}
|
|
410
|
+
// Structured output: validate, one corrective retry, then fall back to raw.
|
|
411
|
+
// The schema: log lines are the telemetry that decides whether the
|
|
412
|
+
// deferred terminate-style structured-output tool ever gets reopened.
|
|
413
|
+
let validated = validateStructuredSummary(record.summary, spec.opts.schema);
|
|
414
|
+
if (!validated.ok) {
|
|
415
|
+
logs.push(`schema: ${record.nickname} first output failed validation; sending one corrective retry`);
|
|
416
|
+
try {
|
|
417
|
+
await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
|
|
418
|
+
await record.promise?.catch(() => undefined);
|
|
419
|
+
validated = validateStructuredSummary(record.summary, spec.opts.schema);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
// resume failed; fall through to raw summary
|
|
423
|
+
}
|
|
424
|
+
logs.push(validated.ok
|
|
425
|
+
? `schema: ${record.nickname} corrective retry produced valid output`
|
|
426
|
+
: `schema: ${record.nickname} corrective retry still invalid; returning raw summary`);
|
|
427
|
+
}
|
|
428
|
+
return { ok: true, value: validated.ok ? validated.value : record.summary };
|
|
429
|
+
};
|
|
430
|
+
const result = await runWorkflow({
|
|
431
|
+
script: options.script,
|
|
432
|
+
args: options.args,
|
|
433
|
+
dispatchAgent,
|
|
434
|
+
onLog: (message) => logs.push(message),
|
|
435
|
+
onPhase: (title) => logs.push(`— phase: ${title} —`),
|
|
436
|
+
budget: {
|
|
437
|
+
// The ledger is pure accounting (no pool limit); scripts see an
|
|
438
|
+
// unlimited budget unless a future host contract reintroduces one.
|
|
439
|
+
total: null,
|
|
440
|
+
spent: () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0),
|
|
441
|
+
remaining: () => Number.POSITIVE_INFINITY,
|
|
442
|
+
},
|
|
443
|
+
signal: options.abortSignal,
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
result,
|
|
447
|
+
agentCount: runRecords.length,
|
|
448
|
+
logs,
|
|
449
|
+
snapshots: runRecords.map((record) => this.snapshotSubagent(record)),
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
markSubagentDelivered(agentId) {
|
|
453
|
+
this.store.markDelivered(agentId);
|
|
454
|
+
}
|
|
455
|
+
snapshotSubagent(record) {
|
|
456
|
+
const snapshot = snapshotSubagentThread(record);
|
|
457
|
+
if (record.status === "queued") {
|
|
458
|
+
const queuePosition = this.scheduler.queuePosition(record.agentId);
|
|
459
|
+
if (queuePosition !== undefined)
|
|
460
|
+
return { ...snapshot, queuePosition };
|
|
461
|
+
}
|
|
462
|
+
return snapshot;
|
|
463
|
+
}
|
|
464
|
+
/** Returns the blocking diagnostic message when the profile cannot run, else undefined. */
|
|
465
|
+
admitSubagentProfile(record, approval) {
|
|
466
|
+
const diagnostics = validateAgentProfileTools(this.parent.allTools(), record.profile, approval);
|
|
467
|
+
const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
468
|
+
if (blocking.length === 0)
|
|
469
|
+
return undefined;
|
|
470
|
+
return blocking.map((diagnostic) => diagnostic.message).join("\n");
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Background children (queueUpdates) get their results ingested before the
|
|
474
|
+
* parent's next inference turn (design §5); foreground children (team,
|
|
475
|
+
* legacy task) deliver through their tool result instead.
|
|
476
|
+
*/
|
|
477
|
+
maybeEnqueueIngestion(record, options) {
|
|
478
|
+
if (options.queueUpdates) {
|
|
479
|
+
this.resultIntegrator.enqueue(record.agentId);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
finalizeSubagentBlocked(record, error, emitOptions) {
|
|
483
|
+
record.status = "blocked";
|
|
484
|
+
record.finalReason = "blocked";
|
|
485
|
+
record.error = error;
|
|
486
|
+
record.updatedAt = Date.now();
|
|
487
|
+
this.emitSubagentLifecycle(record, emitOptions, "blocked", undefined, error);
|
|
488
|
+
this.store.persist(record);
|
|
489
|
+
this.store.notifyWaiters(record);
|
|
490
|
+
}
|
|
491
|
+
dispatchSubagentRun(record, input, cwd, options) {
|
|
492
|
+
record.status = "queued";
|
|
493
|
+
record.updatedAt = Date.now();
|
|
494
|
+
const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
|
|
495
|
+
return this.scheduler.dispatch({
|
|
496
|
+
agentId: record.agentId,
|
|
497
|
+
category: record.category,
|
|
498
|
+
signal: queueSignal,
|
|
499
|
+
run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
|
|
500
|
+
onCancelledWhileQueued: (reason) => {
|
|
501
|
+
record.status = "cancelled";
|
|
502
|
+
record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
|
|
503
|
+
record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
|
|
504
|
+
record.updatedAt = Date.now();
|
|
505
|
+
// This path is NOT only "run never started" — a 429/transport
|
|
506
|
+
// failure re-queues the entry with the abort listener re-armed, so
|
|
507
|
+
// an abort during backoff lands here AFTER attempt 1 already ran
|
|
508
|
+
// (worktree created, SubagentStart fired). Reclaim is therefore a
|
|
509
|
+
// real leak fix here, and an open Start pair must be closed too
|
|
510
|
+
// (design §9): only when no Start ever fired is skipping Stop right.
|
|
511
|
+
if (record.hookStopPending) {
|
|
512
|
+
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
513
|
+
}
|
|
514
|
+
this.reclaimWorktree(record);
|
|
515
|
+
this.emitSubagentLifecycle(record, options, "cancelled", undefined, record.error);
|
|
516
|
+
this.store.persist(record);
|
|
517
|
+
this.store.notifyWaiters(record);
|
|
518
|
+
this.maybeEnqueueIngestion(record, options);
|
|
519
|
+
},
|
|
520
|
+
onRateLimitExhausted: (attempts) => {
|
|
521
|
+
record.status = "failed";
|
|
522
|
+
record.finalReason = "rate_limited_exhausted";
|
|
523
|
+
record.error = `Provider rate limit persisted after ${attempts} attempts.`;
|
|
524
|
+
record.updatedAt = Date.now();
|
|
525
|
+
// Reclaim before persist so the handoff note lands in the
|
|
526
|
+
// persisted toolNotes.
|
|
527
|
+
this.reclaimWorktree(record);
|
|
528
|
+
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
529
|
+
this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
|
|
530
|
+
this.store.persist(record);
|
|
531
|
+
this.store.notifyWaiters(record);
|
|
532
|
+
this.maybeEnqueueIngestion(record, options);
|
|
533
|
+
},
|
|
534
|
+
onTransportRetryExhausted: (attempts) => {
|
|
535
|
+
record.status = "failed";
|
|
536
|
+
// failed_transient stays resumable, so the parent can still send_input
|
|
537
|
+
// to recover the child with its context intact.
|
|
538
|
+
record.finalReason = "failed_transient";
|
|
539
|
+
record.error = `Provider transport error persisted after ${attempts} attempts.`;
|
|
540
|
+
record.updatedAt = Date.now();
|
|
541
|
+
this.reclaimWorktree(record);
|
|
542
|
+
void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
|
|
543
|
+
this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
|
|
544
|
+
this.store.persist(record);
|
|
545
|
+
this.store.notifyWaiters(record);
|
|
546
|
+
this.maybeEnqueueIngestion(record, options);
|
|
547
|
+
},
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
emitSubagentLifecycle(record, options, status, event, message) {
|
|
551
|
+
const update = this.buildSubagentUpdate(record, status, event, message);
|
|
552
|
+
options.directEmit?.(update);
|
|
553
|
+
if (options.queueUpdates) {
|
|
554
|
+
this.pendingUpdates.push({ id: record.parentToolCallId, name: record.parentToolName, update });
|
|
555
|
+
this.wakeSubagentUpdateWaiters();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/** Lets a blocked tool-execution loop drain freshly queued subagent updates. */
|
|
559
|
+
wakeSubagentUpdateWaiters() {
|
|
560
|
+
for (const wake of this.updateWakers)
|
|
561
|
+
wake();
|
|
562
|
+
}
|
|
563
|
+
async runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal) {
|
|
564
|
+
// Pairing state (design §9), tracked at this single choke point —
|
|
565
|
+
// every Start/Stop passes through here, so no call site can desync it.
|
|
566
|
+
// Set synchronously, before the await, to stay ordered with callers.
|
|
567
|
+
record.hookStopPending = eventName === "SubagentStart";
|
|
568
|
+
try {
|
|
569
|
+
await this.parent.runExternalHook({
|
|
570
|
+
eventName,
|
|
571
|
+
cwd,
|
|
572
|
+
runId: record.runId,
|
|
573
|
+
target: record.profile.name,
|
|
574
|
+
payload: {
|
|
575
|
+
agentId: record.agentId,
|
|
576
|
+
nickname: record.nickname,
|
|
577
|
+
profile: record.profile.name,
|
|
578
|
+
status,
|
|
579
|
+
error,
|
|
580
|
+
},
|
|
581
|
+
}, abortSignal);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Subagent lifecycle hooks are observe-only; never fail the subagent.
|
|
585
|
+
// Observe-only extends to the RESULT, not just errors: the returned
|
|
586
|
+
// events and modelContext are dropped ON PURPOSE, matching every other
|
|
587
|
+
// terminal/lifecycle event (Stop, StopFailure, SessionStart/End).
|
|
588
|
+
// Injecting them would push each child's start/stop hook output into
|
|
589
|
+
// the parent transcript, once per child in a fan-out. Do not "fix"
|
|
590
|
+
// this by wiring the result into injectHookModelContext.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
createSubagentThreadRecord(options) {
|
|
594
|
+
const now = Date.now();
|
|
595
|
+
const nickname = options.nickname ?? assignAgentNickname(options.profile, this.activeSubagentNicknames());
|
|
596
|
+
return {
|
|
597
|
+
agentId: options.agentId ?? randomUUID(),
|
|
598
|
+
runId: options.runId ?? randomUUID(),
|
|
599
|
+
nickname,
|
|
600
|
+
profile: options.profile,
|
|
601
|
+
category: options.route?.category,
|
|
602
|
+
route: options.route,
|
|
603
|
+
workflowInternal: options.workflowInternal,
|
|
604
|
+
parentToolCallId: options.parentToolCallId,
|
|
605
|
+
parentToolName: options.parentToolName,
|
|
606
|
+
status: "queued",
|
|
607
|
+
task: options.task,
|
|
608
|
+
summary: "",
|
|
609
|
+
toolNotes: [],
|
|
610
|
+
createdAt: now,
|
|
611
|
+
updatedAt: now,
|
|
612
|
+
abortController: new AbortController(),
|
|
613
|
+
waiters: new Set(),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
runSubagentThread(record, input, cwd, options) {
|
|
617
|
+
return this.childRunner.run(record, input, cwd, options);
|
|
618
|
+
}
|
|
619
|
+
async createSubAgentInstance(record, tools, cwd, forkContext) {
|
|
620
|
+
let childCwd = cwd;
|
|
621
|
+
let childMode = "plan";
|
|
622
|
+
if (record.profile.mode === "write_worktree") {
|
|
623
|
+
// Write children work in a runtime-allocated worktree with fresh tool
|
|
624
|
+
// instances bound to it (design §8): the parent tree is never touched,
|
|
625
|
+
// and the tools' own workspace fence enforces containment in code.
|
|
626
|
+
// NOTE: record.worktree must be assigned before anything below can
|
|
627
|
+
// throw — ChildRunner's failure path reclaims it off the record.
|
|
628
|
+
if (!record.worktree) {
|
|
629
|
+
record.worktree = createSubagentWorktree(cwd, record.agentId);
|
|
630
|
+
}
|
|
631
|
+
childCwd = record.worktree.path;
|
|
632
|
+
childMode = "default";
|
|
633
|
+
tools = createWorktreeChildTools(childCwd, record.profile.tools.include);
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
// Readonly children share the parent's tool instances; isolate the only
|
|
637
|
+
// one with mutable file state (read → its FileStateTracker) so concurrent
|
|
638
|
+
// fan-out members never race shared tool state (design v2 §2).
|
|
639
|
+
tools = isolateReadonlyChildFileTools(tools);
|
|
640
|
+
}
|
|
641
|
+
const childToolNames = tools.map((tool) => tool.name);
|
|
642
|
+
const route = record.route ?? {
|
|
643
|
+
providerId: this.parent.providerId,
|
|
644
|
+
model: this.parent.apiModel,
|
|
645
|
+
thinkingLevel: this.parent.thinkingLevel,
|
|
646
|
+
inherited: true,
|
|
647
|
+
};
|
|
648
|
+
const provider = await this.resolveProviderForRoute(route);
|
|
649
|
+
const childSystemPrompt = buildSystemPrompt({
|
|
650
|
+
agentName: "Bubble",
|
|
651
|
+
configuredProvider: route.providerId || "none",
|
|
652
|
+
configuredModel: route.model || "none",
|
|
653
|
+
configuredModelId: route.providerId && route.model ? `${route.providerId}:${route.model}` : route.model || "none",
|
|
654
|
+
thinkingLevel: route.thinkingLevel,
|
|
655
|
+
mode: childMode,
|
|
656
|
+
workingDir: childCwd,
|
|
657
|
+
...buildToolPromptOptions(tools),
|
|
658
|
+
memoryPrompt: childToolNames.some((name) => name === "memory")
|
|
659
|
+
? this.parent.memoryPrompt
|
|
660
|
+
: undefined,
|
|
661
|
+
agentProfilePrompt: [
|
|
662
|
+
`You are subagent ${record.nickname}. Your agent profile is ${record.profile.name}.`,
|
|
663
|
+
record.profile.mode === "write_worktree"
|
|
664
|
+
? [
|
|
665
|
+
"You work inside an isolated git worktree; the parent reviews your diff after you finish.",
|
|
666
|
+
"Make your changes, verify them (run tests where possible), and end with a handoff that lists the files you changed and how you verified them.",
|
|
667
|
+
"Do not commit, push, or touch anything outside this worktree.",
|
|
668
|
+
].join(" ")
|
|
669
|
+
: "",
|
|
670
|
+
record.profile.prompt,
|
|
671
|
+
].filter(Boolean).join("\n\n"),
|
|
672
|
+
});
|
|
673
|
+
// Cross-restart resume (design §7): rebuild the child from its persisted
|
|
674
|
+
// history — including its original system prompt — so send_input continues
|
|
675
|
+
// with context intact. Consumed here so a later run does not re-seed it.
|
|
676
|
+
const resumeMessages = record.messages && record.messages.length > 0
|
|
677
|
+
? record.messages.map((message) => ({ ...message }))
|
|
678
|
+
: undefined;
|
|
679
|
+
if (resumeMessages)
|
|
680
|
+
record.messages = undefined;
|
|
681
|
+
return this.parent.createChild({
|
|
682
|
+
provider,
|
|
683
|
+
providerId: route.providerId,
|
|
684
|
+
model: route.model,
|
|
685
|
+
tools,
|
|
686
|
+
thinkingLevel: route.thinkingLevel,
|
|
687
|
+
mode: childMode,
|
|
688
|
+
maxTurns: record.profile.maxTurns,
|
|
689
|
+
budgetSource: { runId: record.runId, subAgentId: record.agentId },
|
|
690
|
+
systemPrompt: childSystemPrompt,
|
|
691
|
+
subAgentId: record.agentId,
|
|
692
|
+
resumeMessages,
|
|
693
|
+
forkContext,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
async resolveProviderForRoute(route) {
|
|
697
|
+
if (!route.providerId || route.providerId === this.parent.providerId) {
|
|
698
|
+
return this.parent.provider;
|
|
699
|
+
}
|
|
700
|
+
if (!this.parent.providerFactory) {
|
|
701
|
+
throw new Error([
|
|
702
|
+
`Subagent route requires provider "${route.providerId}" for model "${route.model}",`,
|
|
703
|
+
`but the parent agent only has provider "${this.parent.providerId || "none"}" and no provider factory is configured.`,
|
|
704
|
+
].join(" "));
|
|
705
|
+
}
|
|
706
|
+
return this.parent.providerFactory(route);
|
|
707
|
+
}
|
|
708
|
+
buildSubagentUpdate(record, status, event, message) {
|
|
709
|
+
return {
|
|
710
|
+
type: "subagent_update",
|
|
711
|
+
parentToolCallId: record.parentToolCallId,
|
|
712
|
+
runId: record.runId,
|
|
713
|
+
subAgentId: record.agentId,
|
|
714
|
+
agentName: record.profile.name,
|
|
715
|
+
nickname: record.nickname,
|
|
716
|
+
category: record.category,
|
|
717
|
+
route: record.route,
|
|
718
|
+
status,
|
|
719
|
+
childEvent: event,
|
|
720
|
+
summaryDelta: event?.type === "text_delta" ? event.content : undefined,
|
|
721
|
+
toolName: "name" in (event ?? {}) ? event.name : undefined,
|
|
722
|
+
toolCallId: "id" in (event ?? {}) ? event.id : undefined,
|
|
723
|
+
message,
|
|
724
|
+
metadata: {
|
|
725
|
+
kind: "subagent",
|
|
726
|
+
runId: record.runId,
|
|
727
|
+
subagents: [{
|
|
728
|
+
subAgentId: record.agentId,
|
|
729
|
+
agentName: record.profile.name,
|
|
730
|
+
nickname: record.nickname,
|
|
731
|
+
category: record.category,
|
|
732
|
+
route: record.route,
|
|
733
|
+
status,
|
|
734
|
+
profileSource: record.profile.source,
|
|
735
|
+
task: record.task,
|
|
736
|
+
summary: record.summary,
|
|
737
|
+
toolNotes: record.toolNotes,
|
|
738
|
+
usage: record.usage,
|
|
739
|
+
error: record.error,
|
|
740
|
+
}],
|
|
741
|
+
},
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
queueSubagentUpdate(record, status, event, message) {
|
|
745
|
+
this.pendingUpdates.push({
|
|
746
|
+
id: record.parentToolCallId,
|
|
747
|
+
name: record.parentToolName,
|
|
748
|
+
update: this.buildSubagentUpdate(record, status, event, message),
|
|
749
|
+
});
|
|
750
|
+
this.wakeSubagentUpdateWaiters();
|
|
751
|
+
}
|
|
752
|
+
/** Queued background-child updates for Agent.run() to emit. */
|
|
753
|
+
drainToolUpdates() {
|
|
754
|
+
return this.pendingUpdates.splice(0, this.pendingUpdates.length)
|
|
755
|
+
.map((pending) => ({
|
|
756
|
+
type: "tool_update",
|
|
757
|
+
id: pending.id,
|
|
758
|
+
name: pending.name,
|
|
759
|
+
update: pending.update,
|
|
760
|
+
}));
|
|
761
|
+
}
|
|
762
|
+
activeSubagentNicknames() {
|
|
763
|
+
return this.store.active().map((record) => record.nickname);
|
|
764
|
+
}
|
|
765
|
+
resolveSubagentTargets(agentIds) {
|
|
766
|
+
if (!agentIds || agentIds.length === 0) {
|
|
767
|
+
return this.store.values().filter((record) => record.status !== "closed" && !record.workflowInternal);
|
|
768
|
+
}
|
|
769
|
+
return agentIds.map((id) => {
|
|
770
|
+
const record = this.store.get(id);
|
|
771
|
+
if (!record) {
|
|
772
|
+
throw new Error(`Unknown subagent: ${id}`);
|
|
773
|
+
}
|
|
774
|
+
return record;
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
export function isFinalSubagentStatus(status) {
|
|
779
|
+
return status === "completed"
|
|
780
|
+
|| status === "failed"
|
|
781
|
+
|| status === "blocked"
|
|
782
|
+
|| status === "cancelled"
|
|
783
|
+
|| status === "closed";
|
|
784
|
+
}
|
|
785
|
+
export function normalizeWaitTimeout(value) {
|
|
786
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
787
|
+
return 30_000;
|
|
788
|
+
return Math.max(100, Math.min(3_600_000, Math.floor(value)));
|
|
789
|
+
}
|