@bubblebrain-ai/bubble 0.0.21 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +197 -34
- package/dist/agent/abort-errors.d.ts +14 -0
- package/dist/agent/abort-errors.js +21 -0
- package/dist/agent/budget-ledger.d.ts +41 -0
- package/dist/agent/budget-ledger.js +64 -0
- package/dist/agent/child-runner.d.ts +55 -0
- package/dist/agent/child-runner.js +312 -0
- package/dist/agent/internal-reminder-sanitizer.js +29 -9
- package/dist/agent/profiles.d.ts +8 -0
- package/dist/agent/profiles.js +27 -5
- package/dist/agent/result-integrator.d.ts +22 -0
- package/dist/agent/result-integrator.js +50 -0
- package/dist/agent/subagent-control.d.ts +31 -0
- package/dist/agent/subagent-control.js +27 -0
- package/dist/agent/subagent-lifecycle-reminder.js +11 -2
- package/dist/agent/subagent-scheduler.d.ts +95 -0
- package/dist/agent/subagent-scheduler.js +256 -0
- package/dist/agent/subagent-store.d.ts +41 -0
- package/dist/agent/subagent-store.js +149 -0
- package/dist/agent/subagent-summary.d.ts +30 -0
- package/dist/agent/subagent-summary.js +74 -0
- package/dist/agent/worktree.d.ts +29 -0
- package/dist/agent/worktree.js +73 -0
- package/dist/agent.d.ts +63 -5
- package/dist/agent.js +360 -287
- package/dist/approval/controller.js +9 -1
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +17 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +17 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/agent-host/run-driver.js +1 -0
- package/dist/main.js +38 -2
- package/dist/model-catalog.js +6 -0
- package/dist/network/errors.d.ts +28 -0
- package/dist/network/errors.js +24 -0
- package/dist/orchestrator/default-hooks.js +5 -1
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/delegation.d.ts +14 -0
- package/dist/prompt/delegation.js +64 -0
- package/dist/prompt/task-reminders.d.ts +5 -1
- package/dist/prompt/task-reminders.js +10 -2
- package/dist/provider-anthropic.js +23 -0
- package/dist/provider-transform.js +14 -0
- package/dist/provider.js +23 -3
- package/dist/slash-commands/commands.js +29 -2
- package/dist/slash-commands/types.d.ts +2 -0
- package/dist/tools/agent-lifecycle.d.ts +29 -3
- package/dist/tools/agent-lifecycle.js +394 -40
- package/dist/tools/child-tools.d.ts +31 -0
- package/dist/tools/child-tools.js +106 -0
- package/dist/tools/index.js +1 -1
- package/dist/tui/run.d.ts +17 -1
- package/dist/tui/run.js +155 -10
- package/dist/tui/session-picker-data.d.ts +18 -0
- package/dist/tui/session-picker-data.js +21 -0
- package/dist/tui/trace-groups.js +41 -5
- package/dist/tui/wordmark.d.ts +2 -0
- package/dist/tui/wordmark.js +31 -4
- package/dist/tui-ink/approval/approval-dialog.js +10 -0
- package/dist/tui-opentui/approval/approval-dialog.js +10 -0
- package/dist/types.d.ts +17 -0
- package/dist/update/index.d.ts +18 -4
- package/dist/update/index.js +41 -19
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -54,17 +54,37 @@ export function sanitizeAssistantProviderMetadata(metadata) {
|
|
|
54
54
|
if (!metadata || !anthropic || !blocks?.length)
|
|
55
55
|
return metadata;
|
|
56
56
|
let changed = false;
|
|
57
|
-
const sanitizedBlocks =
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
const sanitizedBlocks = [];
|
|
58
|
+
for (const block of blocks) {
|
|
59
|
+
// Plaintext text blocks are unsigned, so rewriting them in place is safe.
|
|
60
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
61
|
+
const sanitizedText = sanitizeInternalReminderBlocks(block.text);
|
|
62
|
+
if (sanitizedText !== block.text) {
|
|
63
|
+
changed = true;
|
|
64
|
+
sanitizedBlocks.push({ ...block, text: sanitizedText });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
sanitizedBlocks.push(block);
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
// Extended-thinking blocks carry an Anthropic signature over their exact
|
|
72
|
+
// text; rewriting the text would invalidate the signature and the API
|
|
73
|
+
// would reject the replayed block. So when a thinking block's text carries
|
|
74
|
+
// internal markup (e.g. an echoed system reminder), DROP the whole block
|
|
75
|
+
// rather than mutate it. Thinking text is never user-visible — the display
|
|
76
|
+
// path renders message.reasoning, not contentBlocks — so dropping loses
|
|
77
|
+
// nothing on screen; it only keeps the verbatim reminder out of the
|
|
78
|
+
// persisted metadata and the Anthropic replay payload. redacted_thinking
|
|
79
|
+
// holds encrypted `data` (no plaintext field) and cannot carry a reminder.
|
|
80
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
81
|
+
if (sanitizeInternalReminderBlocks(block.thinking) !== block.thinking) {
|
|
82
|
+
changed = true;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
64
85
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
86
|
+
sanitizedBlocks.push(block);
|
|
87
|
+
}
|
|
68
88
|
if (!changed)
|
|
69
89
|
return metadata;
|
|
70
90
|
return {
|
package/dist/agent/profiles.d.ts
CHANGED
|
@@ -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"];
|
package/dist/agent/profiles.js
CHANGED
|
@@ -15,7 +15,16 @@ const READONLY_PRESET = [
|
|
|
15
15
|
"skill",
|
|
16
16
|
"todo_write",
|
|
17
17
|
];
|
|
18
|
-
const SUBAGENT_DENY_TOOLS = new Set([
|
|
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
|
|
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
|
|
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
|
|
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(),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResultIntegrator — turns background child completions into ingestion
|
|
3
|
+
* notices injected before the parent's next inference turn (design doc §5).
|
|
4
|
+
*
|
|
5
|
+
* Injection marks the child as delivered (§3.3), so the lifecycle reminder
|
|
6
|
+
* demotes it to a one-liner and the same summary never appears twice in full
|
|
7
|
+
* form in the parent transcript.
|
|
8
|
+
*/
|
|
9
|
+
import type { SubagentThreadRecord } from "./subagent-control.js";
|
|
10
|
+
import type { SubagentStore } from "./subagent-store.js";
|
|
11
|
+
export declare class ResultIntegrator {
|
|
12
|
+
private readonly pending;
|
|
13
|
+
enqueue(agentId: string): void;
|
|
14
|
+
hasPending(): boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Builds notices for children whose results have not yet reached parent
|
|
17
|
+
* context, marking them delivered. Already-delivered children (e.g. the
|
|
18
|
+
* model wait_agent-ed first) are skipped silently.
|
|
19
|
+
*/
|
|
20
|
+
drainNotices(store: SubagentStore, now?: number): string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function buildIngestionNotice(record: SubagentThreadRecord): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ResultIntegrator — turns background child completions into ingestion
|
|
3
|
+
* notices injected before the parent's next inference turn (design doc §5).
|
|
4
|
+
*
|
|
5
|
+
* Injection marks the child as delivered (§3.3), so the lifecycle reminder
|
|
6
|
+
* demotes it to a one-liner and the same summary never appears twice in full
|
|
7
|
+
* form in the parent transcript.
|
|
8
|
+
*/
|
|
9
|
+
import { fenceChildOutput } from "./subagent-summary.js";
|
|
10
|
+
export class ResultIntegrator {
|
|
11
|
+
pending = [];
|
|
12
|
+
enqueue(agentId) {
|
|
13
|
+
if (!this.pending.includes(agentId)) {
|
|
14
|
+
this.pending.push(agentId);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
hasPending() {
|
|
18
|
+
return this.pending.length > 0;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Builds notices for children whose results have not yet reached parent
|
|
22
|
+
* context, marking them delivered. Already-delivered children (e.g. the
|
|
23
|
+
* model wait_agent-ed first) are skipped silently.
|
|
24
|
+
*/
|
|
25
|
+
drainNotices(store, now = Date.now()) {
|
|
26
|
+
const ids = this.pending.splice(0, this.pending.length);
|
|
27
|
+
const notices = [];
|
|
28
|
+
for (const id of ids) {
|
|
29
|
+
const record = store.get(id);
|
|
30
|
+
if (!record || record.deliveredAt !== undefined)
|
|
31
|
+
continue;
|
|
32
|
+
notices.push(buildIngestionNotice(record));
|
|
33
|
+
store.markDelivered(id, now);
|
|
34
|
+
}
|
|
35
|
+
return notices;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function buildIngestionNotice(record) {
|
|
39
|
+
const lines = [
|
|
40
|
+
`subagent ${record.nickname} (agent_id: ${record.agentId}) ${record.status}.`,
|
|
41
|
+
];
|
|
42
|
+
if (record.error) {
|
|
43
|
+
lines.push(`error: ${record.error}`);
|
|
44
|
+
}
|
|
45
|
+
if (record.summary) {
|
|
46
|
+
lines.push(fenceChildOutput(record.summary));
|
|
47
|
+
}
|
|
48
|
+
lines.push("Full result via wait_agent. Do not redo this delegated work.");
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
import type { AgentProfile, AgentProfileSource, SubagentRunResult } from "./profiles.js";
|
|
2
2
|
import type { ResolvedSubagentRoute } from "./categories.js";
|
|
3
|
+
import type { SubagentWorktree } from "./worktree.js";
|
|
3
4
|
import type { AgentEvent, ContentPart, Message, ToolUpdate } from "../types.js";
|
|
4
5
|
export type SubagentThreadStatus = "queued" | "running" | "completed" | "failed" | "blocked" | "cancelled" | "closed";
|
|
6
|
+
/**
|
|
7
|
+
* Why a child run ended. Drives the `resumable` flag and the guidance line
|
|
8
|
+
* rendered in lifecycle tool replies (design doc §3.1) — a resume hint is
|
|
9
|
+
* emitted iff the runtime judged the run resumable, never as a blanket string.
|
|
10
|
+
*/
|
|
11
|
+
export type SubagentFinalReason = "completed" | "failed_transient" | "failed_fatal" | "rate_limited_exhausted" | "blocked" | "cancelled_interrupt" | "cancelled_user" | "cancelled_budget" | "cancelled_parent_abort";
|
|
12
|
+
export declare function isResumableReason(reason: SubagentFinalReason): boolean;
|
|
13
|
+
/** Per-child token budget, fixed at dispatch time (design doc §6). */
|
|
14
|
+
export interface SubagentTokenCap {
|
|
15
|
+
/** Soft cap: crossing it injects a wrap-up reminder into the child. */
|
|
16
|
+
soft: number;
|
|
17
|
+
/** Hard cap: crossing it aborts this child only. Updated at turn checks. */
|
|
18
|
+
hard: number;
|
|
19
|
+
/** Ledger tokens already attributed to this child when the run started. */
|
|
20
|
+
baseline: number;
|
|
21
|
+
}
|
|
5
22
|
export interface SubagentThreadSnapshot {
|
|
6
23
|
agentId: string;
|
|
7
24
|
runId: string;
|
|
@@ -11,6 +28,8 @@ export interface SubagentThreadSnapshot {
|
|
|
11
28
|
category?: string;
|
|
12
29
|
route?: ResolvedSubagentRoute;
|
|
13
30
|
status: SubagentThreadStatus;
|
|
31
|
+
finalReason?: SubagentFinalReason;
|
|
32
|
+
resumable?: boolean;
|
|
14
33
|
task: string;
|
|
15
34
|
summary: string;
|
|
16
35
|
toolNotes: string[];
|
|
@@ -18,6 +37,12 @@ export interface SubagentThreadSnapshot {
|
|
|
18
37
|
error?: string;
|
|
19
38
|
createdAt: number;
|
|
20
39
|
updatedAt: number;
|
|
40
|
+
deliveredAt?: number;
|
|
41
|
+
/** 1-based position in the scheduler queue while status is "queued". */
|
|
42
|
+
queuePosition?: number;
|
|
43
|
+
tokenCap?: SubagentTokenCap;
|
|
44
|
+
/** Present for write_worktree children: where the isolated checkout lives. */
|
|
45
|
+
worktree?: SubagentWorktree;
|
|
21
46
|
}
|
|
22
47
|
export interface SubagentThreadRecord {
|
|
23
48
|
agentId: string;
|
|
@@ -29,6 +54,7 @@ export interface SubagentThreadRecord {
|
|
|
29
54
|
parentToolCallId: string;
|
|
30
55
|
parentToolName: string;
|
|
31
56
|
status: SubagentThreadStatus;
|
|
57
|
+
finalReason?: SubagentFinalReason;
|
|
32
58
|
task: string;
|
|
33
59
|
summary: string;
|
|
34
60
|
toolNotes: string[];
|
|
@@ -36,6 +62,9 @@ export interface SubagentThreadRecord {
|
|
|
36
62
|
error?: string;
|
|
37
63
|
createdAt: number;
|
|
38
64
|
updatedAt: number;
|
|
65
|
+
deliveredAt?: number;
|
|
66
|
+
tokenCap?: SubagentTokenCap;
|
|
67
|
+
worktree?: SubagentWorktree;
|
|
39
68
|
abortController: AbortController;
|
|
40
69
|
waiters: Set<() => void>;
|
|
41
70
|
agent?: {
|
|
@@ -43,6 +72,7 @@ export interface SubagentThreadRecord {
|
|
|
43
72
|
injectSystemReminder(content: string): void;
|
|
44
73
|
run(input: string | ContentPart[], cwd: string, options?: {
|
|
45
74
|
abortSignal?: AbortSignal;
|
|
75
|
+
resumeWithoutInput?: boolean;
|
|
46
76
|
}): AsyncIterable<AgentEvent>;
|
|
47
77
|
};
|
|
48
78
|
messages?: Message[];
|
|
@@ -55,3 +85,4 @@ export interface PendingSubagentToolUpdate {
|
|
|
55
85
|
}
|
|
56
86
|
export declare function snapshotSubagentThread(record: SubagentThreadRecord): SubagentThreadSnapshot;
|
|
57
87
|
export declare function subagentResultFromThread(record: SubagentThreadRecord): SubagentRunResult;
|
|
88
|
+
export declare function isFinalSubagentThreadStatus(status: SubagentThreadStatus): boolean;
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
export function isResumableReason(reason) {
|
|
2
|
+
switch (reason) {
|
|
3
|
+
case "failed_transient":
|
|
4
|
+
case "rate_limited_exhausted":
|
|
5
|
+
case "cancelled_interrupt":
|
|
6
|
+
case "cancelled_user":
|
|
7
|
+
case "cancelled_parent_abort":
|
|
8
|
+
return true;
|
|
9
|
+
case "completed":
|
|
10
|
+
case "failed_fatal":
|
|
11
|
+
case "blocked":
|
|
12
|
+
case "cancelled_budget":
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
1
16
|
export function snapshotSubagentThread(record) {
|
|
2
17
|
return {
|
|
3
18
|
agentId: record.agentId,
|
|
@@ -8,6 +23,8 @@ export function snapshotSubagentThread(record) {
|
|
|
8
23
|
category: record.category,
|
|
9
24
|
route: record.route,
|
|
10
25
|
status: record.status,
|
|
26
|
+
finalReason: record.finalReason,
|
|
27
|
+
resumable: record.finalReason !== undefined ? isResumableReason(record.finalReason) : undefined,
|
|
11
28
|
task: record.task,
|
|
12
29
|
summary: record.summary,
|
|
13
30
|
toolNotes: [...record.toolNotes],
|
|
@@ -15,6 +32,9 @@ export function snapshotSubagentThread(record) {
|
|
|
15
32
|
error: record.error,
|
|
16
33
|
createdAt: record.createdAt,
|
|
17
34
|
updatedAt: record.updatedAt,
|
|
35
|
+
deliveredAt: record.deliveredAt,
|
|
36
|
+
tokenCap: record.tokenCap ? { ...record.tokenCap } : undefined,
|
|
37
|
+
worktree: record.worktree ? { ...record.worktree } : undefined,
|
|
18
38
|
};
|
|
19
39
|
}
|
|
20
40
|
export function subagentResultFromThread(record) {
|
|
@@ -40,3 +60,10 @@ export function subagentResultFromThread(record) {
|
|
|
40
60
|
error: record.error,
|
|
41
61
|
};
|
|
42
62
|
}
|
|
63
|
+
export function isFinalSubagentThreadStatus(status) {
|
|
64
|
+
return status === "completed"
|
|
65
|
+
|| status === "failed"
|
|
66
|
+
|| status === "blocked"
|
|
67
|
+
|| status === "cancelled"
|
|
68
|
+
|| status === "closed";
|
|
69
|
+
}
|