@bubblebrain-ai/bubble 0.0.46 → 0.0.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/agent/child-runner.js +11 -0
  2. package/dist/agent/internal-reminder-sanitizer.d.ts +7 -0
  3. package/dist/agent/internal-reminder-sanitizer.js +13 -0
  4. package/dist/agent/subagent/router.d.ts +65 -0
  5. package/dist/agent/subagent/router.js +198 -0
  6. package/dist/agent/subagent/runtime.d.ts +203 -0
  7. package/dist/agent/subagent/runtime.js +789 -0
  8. package/dist/agent/subagent-control.d.ts +5 -0
  9. package/dist/agent/subagent-store.d.ts +18 -2
  10. package/dist/agent/subagent-store.js +32 -1
  11. package/dist/agent/tool-intent.d.ts +10 -0
  12. package/dist/agent/tool-intent.js +18 -0
  13. package/dist/agent/tool-visibility.d.ts +10 -0
  14. package/dist/agent/tool-visibility.js +6 -0
  15. package/dist/agent/workflow/runs.d.ts +72 -0
  16. package/dist/agent/workflow/runs.js +157 -0
  17. package/dist/agent.d.ts +78 -96
  18. package/dist/agent.js +266 -1072
  19. package/dist/context/compact-llm.js +14 -5
  20. package/dist/context/compact.d.ts +9 -0
  21. package/dist/context/compact.js +36 -10
  22. package/dist/context/compaction-files.d.ts +44 -0
  23. package/dist/context/compaction-files.js +148 -0
  24. package/dist/context/llm-compactor.js +24 -5
  25. package/dist/feishu/agent-host/run-driver.js +3 -0
  26. package/dist/feishu/serve.js +13 -9
  27. package/dist/goal/tools.js +12 -3
  28. package/dist/main.js +24 -19
  29. package/dist/model-catalog.js +36 -2
  30. package/dist/model-config.d.ts +2 -0
  31. package/dist/model-data.generated.d.ts +16 -0
  32. package/dist/model-data.generated.js +275 -0
  33. package/dist/model-pricing.d.ts +11 -7
  34. package/dist/model-pricing.js +0 -0
  35. package/dist/oauth/grok.d.ts +1 -1
  36. package/dist/oauth/grok.js +5 -2
  37. package/dist/oauth/storage.d.ts +9 -2
  38. package/dist/oauth/storage.js +16 -9
  39. package/dist/orchestrator/default-hooks.d.ts +20 -0
  40. package/dist/orchestrator/default-hooks.js +47 -295
  41. package/dist/orchestrator/hooks.d.ts +6 -39
  42. package/dist/permission/mode.js +1 -1
  43. package/dist/prompt/reminders.d.ts +9 -35
  44. package/dist/prompt/reminders.js +30 -136
  45. package/dist/prompt/task-reminders.d.ts +0 -31
  46. package/dist/prompt/task-reminders.js +0 -87
  47. package/dist/provider-ai-sdk.d.ts +2 -0
  48. package/dist/provider-ai-sdk.js +1 -0
  49. package/dist/provider-anthropic.d.ts +2 -0
  50. package/dist/provider-anthropic.js +3 -1
  51. package/dist/provider-ark-responses.d.ts +2 -0
  52. package/dist/provider-ark-responses.js +1 -0
  53. package/dist/provider-registry.d.ts +13 -0
  54. package/dist/provider-registry.js +83 -0
  55. package/dist/provider-transform.js +37 -1
  56. package/dist/provider.d.ts +7 -0
  57. package/dist/provider.js +13 -1
  58. package/dist/sdk/index.d.ts +152 -0
  59. package/dist/sdk/index.js +400 -0
  60. package/dist/session.js +9 -1
  61. package/dist/slash-commands/commands.js +33 -19
  62. package/dist/tools/exit-plan-mode.js +8 -0
  63. package/dist/tui/detect-theme.d.ts +1 -0
  64. package/dist/tui/detect-theme.js +11 -5
  65. package/dist/tui-ink/app.d.ts +2 -6
  66. package/dist/tui-ink/app.js +11 -18
  67. package/dist/tui-ink/display-history.d.ts +0 -2
  68. package/dist/tui-ink/display-history.js +0 -52
  69. package/dist/tui-ink/footer.js +2 -2
  70. package/dist/tui-ink/message-list.js +62 -23
  71. package/dist/tui-ink/run.js +10 -5
  72. package/dist/tui-ink/sync-output.d.ts +17 -0
  73. package/dist/tui-ink/sync-output.js +35 -0
  74. package/dist/tui-ink/theme.d.ts +0 -1
  75. package/dist/tui-ink/theme.js +0 -2
  76. package/dist/tui-ink/trace-groups.js +1 -1
  77. package/dist/types.d.ts +21 -1
  78. package/package.json +16 -2
  79. package/dist/agent/discovery-barrier.d.ts +0 -21
  80. package/dist/agent/discovery-barrier.js +0 -173
  81. package/dist/agent/evidence-tracker.d.ts +0 -15
  82. package/dist/agent/evidence-tracker.js +0 -93
  83. package/dist/agent/execution-governor.d.ts +0 -36
  84. package/dist/agent/execution-governor.js +0 -218
  85. package/dist/agent/task-classifier.d.ts +0 -3
  86. package/dist/agent/task-classifier.js +0 -114
  87. package/dist/agent/task-size.d.ts +0 -9
  88. package/dist/agent/task-size.js +0 -33
  89. package/dist/agent/tool-arbiter.d.ts +0 -7
  90. package/dist/agent/tool-arbiter.js +0 -33
  91. package/dist/orchestrator/workflow.d.ts +0 -10
  92. 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
+ }