@bubblebrain-ai/bubble 0.0.21 → 0.0.22

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 (58) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +63 -5
  23. package/dist/agent.js +360 -287
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/config.d.ts +8 -0
  28. package/dist/config.js +17 -0
  29. package/dist/feishu/agent-host/approval-card.js +9 -0
  30. package/dist/feishu/agent-host/run-driver.js +1 -0
  31. package/dist/main.js +34 -0
  32. package/dist/network/errors.d.ts +28 -0
  33. package/dist/network/errors.js +24 -0
  34. package/dist/orchestrator/default-hooks.js +5 -1
  35. package/dist/prompt/compose.js +3 -0
  36. package/dist/prompt/delegation.d.ts +14 -0
  37. package/dist/prompt/delegation.js +64 -0
  38. package/dist/prompt/task-reminders.d.ts +5 -1
  39. package/dist/prompt/task-reminders.js +10 -2
  40. package/dist/provider-anthropic.js +23 -0
  41. package/dist/provider.js +23 -3
  42. package/dist/slash-commands/commands.js +29 -2
  43. package/dist/slash-commands/types.d.ts +2 -0
  44. package/dist/tools/agent-lifecycle.d.ts +29 -3
  45. package/dist/tools/agent-lifecycle.js +394 -40
  46. package/dist/tools/child-tools.d.ts +31 -0
  47. package/dist/tools/child-tools.js +106 -0
  48. package/dist/tools/index.js +1 -1
  49. package/dist/tui/run.d.ts +11 -1
  50. package/dist/tui/run.js +92 -4
  51. package/dist/tui/session-picker-data.d.ts +18 -0
  52. package/dist/tui/session-picker-data.js +21 -0
  53. package/dist/tui/wordmark.d.ts +2 -0
  54. package/dist/tui/wordmark.js +31 -4
  55. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  56. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  57. package/dist/types.d.ts +17 -0
  58. package/package.json +1 -1
@@ -0,0 +1,14 @@
1
+ export declare class AgentAbortError extends Error {
2
+ constructor(message?: string);
3
+ }
4
+ /**
5
+ * Abort tagged with why the runtime stopped a child, so finalization can map
6
+ * it to a SubagentFinalReason (design doc §3.1) instead of guessing from
7
+ * message strings.
8
+ */
9
+ export declare class SubagentAbortError extends AgentAbortError {
10
+ readonly subagentReason: "interrupt" | "user_close" | "budget";
11
+ constructor(message: string, subagentReason: "interrupt" | "user_close" | "budget");
12
+ }
13
+ /** Shown when the model produced no user-visible content despite recovery attempts. */
14
+ export declare const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
@@ -0,0 +1,21 @@
1
+ export class AgentAbortError extends Error {
2
+ constructor(message = "Agent run cancelled.") {
3
+ super(message);
4
+ this.name = "AgentAbortError";
5
+ }
6
+ }
7
+ /**
8
+ * Abort tagged with why the runtime stopped a child, so finalization can map
9
+ * it to a SubagentFinalReason (design doc §3.1) instead of guessing from
10
+ * message strings.
11
+ */
12
+ export class SubagentAbortError extends AgentAbortError {
13
+ subagentReason;
14
+ constructor(message, subagentReason) {
15
+ super(message);
16
+ this.subagentReason = subagentReason;
17
+ this.name = "SubagentAbortError";
18
+ }
19
+ }
20
+ /** Shown when the model produced no user-visible content despite recovery attempts. */
21
+ export const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
@@ -1,4 +1,5 @@
1
1
  import type { TokenUsage } from "../types.js";
2
+ import type { SubagentTokenCap } from "./subagent-control.js";
2
3
  export interface BudgetUsageSource {
3
4
  runId: string;
4
5
  subAgentId?: string;
@@ -8,13 +9,53 @@ export interface BudgetSnapshot {
8
9
  limit?: number;
9
10
  exhausted: boolean;
10
11
  }
12
+ /**
13
+ * Shared token ledger for a parent and all of its children, with per-source
14
+ * accounting so the runtime can enforce per-child caps (design doc §6).
15
+ * The shared pool limit is optional — both production hosts construct the
16
+ * ledger without one — so per-child caps must never be derived solely from
17
+ * "pool remaining"; see computeChildTokenCap.
18
+ */
11
19
  export declare class BudgetLedger {
12
20
  private readonly limit?;
13
21
  private spent;
22
+ private readonly spentBySource;
14
23
  private readonly controller;
15
24
  constructor(limit?: number | undefined);
16
25
  get signal(): AbortSignal;
17
26
  recordUsage(usage: TokenUsage, source: BudgetUsageSource): void;
27
+ /** Tokens attributed to one child (or the parent when subAgentId is omitted). */
28
+ spentBy(subAgentId?: string): number;
29
+ /** Pool tokens remaining, or undefined when the pool has no limit. */
30
+ remaining(): number | undefined;
31
+ get poolLimit(): number | undefined;
18
32
  snapshot(): BudgetSnapshot;
19
33
  }
34
+ /** Default absolute per-child soft cap; applies even on limit-free hosts. */
35
+ export declare const DEFAULT_CHILD_TOKEN_CAP = 200000;
36
+ /** Share of a limited pool reserved for the parent's own turns. */
37
+ export declare const PARENT_POOL_RESERVE_RATIO = 0.2;
38
+ /** Hard cap sits at least this many tokens above the soft cap (≈ 2 turns). */
39
+ export declare const CHILD_HARD_CAP_FLOOR = 20000;
40
+ /**
41
+ * Per-child token cap, fixed at dispatch (design doc §6). The soft cap is an
42
+ * absolute number (config default 200k) so it is effective on limit-free
43
+ * hosts; when the pool *is* limited, the fair share of what remains after the
44
+ * parent's reserve further bounds it. The cap never shrinks mid-run because
45
+ * siblings spawned later.
46
+ */
47
+ export declare function computeChildTokenCap(options: {
48
+ ledger?: BudgetLedger;
49
+ subAgentId: string;
50
+ activeChildren: number;
51
+ configCap?: number;
52
+ profileMaxTokens?: number;
53
+ }): SubagentTokenCap;
54
+ /**
55
+ * Hard cap recomputed at each turn-boundary check: at least ~2 of this
56
+ * child's average turns above the soft cap, never below the absolute floor
57
+ * (design doc §6 — replaces the fixed 25% ratio that could be smaller than a
58
+ * single turn).
59
+ */
60
+ export declare function childHardCap(soft: number, avgTurnTokens: number): number;
20
61
  export declare function composeAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined;
@@ -1,6 +1,15 @@
1
+ const PARENT_SOURCE_KEY = "__parent__";
2
+ /**
3
+ * Shared token ledger for a parent and all of its children, with per-source
4
+ * accounting so the runtime can enforce per-child caps (design doc §6).
5
+ * The shared pool limit is optional — both production hosts construct the
6
+ * ledger without one — so per-child caps must never be derived solely from
7
+ * "pool remaining"; see computeChildTokenCap.
8
+ */
1
9
  export class BudgetLedger {
2
10
  limit;
3
11
  spent = 0;
12
+ spentBySource = new Map();
4
13
  controller = new AbortController();
5
14
  constructor(limit) {
6
15
  this.limit = limit;
@@ -11,10 +20,25 @@ export class BudgetLedger {
11
20
  recordUsage(usage, source) {
12
21
  const delta = usage.promptTokens + usage.completionTokens;
13
22
  this.spent += delta;
23
+ const key = source.subAgentId ?? PARENT_SOURCE_KEY;
24
+ this.spentBySource.set(key, (this.spentBySource.get(key) ?? 0) + delta);
14
25
  if (this.limit !== undefined && this.spent >= this.limit && !this.controller.signal.aborted) {
15
26
  this.controller.abort(budgetAbortError("Budget exhausted"));
16
27
  }
17
28
  }
29
+ /** Tokens attributed to one child (or the parent when subAgentId is omitted). */
30
+ spentBy(subAgentId) {
31
+ return this.spentBySource.get(subAgentId ?? PARENT_SOURCE_KEY) ?? 0;
32
+ }
33
+ /** Pool tokens remaining, or undefined when the pool has no limit. */
34
+ remaining() {
35
+ if (this.limit === undefined)
36
+ return undefined;
37
+ return Math.max(0, this.limit - this.spent);
38
+ }
39
+ get poolLimit() {
40
+ return this.limit;
41
+ }
18
42
  snapshot() {
19
43
  return {
20
44
  spent: this.spent,
@@ -23,6 +47,46 @@ export class BudgetLedger {
23
47
  };
24
48
  }
25
49
  }
50
+ /** Default absolute per-child soft cap; applies even on limit-free hosts. */
51
+ export const DEFAULT_CHILD_TOKEN_CAP = 200_000;
52
+ /** Share of a limited pool reserved for the parent's own turns. */
53
+ export const PARENT_POOL_RESERVE_RATIO = 0.2;
54
+ /** Hard cap sits at least this many tokens above the soft cap (≈ 2 turns). */
55
+ export const CHILD_HARD_CAP_FLOOR = 20_000;
56
+ /**
57
+ * Per-child token cap, fixed at dispatch (design doc §6). The soft cap is an
58
+ * absolute number (config default 200k) so it is effective on limit-free
59
+ * hosts; when the pool *is* limited, the fair share of what remains after the
60
+ * parent's reserve further bounds it. The cap never shrinks mid-run because
61
+ * siblings spawned later.
62
+ */
63
+ export function computeChildTokenCap(options) {
64
+ let soft = options.configCap ?? DEFAULT_CHILD_TOKEN_CAP;
65
+ if (options.profileMaxTokens !== undefined && options.profileMaxTokens > 0) {
66
+ soft = Math.min(soft, options.profileMaxTokens);
67
+ }
68
+ const limit = options.ledger?.poolLimit;
69
+ if (options.ledger && limit !== undefined) {
70
+ const reserve = Math.floor(limit * PARENT_POOL_RESERVE_RATIO);
71
+ const available = Math.max(0, (options.ledger.remaining() ?? 0) - reserve);
72
+ const share = Math.floor(available / (options.activeChildren + 1));
73
+ soft = Math.max(1, Math.min(soft, share));
74
+ }
75
+ return {
76
+ soft,
77
+ hard: soft + CHILD_HARD_CAP_FLOOR,
78
+ baseline: options.ledger?.spentBy(options.subAgentId) ?? 0,
79
+ };
80
+ }
81
+ /**
82
+ * Hard cap recomputed at each turn-boundary check: at least ~2 of this
83
+ * child's average turns above the soft cap, never below the absolute floor
84
+ * (design doc §6 — replaces the fixed 25% ratio that could be smaller than a
85
+ * single turn).
86
+ */
87
+ export function childHardCap(soft, avgTurnTokens) {
88
+ return soft + Math.max(CHILD_HARD_CAP_FLOOR, Math.ceil(avgTurnTokens * 2));
89
+ }
26
90
  function budgetAbortError(message) {
27
91
  const error = new Error(message);
28
92
  error.name = "AbortError";
@@ -0,0 +1,55 @@
1
+ /**
2
+ * ChildRunner — executes one logical run of a subagent thread and reports the
3
+ * outcome to the scheduler (design doc §2, extracted in Phase 3).
4
+ *
5
+ * A logical run spans dispatch → final state; a rate-limit re-entry is the
6
+ * same logical run (no second SubagentStart), while a send_input restart is a
7
+ * new one. The runner owns: tool validation defense, instance reuse,
8
+ * turn-boundary budget enforcement, the handoff completeness guard, and the
9
+ * mapping of failures to SubagentFinalReason.
10
+ */
11
+ import { BudgetLedger } from "./budget-ledger.js";
12
+ import type { SubagentRunOutcome } from "./subagent-scheduler.js";
13
+ import type { SubagentFinalReason, SubagentThreadRecord } from "./subagent-control.js";
14
+ import type { AgentEvent, Message, ToolRegistryEntry, ToolUpdate } from "../types.js";
15
+ export interface ChildRunOptions {
16
+ approval: "fail" | "disabled";
17
+ abortSignal?: AbortSignal;
18
+ forkContext?: boolean;
19
+ directEmit?: (update: ToolUpdate) => void;
20
+ queueUpdates?: boolean;
21
+ reuseAgent?: boolean;
22
+ /** 1-based scheduler attempt; >1 means rate-limit re-entry of the same logical run. */
23
+ attempt?: number;
24
+ }
25
+ export interface ChildRunnerHost {
26
+ allTools(): ToolRegistryEntry[];
27
+ budgetLedger(): BudgetLedger | undefined;
28
+ emit(record: SubagentThreadRecord, options: ChildRunOptions, status: ToolUpdate["status"], event?: AgentEvent, message?: string): void;
29
+ runLifecycleHook(record: SubagentThreadRecord, cwd: string, eventName: "SubagentStart" | "SubagentStop", status?: string, error?: string, abortSignal?: AbortSignal): Promise<void>;
30
+ finalizeBlocked(record: SubagentThreadRecord, error: string, options: ChildRunOptions): void;
31
+ createInstance(record: SubagentThreadRecord, tools: ToolRegistryEntry[], cwd: string, forkContext?: boolean): Promise<NonNullable<SubagentThreadRecord["agent"]>>;
32
+ notifyWaiters(record: SubagentThreadRecord): void;
33
+ /** Called on every final state so background results can be ingested (§5). */
34
+ onFinal(record: SubagentThreadRecord, options: ChildRunOptions): void;
35
+ }
36
+ export declare class ChildRunner {
37
+ private readonly host;
38
+ constructor(host: ChildRunnerHost);
39
+ run(record: SubagentThreadRecord, input: string | import("../types.js").ContentPart[], cwd: string, options: ChildRunOptions): Promise<SubagentRunOutcome>;
40
+ private runFinalSummaryTurn;
41
+ }
42
+ export declare function sanitizeSubagentSummary(value: string): string;
43
+ /**
44
+ * Handoff completeness guard (design §3.2): a deterministic CJK-aware token
45
+ * floor and a cheap intermediate-narration prefix check run in parallel.
46
+ * Both only apply after the child actually used tools — a short direct answer
47
+ * to a trivial question is a complete handoff.
48
+ */
49
+ export declare function needsExplicitFinalSummary(record: SubagentThreadRecord, executedAnyTool: boolean): boolean;
50
+ export declare function classifySubagentAbortReason(reason: unknown, parentSignal: AbortSignal | undefined, ledger: BudgetLedger | undefined): SubagentFinalReason;
51
+ /**
52
+ * Drops trailing "[model request interrupted ...]" boundary messages so a
53
+ * rate-limit re-entry resumes from clean history (design §4.5).
54
+ */
55
+ export declare function stripTrailingModelInterruptedBoundary(messages: Message[]): void;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * ChildRunner — executes one logical run of a subagent thread and reports the
3
+ * outcome to the scheduler (design doc §2, extracted in Phase 3).
4
+ *
5
+ * A logical run spans dispatch → final state; a rate-limit re-entry is the
6
+ * same logical run (no second SubagentStart), while a send_input restart is a
7
+ * new one. The runner owns: tool validation defense, instance reuse,
8
+ * turn-boundary budget enforcement, the handoff completeness guard, and the
9
+ * mapping of failures to SubagentFinalReason.
10
+ */
11
+ import { AgentAbortError, EMPTY_ASSISTANT_FALLBACK, SubagentAbortError } from "./abort-errors.js";
12
+ import { childHardCap, composeAbortSignals } from "./budget-ledger.js";
13
+ import { isOnlyProviderProtocolArtifacts, stripProviderProtocolArtifacts } from "../provider-artifacts.js";
14
+ import { isRateLimitError } from "../network/errors.js";
15
+ import { mergeUsage, selectToolsForAgentProfile, validateAgentProfileTools } from "./profiles.js";
16
+ import { estimateHandoffTokens, HANDOFF_TOKEN_FLOOR, isIntermediateHandoff, stripInternalTagFragments, } from "./subagent-summary.js";
17
+ export class ChildRunner {
18
+ host;
19
+ constructor(host) {
20
+ this.host = host;
21
+ }
22
+ async run(record, input, cwd, options) {
23
+ const attempt = options.attempt ?? 1;
24
+ const emit = (status, event, message) => this.host.emit(record, options, status, event, message);
25
+ const allTools = this.host.allTools();
26
+ const diagnostics = validateAgentProfileTools(allTools, record.profile, options.approval);
27
+ const blockingDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
28
+ if (attempt === 1) {
29
+ for (const diagnostic of diagnostics.filter((item) => item.severity === "warning")) {
30
+ record.toolNotes.push(`profile: ${diagnostic.message}`);
31
+ }
32
+ }
33
+ if (blockingDiagnostics.length > 0) {
34
+ this.host.finalizeBlocked(record, blockingDiagnostics.map((diagnostic) => diagnostic.message).join("\n"), options);
35
+ this.host.onFinal(record, options);
36
+ return { kind: "final" };
37
+ }
38
+ const tools = selectToolsForAgentProfile(allTools, record.profile, options.approval);
39
+ const reuseExistingAgent = (options.reuseAgent || attempt > 1) && !!record.agent;
40
+ let subAgent;
41
+ try {
42
+ subAgent = reuseExistingAgent
43
+ ? record.agent
44
+ : await this.host.createInstance(record, tools, cwd, options.forkContext);
45
+ }
46
+ catch (error) {
47
+ // Instance creation failed before the run started: no SubagentStart
48
+ // fired, so no SubagentStop follows (§9 — hooks pair per started run).
49
+ this.host.finalizeBlocked(record, error?.message || String(error), options);
50
+ record.finalReason = "failed_fatal";
51
+ this.host.onFinal(record, options);
52
+ return { kind: "final" };
53
+ }
54
+ record.agent = subAgent;
55
+ // Write children run inside their isolated worktree (design §8).
56
+ const runCwd = record.worktree?.path ?? cwd;
57
+ record.status = "running";
58
+ record.updatedAt = Date.now();
59
+ // SubagentStart fires exactly once per logical run (design §9): a
60
+ // rate-limit re-entry is the same logical run and must not re-fire it.
61
+ if (attempt === 1) {
62
+ await this.host.runLifecycleHook(record, cwd, "SubagentStart", record.status, undefined, options.abortSignal);
63
+ }
64
+ emit("running", undefined, attempt > 1
65
+ ? `Retrying ${record.nickname} (${record.profile.name}) after a rate limit, attempt ${attempt}...`
66
+ : `Running ${record.nickname} (${record.profile.name})...`);
67
+ let turnSummaryBuffer = "";
68
+ let turnHadToolCall = false;
69
+ let executedAnyTool = false;
70
+ // Per-child budget enforcement happens at turn boundaries (design §6):
71
+ // turn_end already carries usage, and a reminder injected here is seen by
72
+ // the very next provider call — unlike chunk-level aborts.
73
+ const cap = record.tokenCap;
74
+ let runTokens = 0;
75
+ let runTurns = 0;
76
+ let budgetSoftWarned = false;
77
+ // Re-entry after a rate limit: the input was applied on attempt 1, so the
78
+ // child history must not gain a second copy, and any stale interruption
79
+ // boundary from the failed call is stripped (design §4.5).
80
+ const resumeWithoutInput = attempt > 1;
81
+ if (resumeWithoutInput) {
82
+ stripTrailingModelInterruptedBoundary(subAgent.messages);
83
+ }
84
+ try {
85
+ const childAbortSignal = composeAbortSignals([
86
+ options.abortSignal,
87
+ record.abortController.signal,
88
+ ]);
89
+ for await (const event of subAgent.run(input, runCwd, { abortSignal: childAbortSignal, resumeWithoutInput })) {
90
+ if (event.type === "text_delta") {
91
+ turnSummaryBuffer += event.content;
92
+ }
93
+ if (event.type === "tool_call_start"
94
+ || event.type === "tool_call_delta"
95
+ || event.type === "tool_call_end"
96
+ || event.type === "tool_start") {
97
+ turnHadToolCall = true;
98
+ }
99
+ if (event.type === "tool_end") {
100
+ executedAnyTool = true;
101
+ record.toolNotes.push(`${event.name}: ${summarizeSubagentToolEnd(event)}`);
102
+ }
103
+ if (event.type === "turn_end" && event.usage) {
104
+ record.usage = mergeUsage(record.usage, event.usage);
105
+ runTokens += event.usage.promptTokens + event.usage.completionTokens;
106
+ runTurns += 1;
107
+ if (cap) {
108
+ if (!budgetSoftWarned && runTokens >= cap.soft) {
109
+ budgetSoftWarned = true;
110
+ // The hard cap is fixed when the warning fires: soft + ~2 of
111
+ // this child's average turns (absolute floor), so the child
112
+ // gets a real chance to wrap up before the kill (design §6).
113
+ cap.hard = childHardCap(cap.soft, runTokens / Math.max(1, runTurns));
114
+ subAgent.injectSystemReminder(buildChildBudgetWrapUpReminder(runTokens, cap.soft));
115
+ }
116
+ else if (budgetSoftWarned && runTokens >= cap.hard) {
117
+ record.abortController.abort(new SubagentAbortError(`Subagent ${record.agentId} exceeded its hard token cap (${cap.hard}).`, "budget"));
118
+ }
119
+ }
120
+ }
121
+ if (event.type === "turn_end") {
122
+ const turnSummary = stripProviderProtocolArtifacts(turnSummaryBuffer).trim();
123
+ if (!turnHadToolCall && turnSummary) {
124
+ // Only the latest tool-free assistant turn is a candidate for the summary;
125
+ // earlier ones are intermediate "I'll do X next" reasoning, not the final answer.
126
+ record.summary = turnSummary;
127
+ }
128
+ turnSummaryBuffer = "";
129
+ turnHadToolCall = false;
130
+ }
131
+ record.updatedAt = Date.now();
132
+ emit("running", event);
133
+ }
134
+ }
135
+ catch (error) {
136
+ if (isRateLimitError(error)
137
+ && !record.abortController.signal.aborted
138
+ && !options.abortSignal?.aborted) {
139
+ // Not a failure: keep the agent instance and its context, hand the
140
+ // backoff decision to the scheduler — the single 429 backoff layer.
141
+ record.status = "queued";
142
+ record.summary = sanitizeSubagentSummary(record.summary);
143
+ record.updatedAt = Date.now();
144
+ stripTrailingModelInterruptedBoundary(subAgent.messages);
145
+ emit("queued", undefined, `Rate limited; ${record.nickname} will retry with its context intact.`);
146
+ return { kind: "rate_limited", retryAfterMs: error.retryAfterMs };
147
+ }
148
+ const cancelled = error instanceof AgentAbortError || error?.name === "AbortError";
149
+ record.status = cancelled ? "cancelled" : "failed";
150
+ record.finalReason = cancelled
151
+ ? classifySubagentAbortReason(record.abortController.signal.aborted ? record.abortController.signal.reason : error, options.abortSignal, this.host.budgetLedger())
152
+ : "failed_transient";
153
+ record.summary = sanitizeSubagentSummary(record.summary);
154
+ record.error = error?.message || String(error);
155
+ record.updatedAt = Date.now();
156
+ await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, record.error, options.abortSignal);
157
+ emit(record.status, undefined, record.error);
158
+ this.host.notifyWaiters(record);
159
+ this.host.onFinal(record, options);
160
+ return { kind: "final" };
161
+ }
162
+ record.summary = sanitizeSubagentSummary(record.summary);
163
+ if (needsExplicitFinalSummary(record, executedAnyTool)) {
164
+ await this.runFinalSummaryTurn(record, subAgent, runCwd, options.abortSignal, emit);
165
+ }
166
+ record.status = "completed";
167
+ record.finalReason = "completed";
168
+ record.summary = sanitizeSubagentSummary(record.summary);
169
+ record.updatedAt = Date.now();
170
+ await this.host.runLifecycleHook(record, cwd, "SubagentStop", record.status, undefined, options.abortSignal);
171
+ emit("completed", undefined, record.summary || `${record.nickname} completed`);
172
+ this.host.notifyWaiters(record);
173
+ this.host.onFinal(record, options);
174
+ return { kind: "final" };
175
+ }
176
+ async runFinalSummaryTurn(record, subAgent, cwd, abortSignal, emit) {
177
+ const prompt = [
178
+ "Produce the final subagent handoff now: what you found, your conclusions, and any unfinished items.",
179
+ "Do not call tools. Do not announce next steps or plans.",
180
+ "Use the evidence already gathered in this child thread.",
181
+ "Return concise findings with concrete file paths and explicit uncertainty.",
182
+ "If your previous message already was the complete handoff, restate it as-is — do not pad it.",
183
+ "Your entire response will be returned to the parent as the subagent's answer.",
184
+ ].join("\n");
185
+ subAgent.injectSystemReminder([
186
+ "Subagent final-summary mode is active.",
187
+ "Do not call tools. Do not announce next steps.",
188
+ "Use only the evidence already gathered in this child thread.",
189
+ "Return the final concise summary as your complete response.",
190
+ ].join("\n"));
191
+ let finalBuffer = "";
192
+ let finalHadToolCall = false;
193
+ const finalAbortSignal = composeAbortSignals([abortSignal, record.abortController.signal]);
194
+ for await (const event of subAgent.run(prompt, cwd, { abortSignal: finalAbortSignal })) {
195
+ if (event.type === "text_delta") {
196
+ finalBuffer += event.content;
197
+ }
198
+ if (event.type === "tool_call_start"
199
+ || event.type === "tool_call_delta"
200
+ || event.type === "tool_call_end"
201
+ || event.type === "tool_start") {
202
+ finalHadToolCall = true;
203
+ }
204
+ if (event.type === "turn_end" && event.usage) {
205
+ record.usage = mergeUsage(record.usage, event.usage);
206
+ }
207
+ emit("running", event);
208
+ }
209
+ const finalSummary = sanitizeSubagentSummary(finalBuffer);
210
+ // The follow-up may only improve the handoff: an empty or fallback
211
+ // response must never replace a real (if short) summary.
212
+ if (!finalHadToolCall && finalSummary && finalSummary !== EMPTY_ASSISTANT_FALLBACK) {
213
+ record.summary = finalSummary;
214
+ }
215
+ }
216
+ }
217
+ export function sanitizeSubagentSummary(value) {
218
+ return stripInternalTagFragments(stripProviderProtocolArtifacts(value)).trim();
219
+ }
220
+ /**
221
+ * Handoff completeness guard (design §3.2): a deterministic CJK-aware token
222
+ * floor and a cheap intermediate-narration prefix check run in parallel.
223
+ * Both only apply after the child actually used tools — a short direct answer
224
+ * to a trivial question is a complete handoff.
225
+ */
226
+ export function needsExplicitFinalSummary(record, executedAnyTool) {
227
+ if (!record.summary)
228
+ return executedAnyTool;
229
+ if (isOnlyProviderProtocolArtifacts(record.summary))
230
+ return true;
231
+ if (/<\/?[||][^<>]*>/.test(record.summary))
232
+ return true;
233
+ if (!executedAnyTool)
234
+ return false;
235
+ if (record.summary === EMPTY_ASSISTANT_FALLBACK)
236
+ return true;
237
+ if (estimateHandoffTokens(record.summary) < HANDOFF_TOKEN_FLOOR)
238
+ return true;
239
+ return isIntermediateHandoff(record.summary);
240
+ }
241
+ export function classifySubagentAbortReason(reason, parentSignal, ledger) {
242
+ if (reason instanceof SubagentAbortError) {
243
+ switch (reason.subagentReason) {
244
+ case "interrupt":
245
+ return "cancelled_interrupt";
246
+ case "user_close":
247
+ return "cancelled_user";
248
+ case "budget":
249
+ return "cancelled_budget";
250
+ }
251
+ }
252
+ if (ledger?.snapshot().exhausted)
253
+ return "cancelled_budget";
254
+ if (parentSignal?.aborted)
255
+ return "cancelled_parent_abort";
256
+ return "cancelled_user";
257
+ }
258
+ /**
259
+ * Drops trailing "[model request interrupted ...]" boundary messages so a
260
+ * rate-limit re-entry resumes from clean history (design §4.5).
261
+ */
262
+ export function stripTrailingModelInterruptedBoundary(messages) {
263
+ while (messages.length > 0) {
264
+ const last = messages[messages.length - 1];
265
+ if (last.role === "assistant" && last.content.startsWith("[model request interrupted")) {
266
+ messages.pop();
267
+ continue;
268
+ }
269
+ break;
270
+ }
271
+ }
272
+ function buildChildBudgetWrapUpReminder(spentTokens, softCap) {
273
+ return [
274
+ `Token budget notice: this subagent has used ~${Math.round(spentTokens)} tokens, crossing its ${softCap}-token budget.`,
275
+ "Wrap up now: stop opening new lines of investigation and produce your complete final handoff",
276
+ "(findings, conclusions, unfinished items) in your next message.",
277
+ ].join(" ");
278
+ }
279
+ function summarizeSubagentToolEnd(event) {
280
+ const metadata = (event.result.metadata ?? {});
281
+ const reason = readString(metadata.reason);
282
+ if (reason)
283
+ return reason;
284
+ const summary = readString(metadata.summary);
285
+ if (summary)
286
+ return summary;
287
+ if (event.result.isError) {
288
+ const firstLine = event.result.content.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
289
+ return firstLine ? truncateForNote(firstLine) : "failed";
290
+ }
291
+ const matches = readNumber(metadata.matches);
292
+ const pattern = readString(metadata.pattern);
293
+ const path = readString(metadata.path);
294
+ if (matches !== undefined) {
295
+ const target = pattern ? ` for ${pattern}` : "";
296
+ const within = path ? ` in ${path}` : "";
297
+ return `${matches} match${matches === 1 ? "" : "es"}${target}${within}`;
298
+ }
299
+ const kind = readString(metadata.kind);
300
+ if (path)
301
+ return kind ? `${kind} ${path}` : path;
302
+ return event.result.status ?? "completed";
303
+ }
304
+ function readString(value) {
305
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
306
+ }
307
+ function readNumber(value) {
308
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
309
+ }
310
+ function truncateForNote(value, max = 200) {
311
+ return value.length <= max ? value : `${value.slice(0, max - 3)}...`;
312
+ }
@@ -20,6 +20,8 @@ export interface AgentProfile {
20
20
  category?: string;
21
21
  tools: AgentProfileTools;
22
22
  maxTurns?: number;
23
+ /** Optional per-child token cap declared by the profile (may only lower the runtime default). */
24
+ maxTokens?: number;
23
25
  approval: AgentProfileApproval;
24
26
  nicknameCandidates?: string[];
25
27
  prompt: string;
@@ -58,6 +60,12 @@ export declare function discoverAgentProfiles(cwd: string, scope?: AgentProfileS
58
60
  export declare function builtinAgentProfiles(): AgentProfile[];
59
61
  export declare function findAgentProfile(profiles: AgentProfile[], name: string): AgentProfile | undefined;
60
62
  export declare function assignAgentNickname(profile: AgentProfile, activeNicknames?: Iterable<string>): string;
63
+ /**
64
+ * Tool-effect gate as a function of the profile's mode (design §8): readonly
65
+ * children keep the read-only fence; write_worktree children may edit, write,
66
+ * and run bash — inside their isolated worktree, never the parent tree.
67
+ */
68
+ export declare function allowedToolEffectsForMode(mode: AgentProfileMode): Set<string>;
61
69
  export declare function selectToolsForAgentProfile(tools: ToolRegistryEntry[], profile: AgentProfile, approval?: AgentProfileApproval): ToolRegistryEntry[];
62
70
  export declare function validateAgentProfileTools(tools: ToolRegistryEntry[], profile: AgentProfile, approval?: AgentProfileApproval): AgentProfileDiagnostic[];
63
71
  export declare function mergeUsage(current: SubagentRunResult["usage"], usage: TokenUsage): SubagentRunResult["usage"];
@@ -15,7 +15,16 @@ const READONLY_PRESET = [
15
15
  "skill",
16
16
  "todo_write",
17
17
  ];
18
- const SUBAGENT_DENY_TOOLS = new Set(["subagent", "task", "spawn_agent", "wait_agent", "send_input", "close_agent"]);
18
+ const SUBAGENT_DENY_TOOLS = new Set([
19
+ "subagent",
20
+ "task",
21
+ "spawn_agent",
22
+ "wait_agent",
23
+ "send_input",
24
+ "close_agent",
25
+ "list_agents",
26
+ "agent_team",
27
+ ]);
19
28
  const DEFAULT_NICKNAME_CANDIDATES = [
20
29
  "Ada",
21
30
  "Alan",
@@ -146,6 +155,16 @@ export function assignAgentNickname(profile, activeNicknames = []) {
146
155
  }
147
156
  return pool[randomInt(pool.length)];
148
157
  }
158
+ /**
159
+ * Tool-effect gate as a function of the profile's mode (design §8): readonly
160
+ * children keep the read-only fence; write_worktree children may edit, write,
161
+ * and run bash — inside their isolated worktree, never the parent tree.
162
+ */
163
+ export function allowedToolEffectsForMode(mode) {
164
+ return mode === "write_worktree"
165
+ ? new Set(["read", "write_direct", "write_patch", "unknown"])
166
+ : new Set(["read"]);
167
+ }
149
168
  export function selectToolsForAgentProfile(tools, profile, approval = profile.approval) {
150
169
  const explicitInclude = new Set(profile.tools.include ?? []);
151
170
  const selected = requestedToolNames(profile);
@@ -170,13 +189,14 @@ export function selectToolsForAgentProfile(tools, profile, approval = profile.ap
170
189
  export function validateAgentProfileTools(tools, profile, approval = profile.approval) {
171
190
  const available = new Map(tools.map((tool) => [tool.name, tool]));
172
191
  const explicitInclude = new Set(profile.tools.include ?? []);
192
+ const allowedEffects = allowedToolEffectsForMode(profile.mode);
173
193
  const diagnostics = [];
174
194
  for (const name of requestedToolNames(profile)) {
175
195
  if (SUBAGENT_DENY_TOOLS.has(name)) {
176
196
  diagnostics.push({
177
197
  severity: "error",
178
198
  toolName: name,
179
- message: `Tool "${name}" is not allowed inside subagents because recursive delegation is disabled in Phase 1.`,
199
+ message: `Tool "${name}" is not allowed inside subagents because recursive delegation is disabled.`,
180
200
  });
181
201
  continue;
182
202
  }
@@ -192,14 +212,15 @@ export function validateAgentProfileTools(tools, profile, approval = profile.app
192
212
  continue;
193
213
  }
194
214
  const effect = tool.effect ?? "unknown";
195
- if (effect !== "read") {
215
+ if (!allowedEffects.has(effect)) {
196
216
  diagnostics.push({
197
217
  severity: "error",
198
218
  toolName: name,
199
- message: `Tool "${name}" has effect "${effect}" and cannot run in Phase 1 read-only subagents.`,
219
+ message: `Tool "${name}" has effect "${effect}" and cannot run in ${profile.mode} subagents.`,
200
220
  });
201
221
  }
202
- else if (approval === "disabled" && tool.requiresApproval) {
222
+ else if (profile.mode === "readonly" && approval === "disabled" && tool.requiresApproval) {
223
+ // write_worktree children use the worktree approval policy instead.
203
224
  diagnostics.push({
204
225
  severity: "warning",
205
226
  toolName: name,
@@ -299,6 +320,7 @@ function parseAgentProfileFile(raw, source, filePath) {
299
320
  category: stringValue(frontmatter.category),
300
321
  tools: toolsValue(frontmatter.tools),
301
322
  maxTurns: numberValue(frontmatter.maxTurns),
323
+ maxTokens: numberValue(frontmatter.maxTokens),
302
324
  approval: approvalValue(frontmatter.approval),
303
325
  nicknameCandidates: stringArray(frontmatter.nicknameCandidates) ?? stringArray(frontmatter.nicknames),
304
326
  prompt: parsed.body.trim(),