@bubblebrain-ai/bubble 0.0.28 → 0.0.29

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 (59) hide show
  1. package/README.md +21 -0
  2. package/dist/agent/categories.d.ts +2 -0
  3. package/dist/agent/categories.js +4 -0
  4. package/dist/agent/child-runner.d.ts +5 -1
  5. package/dist/agent/child-runner.js +35 -2
  6. package/dist/agent/profiles.js +3 -0
  7. package/dist/agent/structured-output.d.ts +37 -0
  8. package/dist/agent/structured-output.js +193 -0
  9. package/dist/agent/subagent-control.d.ts +3 -0
  10. package/dist/agent/subagent-scheduler.d.ts +10 -0
  11. package/dist/agent/subagent-scheduler.js +31 -0
  12. package/dist/agent/workflow/control.d.ts +37 -0
  13. package/dist/agent/workflow/control.js +20 -0
  14. package/dist/agent/workflow/errors.d.ts +16 -0
  15. package/dist/agent/workflow/errors.js +24 -0
  16. package/dist/agent/workflow/runtime.d.ts +75 -0
  17. package/dist/agent/workflow/runtime.js +237 -0
  18. package/dist/agent.d.ts +105 -0
  19. package/dist/agent.js +425 -17
  20. package/dist/context/compact-llm.d.ts +10 -1
  21. package/dist/context/compact-llm.js +13 -5
  22. package/dist/context/compact.d.ts +30 -0
  23. package/dist/context/compact.js +34 -17
  24. package/dist/network/provider-transport.d.ts +9 -0
  25. package/dist/network/provider-transport.js +19 -1
  26. package/dist/provider.d.ts +14 -0
  27. package/dist/provider.js +24 -0
  28. package/dist/session.d.ts +16 -0
  29. package/dist/session.js +33 -1
  30. package/dist/slash-commands/commands.js +47 -1
  31. package/dist/slash-commands/types.d.ts +16 -1
  32. package/dist/tools/agent-lifecycle.d.ts +6 -0
  33. package/dist/tools/agent-lifecycle.js +285 -0
  34. package/dist/tools/child-tools.d.ts +10 -0
  35. package/dist/tools/child-tools.js +12 -0
  36. package/dist/tools/read.d.ts +1 -1
  37. package/dist/tools/read.js +9 -0
  38. package/dist/tui/image-display.d.ts +6 -0
  39. package/dist/tui/image-display.js +26 -1
  40. package/dist/tui-ink/app.js +84 -6
  41. package/dist/tui-ink/compaction-progress.d.ts +19 -0
  42. package/dist/tui-ink/compaction-progress.js +74 -0
  43. package/dist/tui-ink/input-box.d.ts +7 -1
  44. package/dist/tui-ink/input-box.js +48 -15
  45. package/dist/tui-ink/markdown.d.ts +18 -0
  46. package/dist/tui-ink/markdown.js +172 -16
  47. package/dist/tui-ink/message-list.js +38 -94
  48. package/dist/tui-ink/run.js +5 -0
  49. package/dist/tui-ink/subagent-inspector.d.ts +17 -0
  50. package/dist/tui-ink/subagent-inspector.js +189 -0
  51. package/dist/tui-ink/subagent-view.d.ts +47 -0
  52. package/dist/tui-ink/subagent-view.js +163 -0
  53. package/dist/tui-ink/terminal-env.d.ts +15 -0
  54. package/dist/tui-ink/terminal-env.js +22 -0
  55. package/dist/tui-ink/use-terminal-size.js +33 -6
  56. package/dist/tui-ink/width.d.ts +18 -0
  57. package/dist/tui-ink/width.js +130 -0
  58. package/dist/types.d.ts +35 -0
  59. package/package.json +2 -1
package/dist/agent.js CHANGED
@@ -16,10 +16,13 @@ import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode }
16
16
  import { HookBus } from "./orchestrator/hooks.js";
17
17
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
18
18
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
19
- import { mergeAgentCategories, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
19
+ import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
20
+ import { appendOutputSchemaInstructions, buildSchemaCorrectionPrompt, validateStructuredSummary } from "./agent/structured-output.js";
21
+ import { runWorkflow, WorkflowConcurrencyGate } from "./agent/workflow/runtime.js";
22
+ import { buildWorkflowDeliveryNotice } from "./agent/workflow/control.js";
20
23
  import { getSubtaskPolicy } from "./agent/subtask-policy.js";
21
24
  import { composeAbortSignals, computeChildTokenCap, DEFAULT_CHILD_TOKEN_CAP, PARENT_POOL_RESERVE_RATIO } from "./agent/budget-ledger.js";
22
- import { assignAgentNickname, builtinAgentProfiles, validateAgentProfileTools } from "./agent/profiles.js";
25
+ import { assignAgentNickname, builtinAgentProfiles, discoverAgentProfiles, findAgentProfile, validateAgentProfileTools } from "./agent/profiles.js";
23
26
  import { snapshotSubagentThread, subagentResultFromThread } from "./agent/subagent-control.js";
24
27
  import { SubagentStore } from "./agent/subagent-store.js";
25
28
  import { SubagentScheduler } from "./agent/subagent-scheduler.js";
@@ -27,7 +30,7 @@ import { ChildRunner, classifySubagentAbortReason } from "./agent/child-runner.j
27
30
  import { ResultIntegrator } from "./agent/result-integrator.js";
28
31
  import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./agent/abort-errors.js";
29
32
  import { createSubagentWorktree, finalizeSubagentWorktree } from "./agent/worktree.js";
30
- import { createWorktreeChildTools } from "./tools/child-tools.js";
33
+ import { createWorktreeChildTools, isolateReadonlyChildFileTools } from "./tools/child-tools.js";
31
34
  import { isHiddenToolResult } from "./agent/discovery-barrier.js";
32
35
  import { createStreamingInternalReminderSanitizer, sanitizeAssistantProviderMetadata, sanitizeInternalReasoningText, sanitizeInternalReminderBlocks, } from "./agent/internal-reminder-sanitizer.js";
33
36
  import { buildSystemPrompt } from "./system-prompt.js";
@@ -118,6 +121,10 @@ export class Agent {
118
121
  subagentScheduler;
119
122
  childRunner;
120
123
  resultIntegrator = new ResultIntegrator();
124
+ /** Background dynamic-workflow runs (option C Phase 4), keyed by runId. */
125
+ workflowRuns = new Map();
126
+ /** runIds whose completed result should be ingested at the next turn. */
127
+ pendingWorkflowDeliveries = new Set();
121
128
  subagentsConfig;
122
129
  rateLimitPolicy;
123
130
  pendingSubagentUpdates = [];
@@ -166,6 +173,8 @@ export class Agent {
166
173
  launchIntervalMs: this.subagentsConfig.launchIntervalMs,
167
174
  rateLimitMaxAttempts: this.subagentsConfig.rateLimitMaxAttempts,
168
175
  rateLimitBackoffMs: this.subagentsConfig.rateLimitBackoffMs,
176
+ transportRetryMaxAttempts: this.subagentsConfig.transportRetryMaxAttempts,
177
+ transportRetryBackoffMs: this.subagentsConfig.transportRetryBackoffMs,
169
178
  getCategoryLimit: (category) => mergeAgentCategories(this.agentCategories)[category]?.maxConcurrent,
170
179
  });
171
180
  this.childRunner = new ChildRunner({
@@ -185,8 +194,12 @@ export class Agent {
185
194
  record.toolNotes.push(`worktree: changes left in ${record.worktree.path} — review the diff before applying`);
186
195
  }
187
196
  }
188
- this.subagentStore.persist(record);
189
- this.maybeEnqueueIngestion(record, options);
197
+ // Workflow-internal agents are not persisted (they never re-import into
198
+ // the store on restart) and never ingest into parent context (option C).
199
+ if (!record.workflowInternal) {
200
+ this.subagentStore.persist(record);
201
+ this.maybeEnqueueIngestion(record, options);
202
+ }
190
203
  },
191
204
  });
192
205
  if (options.systemPrompt) {
@@ -533,6 +546,7 @@ export class Agent {
533
546
  // Background child completions surface before the next inference turn
534
547
  // without requiring a wait_agent call (design §5).
535
548
  this.flushSubagentIngestions();
549
+ this.flushWorkflowDeliveries();
536
550
  for (const update of this.drainSubagentToolUpdates())
537
551
  yield emit(update);
538
552
  for (const event of await applyPendingInputs())
@@ -1351,6 +1365,39 @@ export class Agent {
1351
1365
  // If LLM compaction failed for any reason, leave this.messages alone —
1352
1366
  // the projector's algorithmic budgeted-mode passes will still try.
1353
1367
  }
1368
+ /**
1369
+ * Stream a 9-section handoff summary of `oldMessages` from the session model.
1370
+ * Powers the manual `/compact` command: streaming (rather than `complete()`)
1371
+ * is what lets the TUI show live progress as the summary is produced.
1372
+ *
1373
+ * `onDelta` receives the full accumulated text and the latest delta on each
1374
+ * chunk. Returns the trimmed summary, or "" if the model produced nothing
1375
+ * (the caller falls back to heuristic compaction in that case). Throws only
1376
+ * if the provider stream itself errors.
1377
+ */
1378
+ async summarizeForCompaction(oldMessages, onDelta, abortSignal) {
1379
+ if (oldMessages.length === 0)
1380
+ return "";
1381
+ const { buildCompactionPromptMessages } = await import("./context/compact-llm.js");
1382
+ const promptMessages = buildCompactionPromptMessages(oldMessages);
1383
+ const stream = this.provider.streamChat(promptMessages, {
1384
+ model: this.apiModel,
1385
+ temperature: 0.2,
1386
+ thinkingLevel: "off",
1387
+ abortSignal,
1388
+ });
1389
+ let full = "";
1390
+ for await (const chunk of stream) {
1391
+ if (chunk.type === "text" && chunk.content) {
1392
+ full += chunk.content;
1393
+ onDelta?.(full, chunk.content);
1394
+ }
1395
+ }
1396
+ // Strip any internal reminder markup the summarizer may have reproduced from
1397
+ // the transcript: this summary is both displayed in the compaction card and
1398
+ // re-injected as a `Previous conversation summary` system message.
1399
+ return sanitizeInternalReminderBlocks(full).trim();
1400
+ }
1354
1401
  async runSubtask(input, cwd, options) {
1355
1402
  const subtaskType = options?.subtaskType;
1356
1403
  const profile = builtinAgentProfiles().find((item) => item.subtaskType === (subtaskType ?? "general_readonly"))
@@ -1420,7 +1467,7 @@ export class Agent {
1420
1467
  task: typeof input === "string" ? input : "(multimodal task)",
1421
1468
  parentToolCallId: options.parentToolCallId,
1422
1469
  parentToolName: "spawn_agent",
1423
- route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category),
1470
+ route: options.route ?? this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort }),
1424
1471
  });
1425
1472
  this.subagentStore.set(record);
1426
1473
  const approval = options.approval ?? record.profile.approval;
@@ -1529,7 +1576,9 @@ export class Agent {
1529
1576
  return this.snapshotSubagent(record);
1530
1577
  }
1531
1578
  listSubAgents() {
1532
- return this.subagentStore.values().map((record) => this.snapshotSubagent(record));
1579
+ return this.subagentStore.values()
1580
+ .filter((record) => !record.workflowInternal)
1581
+ .map((record) => this.snapshotSubagent(record));
1533
1582
  }
1534
1583
  /**
1535
1584
  * Homogeneous map fan-out (design §1.2): one profile, one template, N items.
@@ -1554,7 +1603,7 @@ export class Agent {
1554
1603
  }
1555
1604
  }
1556
1605
  const approval = options.approval ?? options.profile.approval;
1557
- const route = this.resolveRouteForSubagent(options.profile, options.category);
1606
+ const route = this.resolveRouteForSubagent(options.profile, options.category, { model: options.model, effort: options.effort });
1558
1607
  const records = options.items.map((item) => this.createSubagentThreadRecord({
1559
1608
  profile: options.profile,
1560
1609
  task: options.promptTemplate.split("{{item}}").join(item),
@@ -1587,6 +1636,333 @@ export class Agent {
1587
1636
  this.subagentStore.markDelivered(record.agentId);
1588
1637
  return records.map((record) => this.snapshotSubagent(record));
1589
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
+ /**
1719
+ * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
1720
+ * a QuickJS sandbox. Each agent() call in the script becomes a real scheduled
1721
+ * subagent (same route resolution, ChildRunner, scheduler, schema validation
1722
+ * as spawn_agent), so the script expresses deterministic control flow while
1723
+ * the runtime keeps owning concurrency/budget/retry.
1724
+ *
1725
+ * Foreground entry point (used by `-p`/headless and tests): awaits to
1726
+ * completion and returns the result. Background runs go through startWorkflow.
1727
+ */
1728
+ async runWorkflow(cwd, options) {
1729
+ return this.executeWorkflow(cwd, {
1730
+ script: options.script,
1731
+ args: options.args,
1732
+ parentToolCallId: options.parentToolCallId,
1733
+ abortSignal: options.abortSignal,
1734
+ directEmit: options.emitUpdate,
1735
+ });
1736
+ }
1737
+ /**
1738
+ * Starts a workflow in the BACKGROUND (option C Phase 4): returns a runId
1739
+ * immediately; the script runs detached, its agents stream progress through
1740
+ * the queued channel (drained at turn boundaries like spawn_agent), and its
1741
+ * result is ingested at the next turn. Collect explicitly with waitWorkflow.
1742
+ */
1743
+ startWorkflow(cwd, options) {
1744
+ const runId = randomUUID();
1745
+ const abortController = new AbortController();
1746
+ const composed = composeAbortSignals([options.abortSignal, abortController.signal]);
1747
+ if (composed) {
1748
+ composed.addEventListener("abort", () => abortController.abort(composed.reason), { once: true });
1749
+ }
1750
+ const record = {
1751
+ runId,
1752
+ title: options.title ?? "workflow",
1753
+ status: "running",
1754
+ agentCount: 0,
1755
+ snapshots: [],
1756
+ logs: [],
1757
+ abortController,
1758
+ waiters: new Set(),
1759
+ createdAt: Date.now(),
1760
+ parentToolCallId: options.parentToolCallId,
1761
+ };
1762
+ this.workflowRuns.set(runId, record);
1763
+ record.promise = this.executeWorkflow(cwd, {
1764
+ script: options.script,
1765
+ args: options.args,
1766
+ parentToolCallId: options.parentToolCallId,
1767
+ abortSignal: abortController.signal,
1768
+ queueUpdates: true,
1769
+ }).then((out) => {
1770
+ record.agentCount = out.agentCount;
1771
+ record.snapshots = out.snapshots;
1772
+ record.logs = out.logs;
1773
+ record.result = out.result;
1774
+ record.status = out.result.ok ? "completed" : (abortController.signal.aborted ? "cancelled" : "failed");
1775
+ }, (error) => {
1776
+ record.result = { ok: false, error: error?.message || String(error) };
1777
+ record.status = "failed";
1778
+ }).finally(() => {
1779
+ record.updatedAt = Date.now();
1780
+ this.pendingWorkflowDeliveries.add(runId);
1781
+ for (const waiter of record.waiters)
1782
+ waiter();
1783
+ record.waiters.clear();
1784
+ });
1785
+ return { runId, title: record.title };
1786
+ }
1787
+ /** Blocks until a background workflow reaches a final state (or times out). */
1788
+ async waitWorkflow(runId, timeoutMs) {
1789
+ const record = this.workflowRuns.get(runId);
1790
+ if (!record)
1791
+ return undefined;
1792
+ if (record.status === "running") {
1793
+ const limit = normalizeWaitTimeout(timeoutMs);
1794
+ let waiter;
1795
+ await Promise.race([
1796
+ new Promise((resolve) => { waiter = resolve; record.waiters.add(resolve); }),
1797
+ new Promise((resolve) => setTimeout(resolve, limit)),
1798
+ ]).finally(() => { if (waiter)
1799
+ record.waiters.delete(waiter); });
1800
+ }
1801
+ if (record.status !== "running")
1802
+ this.pendingWorkflowDeliveries.delete(runId);
1803
+ return this.snapshotWorkflow(record);
1804
+ }
1805
+ /** Cancels a running background workflow. */
1806
+ closeWorkflow(runId) {
1807
+ const record = this.workflowRuns.get(runId);
1808
+ if (!record)
1809
+ return undefined;
1810
+ if (record.status === "running")
1811
+ record.abortController.abort(new Error("workflow cancelled"));
1812
+ return this.snapshotWorkflow(record);
1813
+ }
1814
+ listWorkflows() {
1815
+ return [...this.workflowRuns.values()].map((record) => this.snapshotWorkflow(record));
1816
+ }
1817
+ snapshotWorkflow(record) {
1818
+ return {
1819
+ runId: record.runId,
1820
+ title: record.title,
1821
+ status: record.status,
1822
+ agentCount: record.agentCount,
1823
+ result: record.result,
1824
+ logs: record.logs,
1825
+ snapshots: record.snapshots,
1826
+ };
1827
+ }
1828
+ /** Injects completed background-workflow results before the next turn (§5 analog). */
1829
+ flushWorkflowDeliveries() {
1830
+ if (this.pendingWorkflowDeliveries.size === 0)
1831
+ return;
1832
+ for (const runId of [...this.pendingWorkflowDeliveries]) {
1833
+ this.pendingWorkflowDeliveries.delete(runId);
1834
+ const record = this.workflowRuns.get(runId);
1835
+ if (!record || record.status === "running" || record.deliveredAt)
1836
+ continue;
1837
+ record.deliveredAt = Date.now();
1838
+ this.injectSystemReminder(buildWorkflowDeliveryNotice(this.snapshotWorkflow(record)));
1839
+ }
1840
+ }
1841
+ async executeWorkflow(cwd, options) {
1842
+ const profiles = discoverAgentProfiles(cwd, "both").profiles;
1843
+ const runRecords = [];
1844
+ 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);
1853
+ const interactiveReserve = 2;
1854
+ const globalCap = Math.max(1, this.subagentsConfig.maxActiveSubagents ?? 8);
1855
+ const workflowConcurrency = Math.max(1, globalCap - interactiveReserve);
1856
+ const gate = new WorkflowConcurrencyGate(workflowConcurrency);
1857
+ const dispatchAgent = async (spec) => {
1858
+ if (runSpent() >= runTokenCeiling) {
1859
+ return { ok: false, error: "workflow token ceiling reached; not launching more agents" };
1860
+ }
1861
+ const baseProfile = findAgentProfile(profiles, spec.opts.agentType ?? "default")
1862
+ ?? findAgentProfile(profiles, "default");
1863
+ if (!baseProfile)
1864
+ return { ok: false, error: "no default subagent profile available" };
1865
+ // Workflow agents are readonly-by-default; mode upgrades come only from the
1866
+ // profile, never from the script (security invariant).
1867
+ const unsupported = baseProfile.mode !== "readonly" && baseProfile.mode !== "write_worktree";
1868
+ if (unsupported)
1869
+ return { ok: false, error: `profile "${baseProfile.name}" mode ${baseProfile.mode} not supported` };
1870
+ // Default-no-network: unattended orchestration of net-capable agents is new
1871
+ // authority in aggregate (option C review), so strip web tools unless the
1872
+ // script opts in with agentType pointing at a profile that includes them.
1873
+ const profile = {
1874
+ ...baseProfile,
1875
+ tools: { ...baseProfile.tools, exclude: [...(baseProfile.tools.exclude ?? []), "web_fetch", "web_search"] },
1876
+ };
1877
+ let route;
1878
+ try {
1879
+ route = this.resolveRouteForSubagent(profile, spec.opts.category, {
1880
+ model: spec.opts.model,
1881
+ effort: parseThinkingLevel(spec.opts.effort),
1882
+ });
1883
+ }
1884
+ catch (error) {
1885
+ return { ok: false, error: error?.message || String(error) };
1886
+ }
1887
+ const baseTask = spec.opts.schema !== undefined
1888
+ ? appendOutputSchemaInstructions(spec.prompt, spec.opts.schema)
1889
+ : spec.prompt;
1890
+ const record = this.createSubagentThreadRecord({
1891
+ profile,
1892
+ task: baseTask,
1893
+ parentToolCallId: options.parentToolCallId,
1894
+ parentToolName: "run_workflow",
1895
+ route,
1896
+ workflowInternal: true,
1897
+ });
1898
+ runRecords.push(record);
1899
+ this.subagentStore.set(record);
1900
+ const admissionError = this.admitSubagentProfile(record, profile.approval);
1901
+ if (admissionError) {
1902
+ this.finalizeSubagentBlocked(record, admissionError, { directEmit: options.directEmit, queueUpdates: options.queueUpdates });
1903
+ return { ok: false, error: admissionError };
1904
+ }
1905
+ // Leaf-only concurrency permit (option C review M5): held ONLY around this
1906
+ // agent's dispatch, never across parallel/pipeline composition.
1907
+ await gate.acquire();
1908
+ try {
1909
+ record.promise = this.dispatchSubagentRun(record, baseTask, cwd, {
1910
+ approval: profile.approval,
1911
+ abortSignal: options.abortSignal,
1912
+ directEmit: options.directEmit,
1913
+ queueUpdates: options.queueUpdates,
1914
+ });
1915
+ await record.promise;
1916
+ }
1917
+ finally {
1918
+ gate.release();
1919
+ }
1920
+ this.subagentStore.markDelivered(record.agentId);
1921
+ if (record.status !== "completed") {
1922
+ return { ok: false, error: record.error || `agent ${record.nickname} ended: ${record.finalReason ?? record.status}` };
1923
+ }
1924
+ if (spec.opts.schema === undefined) {
1925
+ return { ok: true, value: record.summary };
1926
+ }
1927
+ // Structured output: validate, one corrective retry, then fall back to raw.
1928
+ let validated = validateStructuredSummary(record.summary, spec.opts.schema);
1929
+ if (!validated.ok) {
1930
+ try {
1931
+ await this.sendSubAgentInput(record.agentId, buildSchemaCorrectionPrompt(spec.opts.schema, record.summary), cwd, { abortSignal: options.abortSignal });
1932
+ await record.promise?.catch(() => undefined);
1933
+ validated = validateStructuredSummary(record.summary, spec.opts.schema);
1934
+ }
1935
+ catch {
1936
+ // resume failed; fall through to raw summary
1937
+ }
1938
+ }
1939
+ return { ok: true, value: validated.ok ? validated.value : record.summary };
1940
+ };
1941
+ const result = await runWorkflow({
1942
+ script: options.script,
1943
+ args: options.args,
1944
+ dispatchAgent,
1945
+ onLog: (message) => logs.push(message),
1946
+ onPhase: (title) => logs.push(`— phase: ${title} —`),
1947
+ budget: {
1948
+ total: this.budgetLedger?.poolLimit ?? null,
1949
+ 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
+ },
1956
+ },
1957
+ signal: options.abortSignal,
1958
+ });
1959
+ return {
1960
+ result,
1961
+ agentCount: runRecords.length,
1962
+ logs,
1963
+ snapshots: runRecords.map((record) => this.snapshotSubagent(record)),
1964
+ };
1965
+ }
1590
1966
  /** Marks a child's full summary as delivered to parent context (design §3.3). */
1591
1967
  markSubagentDelivered(agentId) {
1592
1968
  this.subagentStore.markDelivered(agentId);
@@ -1672,6 +2048,19 @@ export class Agent {
1672
2048
  this.subagentStore.notifyWaiters(record);
1673
2049
  this.maybeEnqueueIngestion(record, options);
1674
2050
  },
2051
+ onTransportRetryExhausted: (attempts) => {
2052
+ record.status = "failed";
2053
+ // failed_transient stays resumable, so the parent can still send_input
2054
+ // to recover the child with its context intact.
2055
+ record.finalReason = "failed_transient";
2056
+ record.error = `Provider transport error persisted after ${attempts} attempts.`;
2057
+ record.updatedAt = Date.now();
2058
+ void this.runSubagentLifecycleHookFor(record, cwd, "SubagentStop", record.status, record.error);
2059
+ this.emitSubagentLifecycle(record, options, "failed", undefined, record.error);
2060
+ this.subagentStore.persist(record);
2061
+ this.subagentStore.notifyWaiters(record);
2062
+ this.maybeEnqueueIngestion(record, options);
2063
+ },
1675
2064
  });
1676
2065
  }
1677
2066
  emitSubagentLifecycle(record, options, status, event, message) {
@@ -1701,7 +2090,13 @@ export class Agent {
1701
2090
  // Subagent lifecycle hooks are observe-only; never fail the subagent.
1702
2091
  }
1703
2092
  }
1704
- resolveRouteForSubagent(profile, category) {
2093
+ /**
2094
+ * Resolves a child's model route. Priority, highest first (design v2 §1.1):
2095
+ * call-site override (model/effort) > profile.model > category > inherit parent.
2096
+ * The call-site override is what lets the model say "opus for this reviewer,
2097
+ * haiku for these twenty scouts" per spawn/batch member at request time.
2098
+ */
2099
+ resolveRouteForSubagent(profile, category, override) {
1705
2100
  const parentRoute = {
1706
2101
  providerId: this.providerId,
1707
2102
  model: this.apiModel,
@@ -1713,18 +2108,24 @@ export class Agent {
1713
2108
  if ("error" in resolved) {
1714
2109
  throw new Error(resolved.error);
1715
2110
  }
2111
+ let route = resolved.route;
1716
2112
  if (profile.model && profile.model !== "inherit") {
1717
2113
  const model = resolveModelRoute(profile.model, parentRoute.providerId);
1718
2114
  if (model.model !== "inherit") {
1719
- return {
1720
- ...resolved.route,
1721
- providerId: model.providerId,
1722
- model: model.model,
1723
- inherited: false,
1724
- };
2115
+ route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
2116
+ }
2117
+ }
2118
+ // Call-site override beats profile and category.
2119
+ if (override?.model) {
2120
+ const model = resolveModelRoute(override.model, route.providerId);
2121
+ if (model.model !== "inherit") {
2122
+ route = { ...route, providerId: model.providerId, model: model.model, inherited: false };
1725
2123
  }
1726
2124
  }
1727
- return resolved.route;
2125
+ if (override?.effort) {
2126
+ route = { ...route, thinkingLevel: override.effort, inherited: false };
2127
+ }
2128
+ return route;
1728
2129
  }
1729
2130
  createSubagentThreadRecord(options) {
1730
2131
  const now = Date.now();
@@ -1736,6 +2137,7 @@ export class Agent {
1736
2137
  profile: options.profile,
1737
2138
  category: options.route?.category,
1738
2139
  route: options.route,
2140
+ workflowInternal: options.workflowInternal,
1739
2141
  parentToolCallId: options.parentToolCallId,
1740
2142
  parentToolName: options.parentToolName,
1741
2143
  status: "queued",
@@ -1765,6 +2167,12 @@ export class Agent {
1765
2167
  childMode = "default";
1766
2168
  tools = createWorktreeChildTools(childCwd, record.profile.tools.include);
1767
2169
  }
2170
+ else {
2171
+ // Readonly children share the parent's tool instances; isolate the only
2172
+ // one with mutable file state (read → its FileStateTracker) so concurrent
2173
+ // fan-out members never race shared tool state (design v2 §2).
2174
+ tools = isolateReadonlyChildFileTools(tools);
2175
+ }
1768
2176
  const childToolNames = tools.map((tool) => tool.name);
1769
2177
  const route = record.route ?? {
1770
2178
  providerId: this.providerId,
@@ -1916,7 +2324,7 @@ export class Agent {
1916
2324
  }
1917
2325
  resolveSubagentTargets(agentIds) {
1918
2326
  if (!agentIds || agentIds.length === 0) {
1919
- return this.subagentStore.values().filter((record) => record.status !== "closed");
2327
+ return this.subagentStore.values().filter((record) => record.status !== "closed" && !record.workflowInternal);
1920
2328
  }
1921
2329
  return agentIds.map((id) => {
1922
2330
  const record = this.subagentStore.get(id);
@@ -7,10 +7,19 @@
7
7
  * fails.
8
8
  */
9
9
  import type { CompactOptions, CompactResult } from "./compact.js";
10
- import type { Message, Provider } from "../types.js";
10
+ import type { Message, Provider, ProviderMessage } from "../types.js";
11
11
  export interface LLMCompactOptions extends CompactOptions {
12
12
  provider: Provider;
13
13
  model: string;
14
14
  thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
15
15
  }
16
+ export declare const COMPACT_SYSTEM_PROMPT = "You are a conversation summarizer. Your job is to produce a structured\nsummary of an earlier portion of a software-engineering assistant's\nconversation so that the assistant can continue working without the full\nhistory. Preserve fidelity over brevity where the user's intent, file\npaths, or decisions are concerned. Output ONLY the summary, no preamble.";
17
+ export declare const COMPACT_INSTRUCTIONS = "Summarize the conversation above using exactly these 9 sections, each\npreceded by the literal heading on its own line. If a section has no\ncontent, write \"None\".\n\n1. Primary Request and Intent\n - What the user ultimately wants, in their own framing.\n\n2. Key Technical Concepts\n - Libraries, frameworks, architectural patterns referenced.\n\n3. Files and Code Sections\n - Files read, written, or discussed. Include full paths and a one-line note.\n\n4. Errors and Fixes\n - Bugs encountered and how they were resolved.\n\n5. Problem Solving\n - Non-trivial debugging or design decisions.\n\n6. All User Messages\n - Every user message, verbatim, in order. Do not summarize here.\n\n7. Pending Tasks\n - Work that was planned but not yet completed.\n\n8. Current Work\n - What was being actively worked on when the summary was taken.\n\n9. Optional Next Step\n - The single most natural next action, if obvious.";
16
18
  export declare function compactMessagesWithLLM(messages: Message[], options: LLMCompactOptions): Promise<CompactResult>;
19
+ /**
20
+ * Build the two-message prompt that asks the model for a 9-section summary of
21
+ * `oldMessages`. Shared by the non-streaming overflow path (`generateSummary`)
22
+ * and the streaming manual `/compact` path (`Agent.summarizeForCompaction`).
23
+ */
24
+ export declare function buildCompactionPromptMessages(oldMessages: Message[]): ProviderMessage[];
25
+ export declare function serializeTranscript(messages: Message[]): string;
@@ -7,12 +7,12 @@
7
7
  * fails.
8
8
  */
9
9
  import { compactMessages as compactMessagesHeuristic } from "./compact.js";
10
- const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your job is to produce a structured
10
+ export const COMPACT_SYSTEM_PROMPT = `You are a conversation summarizer. Your job is to produce a structured
11
11
  summary of an earlier portion of a software-engineering assistant's
12
12
  conversation so that the assistant can continue working without the full
13
13
  history. Preserve fidelity over brevity where the user's intent, file
14
14
  paths, or decisions are concerned. Output ONLY the summary, no preamble.`;
15
- const COMPACT_INSTRUCTIONS = `Summarize the conversation above using exactly these 9 sections, each
15
+ export const COMPACT_INSTRUCTIONS = `Summarize the conversation above using exactly these 9 sections, each
16
16
  preceded by the literal heading on its own line. If a section has no
17
17
  content, write "None".
18
18
 
@@ -79,22 +79,30 @@ export async function compactMessagesWithLLM(messages, options) {
79
79
  droppedEntries: oldMessages.length,
80
80
  };
81
81
  }
82
- async function generateSummary(oldMessages, options) {
82
+ /**
83
+ * Build the two-message prompt that asks the model for a 9-section summary of
84
+ * `oldMessages`. Shared by the non-streaming overflow path (`generateSummary`)
85
+ * and the streaming manual `/compact` path (`Agent.summarizeForCompaction`).
86
+ */
87
+ export function buildCompactionPromptMessages(oldMessages) {
83
88
  const transcript = serializeTranscript(oldMessages);
84
- const messages = [
89
+ return [
85
90
  { role: "system", content: COMPACT_SYSTEM_PROMPT },
86
91
  {
87
92
  role: "user",
88
93
  content: `Conversation to summarize:\n\n${transcript}\n\n---\n\n${COMPACT_INSTRUCTIONS}`,
89
94
  },
90
95
  ];
96
+ }
97
+ async function generateSummary(oldMessages, options) {
98
+ const messages = buildCompactionPromptMessages(oldMessages);
91
99
  return options.provider.complete(messages, {
92
100
  model: options.model,
93
101
  temperature: 0.2,
94
102
  thinkingLevel: options.thinkingLevel ?? "off",
95
103
  });
96
104
  }
97
- function serializeTranscript(messages) {
105
+ export function serializeTranscript(messages) {
98
106
  const lines = [];
99
107
  for (const message of messages) {
100
108
  switch (message.role) {
@@ -11,6 +11,36 @@ export interface CompactResult {
11
11
  messages?: Message[];
12
12
  droppedEntries?: number;
13
13
  }
14
+ /**
15
+ * The split of a session log into (metadata, old-to-summarize, kept-verbatim)
16
+ * when it is large enough to compact. `compactable: false` means there aren't
17
+ * enough turns past the last summary to bother.
18
+ *
19
+ * Extracted so callers that supply their OWN summary (e.g. the LLM-backed
20
+ * manual `/compact`) can reuse the exact same turn-boundary logic instead of
21
+ * forking it. `compactSessionEntries` is just this plan + a heuristic summary.
22
+ */
23
+ export type SessionCompactionPlan = {
24
+ compactable: false;
25
+ } | {
26
+ compactable: true;
27
+ metadataEntries: SessionLogEntry[];
28
+ oldEntries: SessionLogEntry[];
29
+ keptEntries: SessionLogEntry[];
30
+ };
31
+ export declare function planSessionCompaction(entries: SessionLogEntry[], options?: CompactOptions): SessionCompactionPlan;
32
+ /**
33
+ * Assemble the post-compaction entry list from a plan and a (possibly
34
+ * LLM-generated) summary string. The summary entry is keyed off the full
35
+ * original `entries` so its id never collides with a prior summary.
36
+ */
37
+ export declare function buildCompactedEntries(entries: SessionLogEntry[], plan: Extract<SessionCompactionPlan, {
38
+ compactable: true;
39
+ }>, summary: string): SessionLogEntry[];
40
+ /** Flatten a plan's old entries into messages for an external summarizer. */
41
+ export declare function planOldMessages(plan: Extract<SessionCompactionPlan, {
42
+ compactable: true;
43
+ }>): Message[];
14
44
  export declare function compactSessionEntries(entries: SessionLogEntry[], options?: CompactOptions): CompactResult;
15
45
  export declare function compactMessages(messages: Message[], options?: CompactOptions): CompactResult;
16
46
  /**