@bubblebrain-ai/bubble 0.0.34 → 0.0.35

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 (52) hide show
  1. package/dist/agent/abort-errors.d.ts +2 -2
  2. package/dist/agent/budget-ledger.d.ts +4 -45
  3. package/dist/agent/budget-ledger.js +5 -75
  4. package/dist/agent/child-runner.d.ts +1 -3
  5. package/dist/agent/child-runner.js +6 -37
  6. package/dist/agent/profiles.d.ts +0 -2
  7. package/dist/agent/profiles.js +0 -3
  8. package/dist/agent/subagent-control.d.ts +5 -12
  9. package/dist/agent/subagent-control.js +0 -2
  10. package/dist/agent/subagent-store.d.ts +1 -1
  11. package/dist/agent/subagent-store.js +1 -3
  12. package/dist/agent/workflow/control.d.ts +7 -0
  13. package/dist/agent/workflow/control.js +22 -1
  14. package/dist/agent/workflow/runtime.d.ts +15 -2
  15. package/dist/agent/workflow/runtime.js +112 -30
  16. package/dist/agent.d.ts +7 -43
  17. package/dist/agent.js +47 -171
  18. package/dist/config.d.ts +1 -3
  19. package/dist/config.js +6 -5
  20. package/dist/main.js +9 -1
  21. package/dist/model-catalog.js +8 -14
  22. package/dist/orchestrator/default-hooks.js +7 -1
  23. package/dist/prompt/delegation.d.ts +1 -1
  24. package/dist/prompt/delegation.js +21 -7
  25. package/dist/prompt/task-reminders.d.ts +1 -0
  26. package/dist/prompt/task-reminders.js +24 -1
  27. package/dist/provider-transform.d.ts +1 -1
  28. package/dist/provider-transform.js +8 -13
  29. package/dist/tools/agent-lifecycle.d.ts +1 -9
  30. package/dist/tools/agent-lifecycle.js +35 -263
  31. package/dist/tui-ink/app.js +25 -12
  32. package/dist/tui-ink/approval/approval-dialog.js +1 -1
  33. package/dist/tui-ink/approval/diff-view.js +4 -4
  34. package/dist/tui-ink/approval/select.js +2 -2
  35. package/dist/tui-ink/feedback-dialog.js +2 -2
  36. package/dist/tui-ink/feishu-setup-picker.js +3 -3
  37. package/dist/tui-ink/input-box.d.ts +1 -6
  38. package/dist/tui-ink/input-box.js +14 -35
  39. package/dist/tui-ink/markdown.js +11 -9
  40. package/dist/tui-ink/message-list.d.ts +5 -1
  41. package/dist/tui-ink/message-list.js +15 -2
  42. package/dist/tui-ink/model-picker.js +19 -13
  43. package/dist/tui-ink/plan-confirm.js +1 -1
  44. package/dist/tui-ink/subagent-view.d.ts +3 -1
  45. package/dist/tui-ink/theme.d.ts +19 -2
  46. package/dist/tui-ink/theme.js +25 -8
  47. package/dist/tui-ink/welcome.d.ts +3 -2
  48. package/dist/tui-ink/welcome.js +10 -6
  49. package/dist/types.d.ts +3 -26
  50. package/dist/variant/variant-resolver.d.ts +13 -0
  51. package/dist/variant/variant-resolver.js +26 -0
  52. package/package.json +5 -2
@@ -50,16 +50,28 @@ globalThis.agent = async (prompt, opts) => {
50
50
  const raw = await __agent(JSON.stringify({ prompt: String(prompt ?? ""), opts: opts || {} }));
51
51
  return JSON.parse(raw);
52
52
  };
53
- globalThis.parallel = (thunks) => Promise.all((thunks || []).map((t) => {
54
- try { return Promise.resolve(t()).catch(() => null); } catch (_e) { return Promise.resolve(null); }
55
- }));
56
- globalThis.pipeline = (items, ...stages) => Promise.all((items || []).map(async (item, i) => {
57
- let v = item;
58
- for (const stage of stages) {
59
- try { v = await stage(v, item, i); } catch (_e) { return null; }
53
+ globalThis.parallel = (thunks) => {
54
+ if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
55
+ if (thunks.some((t) => typeof t !== "function")) {
56
+ throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
60
57
  }
61
- return v;
62
- }));
58
+ return Promise.all(thunks.map((t) => {
59
+ try { return Promise.resolve(t()).catch(() => null); } catch (_e) { return Promise.resolve(null); }
60
+ }));
61
+ };
62
+ globalThis.pipeline = (items, ...stages) => {
63
+ if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
64
+ if (stages.some((s) => typeof s !== "function")) {
65
+ throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
66
+ }
67
+ return Promise.all(items.map(async (item, i) => {
68
+ let v = item;
69
+ for (const stage of stages) {
70
+ try { v = await stage(v, item, i); } catch (_e) { return null; }
71
+ }
72
+ return v;
73
+ }));
74
+ };
63
75
  globalThis.phase = (t) => __phase(String(t ?? ""));
64
76
  globalThis.log = (m) => __log(String(m ?? ""));
65
77
  globalThis.budget = {
@@ -71,18 +83,68 @@ globalThis.budget = {
71
83
  /** Removes ambient nondeterminism so a run is reproducible (design §4.3). */
72
84
  const DETERMINISM_GATING = `
73
85
  delete globalThis.Date;
86
+ Object.defineProperty(globalThis, "Date", {
87
+ configurable: false,
88
+ get() { throw new Error("Workflow scripts must be deterministic: Date.now()/new Date() are unavailable — pass timestamps in via args"); },
89
+ });
74
90
  delete globalThis.WeakRef;
75
91
  delete globalThis.FinalizationRegistry;
76
- Math.random = () => { throw new Error("Math.random is disabled in workflows (nondeterministic)"); };
92
+ Math.random = () => { throw new Error("Workflow scripts must be deterministic: Math.random() is unavailable"); };
77
93
  `;
78
94
  /** Turns `export const meta = …` / `export function …` into plain declarations. */
79
95
  function stripExports(script) {
80
96
  return script.replace(/(^|\n)\s*export\s+(const|let|var|function|class|async)\b/g, "$1$2");
81
97
  }
98
+ /** Lines injected before the script body in the wrapped source (keep in sync with buildWrappedWorkflowSource). */
99
+ const WRAPPER_LINE_OFFSET = 2;
100
+ /** The EXACT source runWorkflow executes — the syntax probe must compile this, not the raw script. */
101
+ export function buildWrappedWorkflowSource(script) {
102
+ return [
103
+ "globalThis.__wfdone = false; globalThis.__wfresult = null; globalThis.__wferror = null;",
104
+ "(async () => {",
105
+ stripExports(script),
106
+ "})().then(",
107
+ " (r) => { globalThis.__wfresult = r === undefined ? null : r; globalThis.__wfdone = true; },",
108
+ " (e) => { globalThis.__wferror = (e && e.message) ? String(e.message) : String(e); globalThis.__wfdone = true; }",
109
+ ");",
110
+ ].join("\n");
111
+ }
112
+ /**
113
+ * Submit-time syntax check: compiles the exact transformed source with
114
+ * QuickJS's compile-only flag (same engine, zero grammar divergence) so the
115
+ * model gets a teaching error as the immediate tool result instead of one
116
+ * full turn later through the background-run delivery path.
117
+ */
118
+ export async function precompileWorkflowScript(script) {
119
+ const QuickJS = await getQuickJS();
120
+ const vm = QuickJS.newContext();
121
+ try {
122
+ const result = vm.evalCode(buildWrappedWorkflowSource(script), "workflow.js", { compileOnly: true });
123
+ if (result.error) {
124
+ const message = vm.dump(result.error);
125
+ result.error.dispose();
126
+ return { ok: false, error: `workflow script error: ${formatError(message)}` };
127
+ }
128
+ result.value.dispose();
129
+ return { ok: true };
130
+ }
131
+ finally {
132
+ try {
133
+ vm.dispose();
134
+ }
135
+ catch { /* teardown must not mask the verdict */ }
136
+ }
137
+ }
82
138
  export async function runWorkflow(options) {
83
139
  const QuickJS = await getQuickJS();
84
140
  const vm = QuickJS.newContext();
85
141
  const pending = new Set();
142
+ // Deferreds for in-flight agent() calls. If the run ends while agents are
143
+ // still flying (user abort, stall, compute deadline), these were never
144
+ // settled and their VM-side handles are still alive — disposing the VM
145
+ // with them alive trips QuickJS's JS_FreeRuntime assertion and Aborts the
146
+ // (process-wide, cached) wasm module, poisoning every later workflow.
147
+ const liveDeferreds = new Set();
86
148
  const state = { disposed: false };
87
149
  let agentCount = 0;
88
150
  const maxAgents = options.maxAgents ?? DEFAULT_MAX_AGENTS;
@@ -93,23 +155,14 @@ export async function runWorkflow(options) {
93
155
  vm.runtime.setInterruptHandler(() => Date.now() - computeStart > computeDeadline);
94
156
  try {
95
157
  vm.unwrapResult(vm.evalCode(DETERMINISM_GATING)).dispose();
96
- installHostFunctions(vm, options, pending, () => agentCount, () => { agentCount += 1; }, maxAgents, state);
158
+ installHostFunctions(vm, options, pending, () => agentCount, () => { agentCount += 1; }, maxAgents, state, liveDeferreds);
97
159
  // args as a deterministic injected global.
98
160
  const argsJson = JSON.stringify(options.args ?? null);
99
161
  vm.unwrapResult(vm.evalCode(`globalThis.args = ${argsJson};`)).dispose();
100
162
  vm.unwrapResult(vm.evalCode(PRELUDE)).dispose();
101
- const body = stripExports(options.script);
102
- const wrapped = [
103
- "globalThis.__wfdone = false; globalThis.__wfresult = null; globalThis.__wferror = null;",
104
- "(async () => {",
105
- body,
106
- "})().then(",
107
- " (r) => { globalThis.__wfresult = r === undefined ? null : r; globalThis.__wfdone = true; },",
108
- " (e) => { globalThis.__wferror = (e && e.message) ? String(e.message) : String(e); globalThis.__wfdone = true; }",
109
- ");",
110
- ].join("\n");
163
+ const wrapped = buildWrappedWorkflowSource(options.script);
111
164
  computeStart = Date.now();
112
- const evalResult = vm.evalCode(wrapped);
165
+ const evalResult = vm.evalCode(wrapped, "workflow.js");
113
166
  if (evalResult.error) {
114
167
  const message = vm.dump(evalResult.error);
115
168
  evalResult.error.dispose();
@@ -154,7 +207,17 @@ export async function runWorkflow(options) {
154
207
  }
155
208
  finally {
156
209
  state.disposed = true;
157
- vm.dispose();
210
+ for (const deferred of liveDeferreds) {
211
+ try {
212
+ deferred.dispose();
213
+ }
214
+ catch { /* already settled/disposed */ }
215
+ }
216
+ liveDeferreds.clear();
217
+ try {
218
+ vm.dispose();
219
+ }
220
+ catch { /* a dispose failure must not mask the run's result */ }
158
221
  }
159
222
  }
160
223
  function abortRace(signal) {
@@ -164,10 +227,11 @@ function abortRace(signal) {
164
227
  return Promise.resolve();
165
228
  return new Promise((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
166
229
  }
167
- function installHostFunctions(vm, options, pending, getCount, bumpCount, maxAgents, state) {
230
+ function installHostFunctions(vm, options, pending, getCount, bumpCount, maxAgents, state, liveDeferreds) {
168
231
  vm.newFunction("__agent", (specHandle) => {
169
232
  const spec = JSON.parse(vm.getString(specHandle));
170
233
  const deferred = vm.newPromise();
234
+ liveDeferreds.add(deferred);
171
235
  // Settling the VM promise touches the context, which may have been disposed
172
236
  // if the run was aborted while host work was still in flight — guard it.
173
237
  const settle = (fn) => {
@@ -203,11 +267,14 @@ function installHostFunctions(vm, options, pending, getCount, bumpCount, maxAgen
203
267
  })).finally(() => { pending.delete(p); });
204
268
  pending.add(p);
205
269
  }
206
- deferred.settled.then(() => { if (!state.disposed)
207
- try {
208
- vm.runtime.executePendingJobs();
209
- }
210
- catch { /* disposed */ } });
270
+ deferred.settled.then(() => {
271
+ liveDeferreds.delete(deferred);
272
+ if (!state.disposed)
273
+ try {
274
+ vm.runtime.executePendingJobs();
275
+ }
276
+ catch { /* disposed */ }
277
+ });
211
278
  return deferred.handle;
212
279
  }).consume((f) => vm.setProp(vm.global, "__agent", f));
213
280
  vm.newFunction("__phase", (h) => {
@@ -231,7 +298,22 @@ function installHostFunctions(vm, options, pending, getCount, bumpCount, maxAgen
231
298
  }
232
299
  function formatError(value) {
233
300
  if (value && typeof value === "object" && "message" in value) {
234
- return String(value.message);
301
+ const obj = value;
302
+ const message = String(obj.message);
303
+ const line = extractScriptLine(obj);
304
+ return line === undefined ? message : `${message} (script line ${line})`;
235
305
  }
236
306
  return String(value);
237
307
  }
308
+ /** Maps a QuickJS error location back onto the raw script's line numbering. */
309
+ function extractScriptLine(obj) {
310
+ if (typeof obj.lineNumber === "number" && Number.isFinite(obj.lineNumber)) {
311
+ return Math.max(1, obj.lineNumber - WRAPPER_LINE_OFFSET);
312
+ }
313
+ if (typeof obj.stack === "string") {
314
+ const match = obj.stack.match(/workflow\.js:(\d+)/);
315
+ if (match)
316
+ return Math.max(1, Number(match[1]) - WRAPPER_LINE_OFFSET);
317
+ }
318
+ return undefined;
319
+ }
package/dist/agent.d.ts CHANGED
@@ -16,10 +16,9 @@ import type { SkillSummary } from "./skills/types.js";
16
16
  import type { FileStateTracker } from "./tools/file-state.js";
17
17
  export { AgentAbortError, SubagentAbortError } from "./agent/abort-errors.js";
18
18
  export declare const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
19
- /** Runtime tuning for the subagent scheduler and per-child budgets. */
19
+ /** Runtime tuning for the subagent scheduler. */
20
20
  export interface AgentSubagentRuntimeConfig {
21
21
  maxActiveSubagents?: number;
22
- childTokenCap?: number;
23
22
  launchBurst?: number;
24
23
  launchIntervalMs?: number;
25
24
  rateLimitMaxAttempts?: number;
@@ -223,47 +222,6 @@ export declare class Agent {
223
222
  }): Promise<SubagentThreadSnapshot>;
224
223
  closeSubAgent(agentId: string): Promise<SubagentThreadSnapshot>;
225
224
  listSubAgents(): SubagentThreadSnapshot[];
226
- /**
227
- * Homogeneous map fan-out (design §1.2): one profile, one template, N items.
228
- * Every member goes through the same admission and the same scheduler
229
- * dispatch as spawn_agent; the tool blocks until all members are final.
230
- */
231
- runAgentTeam(cwd: string, options: {
232
- profile: AgentProfile;
233
- category?: string;
234
- model?: string;
235
- effort?: ThinkingLevel;
236
- promptTemplate: string;
237
- items: string[];
238
- parentToolCallId: string;
239
- emitUpdate?: (update: ToolUpdate) => void;
240
- abortSignal?: AbortSignal;
241
- approval?: "fail" | "disabled";
242
- }): Promise<SubagentThreadSnapshot[]>;
243
- /**
244
- * Heterogeneous fan-out (design v2 §1.3): N independent specs, each with its
245
- * own task, profile, and per-call model/effort, dispatched concurrently as a
246
- * SINGLE tool call. Unlike runAgentTeam (one template over N items), members
247
- * differ. Like the team, every member goes through the same scheduler
248
- * dispatch and the tool blocks until all are final, returning in spec order.
249
- * Keeping fan-out inside one tool call (rather than N parallel spawn_agent
250
- * tool_calls) avoids the provider parallel-tool_call bug (Kimi 400 / lost
251
- * responses).
252
- */
253
- runAgentBatch(cwd: string, options: {
254
- specs: Array<{
255
- task: string;
256
- profile: AgentProfile;
257
- category?: string;
258
- model?: string;
259
- effort?: ThinkingLevel;
260
- outputSchema?: unknown;
261
- }>;
262
- parentToolCallId: string;
263
- emitUpdate?: (update: ToolUpdate) => void;
264
- abortSignal?: AbortSignal;
265
- approval?: "fail" | "disabled";
266
- }): Promise<SubagentThreadSnapshot[]>;
267
225
  /**
268
226
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
269
227
  * a QuickJS sandbox. Each agent() call in the script becomes a real scheduled
@@ -280,6 +238,9 @@ export declare class Agent {
280
238
  parentToolCallId: string;
281
239
  emitUpdate?: (update: ToolUpdate) => void;
282
240
  abortSignal?: AbortSignal;
241
+ ensureProfileTrusted?: (profile: AgentProfile) => Promise<{
242
+ content: string | unknown;
243
+ } | undefined>;
283
244
  }): Promise<{
284
245
  result: {
285
246
  ok: true;
@@ -304,6 +265,9 @@ export declare class Agent {
304
265
  title?: string;
305
266
  parentToolCallId: string;
306
267
  abortSignal?: AbortSignal;
268
+ ensureProfileTrusted?: (profile: AgentProfile) => Promise<{
269
+ content: string | unknown;
270
+ } | undefined>;
307
271
  }): {
308
272
  runId: string;
309
273
  title: string;
package/dist/agent.js CHANGED
@@ -21,7 +21,7 @@ import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateSt
21
21
  import { runWorkflow, WorkflowConcurrencyGate } from "./agent/workflow/runtime.js";
22
22
  import { buildWorkflowDeliveryNotice } from "./agent/workflow/control.js";
23
23
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
24
- import { composeAbortSignals, computeChildTokenCap, DEFAULT_CHILD_TOKEN_CAP, PARENT_POOL_RESERVE_RATIO } from "./agent/budget-ledger.js";
24
+ import { composeAbortSignals } from "./agent/budget-ledger.js";
25
25
  import { assignAgentNickname, builtinAgentProfiles, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
26
26
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
27
27
  import { SubagentStore } from "./agent/subagent-store.js";
@@ -179,7 +179,6 @@ export class Agent {
179
179
  });
180
180
  this.childRunner = new ChildRunner({
181
181
  allTools: () => [...this.tools.values()],
182
- budgetLedger: () => this.budgetLedger,
183
182
  emit: (record, options, status, event, message) => this.emitSubagentLifecycle(record, options, status, event, message),
184
183
  runLifecycleHook: (record, cwd, eventName, status, error, abortSignal) => this.runSubagentLifecycleHookFor(record, cwd, eventName, status, error, abortSignal),
185
184
  finalizeBlocked: (record, error, options) => this.finalizeSubagentBlocked(record, error, options),
@@ -388,7 +387,7 @@ export class Agent {
388
387
  this.messages.unshift(systemMessage);
389
388
  }
390
389
  async *run(userInput, cwd, options = {}) {
391
- const abortSignal = composeAbortSignals([options.abortSignal, this.budgetLedger?.signal]);
390
+ const abortSignal = options.abortSignal;
392
391
  const inputController = options.inputController;
393
392
  const traceContext = {
394
393
  cwd,
@@ -911,14 +910,14 @@ export class Agent {
911
910
  }
912
911
  let tc = parsedCalls[index];
913
912
  let blockedResult;
914
- // agent_team must be the only tool call in its response (design
915
- // §1.2): concurrent calls race the scheduler and the aggregated
916
- // reply; the rejection teaches the model how to re-issue.
917
- if (tc.name === "agent_team" && parsedCalls.length > 1) {
913
+ // run_workflow must be the only tool call in its response: it starts
914
+ // a background orchestration whose result lands next turn, and
915
+ // sibling calls racing it defeat the serialized fan-out contract.
916
+ if (tc.name === "run_workflow" && parsedCalls.length > 1) {
918
917
  blockedResult = {
919
918
  content: [
920
- "agent_team must be the only tool call in your response.",
921
- "Re-issue agent_team alone — one call, nothing else in the same message — and run any other tools or additional teams sequentially after it returns.",
919
+ "run_workflow must be the only tool call in your response.",
920
+ "Re-issue run_workflow alone — one call, nothing else in the same message — and run other tools after it returns.",
922
921
  ].join(" "),
923
922
  isError: true,
924
923
  status: "blocked",
@@ -1580,141 +1579,6 @@ export class Agent {
1580
1579
  .filter((record) => !record.workflowInternal)
1581
1580
  .map((record) => this.snapshotSubagent(record));
1582
1581
  }
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
1582
  /**
1719
1583
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
1720
1584
  * a QuickJS sandbox. Each agent() call in the script becomes a real scheduled
@@ -1732,6 +1596,7 @@ export class Agent {
1732
1596
  parentToolCallId: options.parentToolCallId,
1733
1597
  abortSignal: options.abortSignal,
1734
1598
  directEmit: options.emitUpdate,
1599
+ ensureProfileTrusted: options.ensureProfileTrusted,
1735
1600
  });
1736
1601
  }
1737
1602
  /**
@@ -1766,6 +1631,7 @@ export class Agent {
1766
1631
  parentToolCallId: options.parentToolCallId,
1767
1632
  abortSignal: abortController.signal,
1768
1633
  queueUpdates: true,
1634
+ ensureProfileTrusted: options.ensureProfileTrusted,
1769
1635
  }).then((out) => {
1770
1636
  record.agentCount = out.agentCount;
1771
1637
  record.snapshots = out.snapshots;
@@ -1842,22 +1708,13 @@ export class Agent {
1842
1708
  const profiles = discoverAgentProfiles(cwd, "both").profiles;
1843
1709
  const runRecords = [];
1844
1710
  const logs = [];
1845
- // Per-run isolation (option C review): a token ceiling that aborts only this
1846
- // run (never the parent) and a concurrency sub-cap below the global limit so
1847
- // a workflow can't starve interactive subagents.
1848
- const poolLimit = this.budgetLedger?.poolLimit;
1849
- const runTokenCeiling = poolLimit !== undefined
1850
- ? Math.max(1, Math.floor(poolLimit * (1 - PARENT_POOL_RESERVE_RATIO)))
1851
- : Number.POSITIVE_INFINITY;
1852
- const runSpent = () => runRecords.reduce((sum, r) => sum + (r.usage ? r.usage.promptTokens + r.usage.completionTokens : 0), 0);
1711
+ // Per-run isolation (option C review): a concurrency sub-cap below the
1712
+ // global limit so a workflow can't starve interactive subagents.
1853
1713
  const interactiveReserve = 2;
1854
1714
  const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
1855
1715
  const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
1856
1716
  const gate = new WorkflowConcurrencyGate(workflowConcurrency);
1857
1717
  const dispatchAgent = async (spec) => {
1858
- if (runSpent() >= runTokenCeiling) {
1859
- return { ok: false, error: "workflow token ceiling reached; not launching more agents" };
1860
- }
1861
1718
  const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
1862
1719
  ?? findAgentProfile(profiles, "default");
1863
1720
  if (!baseProfile)
@@ -1895,8 +1752,25 @@ export class Agent {
1895
1752
  route,
1896
1753
  workflowInternal: true,
1897
1754
  });
1755
+ record.expectsStructuredOutput = spec.opts.schema !== undefined;
1756
+ const memberLabel = typeof spec.opts.label === "string" ? spec.opts.label.trim().slice(0, 40) : "";
1757
+ if (memberLabel)
1758
+ record.nickname = memberLabel;
1898
1759
  runRecords.push(record);
1899
1760
  this.subagentStore.set(record);
1761
+ // Project-local profiles pass the same first-use trust gate as
1762
+ // spawn_agent: a .bubble/agents profile must never gain a side door
1763
+ // into execution just because a script named it (Codex review on #58).
1764
+ // Checked AFTER the record exists so a rejected member still shows up
1765
+ // in the run's counts/snapshots as blocked instead of vanishing.
1766
+ if (options.ensureProfileTrusted) {
1767
+ const blocked = await options.ensureProfileTrusted(baseProfile);
1768
+ if (blocked) {
1769
+ const message = typeof blocked.content === "string" ? blocked.content : `profile "${baseProfile.name}" requires user approval`;
1770
+ this.finalizeSubagentBlocked(record, message, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
1771
+ return { ok: false, error: message };
1772
+ }
1773
+ }
1900
1774
  const admissionError = this.admitSubagentProfile(record, profile.approval);
1901
1775
  if (admissionError) {
1902
1776
  this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
@@ -1925,8 +1799,11 @@ export class Agent {
1925
1799
  return { ok: true, value: record.summary };
1926
1800
  }
1927
1801
  // Structured output: validate, one corrective retry, then fall back to raw.
1802
+ // The schema: log lines are the telemetry that decides whether the
1803
+ // deferred terminate-style structured-output tool ever gets reopened.
1928
1804
  let validated = validateStructuredSummary(record.summary, spec.opts.schema);
1929
1805
  if (!validated.ok) {
1806
+ logs.push(`schema: ${record.nickname} first output failed validation; sending one corrective retry`);
1930
1807
  try {
1931
1808
  await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
1932
1809
  await record.promise?.catch(() => undefined);
@@ -1935,6 +1812,9 @@ export class Agent {
1935
1812
  catch {
1936
1813
  // resume failed; fall through to raw summary
1937
1814
  }
1815
+ logs.push(validated.ok
1816
+ ? `schema: ${record.nickname} corrective retry produced valid output`
1817
+ : `schema: ${record.nickname} corrective retry still invalid; returning raw summary`);
1938
1818
  }
1939
1819
  return { ok: true, value: validated.ok ? validated.value : record.summary };
1940
1820
  };
@@ -1945,14 +1825,11 @@ export class Agent {
1945
1825
  onLog: (message) => logs.push(message),
1946
1826
  onPhase: (title) => logs.push(`— phase: ${title} —`),
1947
1827
  budget: {
1948
- total: this.budgetLedger?.poolLimit ?? null,
1828
+ // The ledger is pure accounting (no pool limit); scripts see an
1829
+ // unlimited budget unless a future host contract reintroduces one.
1830
+ total: null,
1949
1831
  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
- },
1832
+ remaining: () => Number.POSITIVE_INFINITY,
1956
1833
  },
1957
1834
  signal: options.abortSignal,
1958
1835
  });
@@ -2013,13 +1890,6 @@ export class Agent {
2013
1890
  dispatchSubagentRun(record, input, cwd, options) {
2014
1891
  record.status = "queued";
2015
1892
  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
1893
  const queueSignal = composeAbortSignals([options.abortSignal, record.abortController.signal]);
2024
1894
  return this.subagentScheduler.dispatch({
2025
1895
  agentId: record.agentId,
@@ -2028,7 +1898,7 @@ export class Agent {
2028
1898
  run: (ctx) => this.runSubagentThread(record, input, cwd, { ...options, attempt: ctx.attempt }),
2029
1899
  onCancelledWhileQueued: (reason) => {
2030
1900
  record.status = "cancelled";
2031
- record.finalReason = classifySubagentAbortReason(reason, options.abortSignal, this.budgetLedger);
1901
+ record.finalReason = classifySubagentAbortReason(reason, options.abortSignal);
2032
1902
  record.error = reason instanceof Error ? reason.message : reason ? String(reason) : "Cancelled while queued.";
2033
1903
  record.updatedAt = Date.now();
2034
1904
  // The run never started, so no SubagentStart fired and no SubagentStop follows.
@@ -2780,5 +2650,11 @@ function isSubagentLifecycleTool(name) {
2780
2650
  || name === "send_input"
2781
2651
  || name === "close_agent"
2782
2652
  || name === "list_agents"
2783
- || name === "agent_team";
2653
+ || name === "run_workflow"
2654
+ || name === "wait_workflow"
2655
+ // Legacy names: still present in transcripts recorded before the tools
2656
+ // were removed (2026-07-06); forked children must not inherit their
2657
+ // dangling tool_calls either.
2658
+ || name === "agent_team"
2659
+ || name === "agent_batch";
2784
2660
  }
package/dist/config.d.ts CHANGED
@@ -36,8 +36,6 @@ export interface UserConfigData {
36
36
  export interface SubagentsUserConfig {
37
37
  /** Global cap on concurrently running children. Default 8. */
38
38
  maxActiveSubagents?: number;
39
- /** Absolute per-child soft token cap. Default 200000. */
40
- childTokenCap?: number;
41
39
  }
42
40
  export declare class UserConfig {
43
41
  private data;
@@ -66,7 +64,7 @@ export declare class UserConfig {
66
64
  getAgentCategories(): AgentCategoriesConfig;
67
65
  getSubagents(): SubagentsUserConfig;
68
66
  }
69
- export declare function shouldProbeTerminalTheme(config: ThemeConfig): boolean;
67
+ export declare function shouldProbeTerminalTheme(_config: ThemeConfig): boolean;
70
68
  export declare function effectiveThemeModeForTerminal(config: ThemeConfig, detectedTheme: Exclude<ThemeMode, "auto">): ThemeMode;
71
69
  /** Mask an API key for safe display. */
72
70
  export declare function maskKey(key: string): string;
package/dist/config.js CHANGED
@@ -45,9 +45,6 @@ function sanitizeSubagentsConfig(value) {
45
45
  if (typeof raw.maxActiveSubagents === "number" && Number.isFinite(raw.maxActiveSubagents)) {
46
46
  out.maxActiveSubagents = Math.max(1, Math.floor(raw.maxActiveSubagents));
47
47
  }
48
- if (typeof raw.childTokenCap === "number" && Number.isFinite(raw.childTokenCap)) {
49
- out.childTokenCap = Math.max(1_000, Math.floor(raw.childTokenCap));
50
- }
51
48
  return Object.keys(out).length > 0 ? out : undefined;
52
49
  }
53
50
  function sanitizeTheme(value) {
@@ -211,8 +208,12 @@ export class UserConfig {
211
208
  return sanitizeSubagentsConfig(this.data.subagents) ?? {};
212
209
  }
213
210
  }
214
- export function shouldProbeTerminalTheme(config) {
215
- return config.mode === "auto" || isLegacyBareDarkTheme(config);
211
+ export function shouldProbeTerminalTheme(_config) {
212
+ // Always probe: even a forced theme needs the real terminal background to
213
+ // decide between inheriting it (theme matches the terminal) and painting
214
+ // its own canvas (forced theme mismatching the terminal, where the
215
+ // palette's foregrounds would otherwise be unreadable).
216
+ return true;
216
217
  }
217
218
  export function effectiveThemeModeForTerminal(config, detectedTheme) {
218
219
  if (isLegacyBareDarkTheme(config) && detectedTheme === "light")