agent-from-zero 0.1.0 → 0.27.0
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 +37 -3
- package/dist/agent.js +500 -34
- package/dist/config.js +88 -12
- package/dist/context.js +4 -3
- package/dist/cost.js +93 -0
- package/dist/diff.js +135 -0
- package/dist/form.js +78 -0
- package/dist/hooks.js +92 -0
- package/dist/judge.js +77 -0
- package/dist/loop.js +267 -39
- package/dist/mcp.js +237 -0
- package/dist/memory.js +186 -0
- package/dist/mentions.js +62 -0
- package/dist/menu.js +154 -0
- package/dist/permissions.js +104 -6
- package/dist/prompt.js +16 -11
- package/dist/session.js +103 -0
- package/dist/skills.js +128 -0
- package/dist/telemetry.js +43 -0
- package/dist/tools.js +120 -18
- package/dist/tui.js +67 -0
- package/dist/ui.js +134 -0
- package/dist/undo.js +88 -0
- package/dist/validate.js +60 -0
- package/package.json +4 -3
package/dist/loop.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import chalk from "chalk"; // terminal colors for status lines
|
|
2
2
|
import ora from "ora"; // the "thinking..." spinner while waiting for the first token
|
|
3
|
-
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
3
|
+
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
4
4
|
import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
5
5
|
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
6
|
+
import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
|
|
6
7
|
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
7
8
|
import { SUB_AGENT_PROMPT } from "./prompt.js"; // the sub-agent's own constitution
|
|
8
|
-
|
|
9
|
+
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
10
|
+
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
11
|
+
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
12
|
+
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
13
|
+
import { printToolSummary } from "./tui.js"; // collapsible tool output
|
|
9
14
|
export const MAX_RETRIES = 10; // total failed API calls per query, across all rounds
|
|
10
15
|
export const MAX_RATE_LIMIT_RETRIES = 3; // 429s get their own, much smaller budget
|
|
11
16
|
export const MAX_CONSECUTIVE_FAILURES = 3; // the circuit breaker
|
|
@@ -18,7 +23,6 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
|
|
|
18
23
|
export var TerminateReason;
|
|
19
24
|
(function (TerminateReason) {
|
|
20
25
|
TerminateReason["Done"] = "done";
|
|
21
|
-
TerminateReason["RoundCap"] = "round_cap";
|
|
22
26
|
TerminateReason["CircuitBreaker"] = "circuit_breaker";
|
|
23
27
|
TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
|
|
24
28
|
TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
|
|
@@ -27,6 +31,12 @@ export var TerminateReason;
|
|
|
27
31
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
28
32
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
29
33
|
})(TerminateReason || (TerminateReason = {}));
|
|
34
|
+
// Which model should a delegated sub-agent run on? The configured sub-agent
|
|
35
|
+
// model if set and non-blank, otherwise the same model as the orchestrator.
|
|
36
|
+
// Pulled out as a pure function so the tiering rule is testable on its own.
|
|
37
|
+
export function subAgentModelFor(opts) {
|
|
38
|
+
return opts.subAgentModel?.trim() || opts.model;
|
|
39
|
+
}
|
|
30
40
|
// The task tool: the parent's handle on sub-agents. Defined here (not in
|
|
31
41
|
// tools.ts) because running it needs the loop itself — it IS a loop.
|
|
32
42
|
const taskTool = {
|
|
@@ -47,18 +57,53 @@ On error: a failed sub-agent returns [sub-agent failed: reason] — retry with a
|
|
|
47
57
|
},
|
|
48
58
|
},
|
|
49
59
|
};
|
|
60
|
+
// The ask_user tool: the model's handle on the user. When it needs the human to
|
|
61
|
+
// choose between options, it calls this instead of asking in prose — and gets
|
|
62
|
+
// back clean, structured selections rather than a free-text paragraph to parse.
|
|
63
|
+
const askUserTool = {
|
|
64
|
+
type: "function",
|
|
65
|
+
function: {
|
|
66
|
+
name: "ask_user",
|
|
67
|
+
description: `Ask the user one or more multiple-choice questions and get back their selections. Use this WHENEVER you need the user to make a decision between options (which approach, which file, yes/no/which) — it is easier for them to pick than to type, and the answers come back unambiguous.
|
|
68
|
+
Provide each question with 2–4 concrete options. Do NOT use it for open-ended input that has no clear options; ask in prose for that.
|
|
69
|
+
If the user cancels, you'll be told — then proceed with your best judgment or ask again.`,
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
questions: {
|
|
74
|
+
type: "array",
|
|
75
|
+
description: "The questions to ask, each with a list of options",
|
|
76
|
+
items: {
|
|
77
|
+
type: "object",
|
|
78
|
+
properties: {
|
|
79
|
+
question: { type: "string", description: "The question text" },
|
|
80
|
+
options: { type: "array", items: { type: "string" }, description: "2–4 concrete choices" },
|
|
81
|
+
},
|
|
82
|
+
required: ["question", "options"],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
required: ["questions"],
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
50
90
|
// Run one sub-agent: a fresh conversation, same tools minus task, same
|
|
51
91
|
// permission gate, and the parent's file read-state protected by a snapshot.
|
|
52
92
|
async function runSubAgent(description, opts) {
|
|
93
|
+
emit("agent_subagent_spawn"); // delegation is worth counting
|
|
94
|
+
const subModel = subAgentModelFor(opts); // the tier this delegated work runs on
|
|
95
|
+
// Show the delegation, and the model when it differs from the orchestrator's —
|
|
96
|
+
// the tiering should be visible, not a silent surprise on the bill.
|
|
53
97
|
if (!opts.quiet)
|
|
54
|
-
console.log(
|
|
98
|
+
console.log(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : "")); // annotate the tier only on a real switch
|
|
99
|
+
await runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // lifecycle hook (observational)
|
|
55
100
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
56
101
|
try {
|
|
57
102
|
const subMessages = [
|
|
58
103
|
{ role: "system", content: SUB_AGENT_PROMPT }, // its own, smaller constitution
|
|
59
104
|
{ role: "user", content: description }, // the task is its entire world
|
|
60
105
|
];
|
|
61
|
-
const result = await runLoop(subMessages, { ...opts, subAgent: true }); // recurse
|
|
106
|
+
const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel }); // recurse as a sub-agent, on its own tier
|
|
62
107
|
if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
|
|
63
108
|
// The framing matters: the parent must treat this as material to verify,
|
|
64
109
|
// not as conclusions to copy — the most common multi-agent failure mode.
|
|
@@ -68,8 +113,9 @@ async function runSubAgent(description, opts) {
|
|
|
68
113
|
}
|
|
69
114
|
finally {
|
|
70
115
|
restoreFileState(snapshot); // the parent's read-before-edit state, exactly as it was
|
|
116
|
+
await runHooks("SubagentStop", { description: description.slice(0, 200) }); // lifecycle hook (observational)
|
|
71
117
|
if (!opts.quiet)
|
|
72
|
-
console.log(
|
|
118
|
+
console.log(mark.subAgentDone); // close the bracket
|
|
73
119
|
}
|
|
74
120
|
}
|
|
75
121
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
@@ -86,56 +132,92 @@ async function interruptibleSleep(ms, isInterrupted) {
|
|
|
86
132
|
await new Promise((r) => setTimeout(r, Math.min(SLICE, ms - waited))); // sleep one slice (or the remainder)
|
|
87
133
|
}
|
|
88
134
|
}
|
|
135
|
+
let modelCallSeq = 0; // increments per model call — seeds the rotating spinner word
|
|
89
136
|
// One streaming model call: prints text as it arrives, assembles tool calls
|
|
90
137
|
// from their deltas, and guards the stream with a two-level watchdog.
|
|
91
138
|
async function streamModelCall(messages, // the full history to send
|
|
92
139
|
opts) {
|
|
93
140
|
const idleAbort = new AbortController(); // the watchdog's own kill switch
|
|
94
141
|
const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
|
|
142
|
+
const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
|
|
143
|
+
const startedAt = Date.now(); // for the spinner's live elapsed counter
|
|
144
|
+
let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
|
|
95
145
|
const spinner = opts.quiet
|
|
96
146
|
? null // the eval harness wants silence
|
|
97
|
-
: ora({ text: opts.subAgent
|
|
147
|
+
: ora({ text: spinnerText(word, 0, !!opts.subAgent, opts.model), discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
|
|
98
148
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
99
149
|
let stallWarned = false; // only warn once per quiet stretch
|
|
100
150
|
// The two-level watchdog, checked once per second:
|
|
101
151
|
// - idle (nothing at all for 90s) → cut the stream, let the retry layer handle it
|
|
102
152
|
// - stall (slow but alive for 30s) → log it and keep waiting
|
|
153
|
+
// It also ticks the spinner: elapsed seconds + a live token estimate (~chars/4).
|
|
103
154
|
const watchdog = setInterval(() => {
|
|
155
|
+
if (spinner?.isSpinning)
|
|
156
|
+
spinner.text = spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, opts.model, Math.round(streamedChars / 4));
|
|
104
157
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
105
|
-
if (quietMs > IDLE_TIMEOUT_MS)
|
|
158
|
+
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
159
|
+
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
106
160
|
idleAbort.abort(); // dead — cut it
|
|
161
|
+
}
|
|
107
162
|
else if (quietMs > STALL_WARN_MS && !stallWarned) {
|
|
108
163
|
stallWarned = true; // don't repeat the warning every second
|
|
164
|
+
emit("agent_watchdog_stall"); // record the slowness — pattern-spotting data
|
|
109
165
|
console.log(chalk.dim(` [watchdog] stream quiet for ${Math.round(quietMs / 1000)}s — still waiting (slow ≠ dead)`));
|
|
110
166
|
}
|
|
111
167
|
}, 1000);
|
|
112
168
|
try {
|
|
169
|
+
emit("agent_api_call"); // one event per attempt — retries show up as extra calls
|
|
113
170
|
const stream = await opts.client.chat.completions.create(
|
|
114
171
|
// Sub-agents do NOT get the task tool: one level of delegation only.
|
|
115
172
|
// Nested spawning means orphan processes and debugging hell.
|
|
116
|
-
|
|
173
|
+
// include_usage adds a final chunk carrying token counts — that is how we
|
|
174
|
+
// meter cost without a second (counting) request.
|
|
175
|
+
{ model: opts.model, messages, tools: opts.subAgent ? toolDefinitions() : [...toolDefinitions(), taskTool, askUserTool], stream: true, stream_options: { include_usage: true } }, { signal });
|
|
117
176
|
let content = ""; // accumulated answer text
|
|
118
|
-
let printedPrefix = false; // have we printed the
|
|
177
|
+
let printedPrefix = false; // have we printed the answer prefix yet?
|
|
178
|
+
let printedThinking = false; // have we started printing the reasoning trace yet?
|
|
119
179
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
120
180
|
for await (const chunk of stream) {
|
|
121
181
|
lastEvent = Date.now(); // feed the watchdog
|
|
122
182
|
stallWarned = false; // the stream spoke — reset the stall warning
|
|
183
|
+
if (chunk.usage)
|
|
184
|
+
recordUsage(chunk.usage); // the final usage chunk — meter it
|
|
123
185
|
const delta = chunk.choices[0]?.delta; // this chunk's increment
|
|
124
186
|
if (!delta)
|
|
125
187
|
continue; // keep-alive or usage chunk — nothing to do
|
|
188
|
+
// Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
|
|
189
|
+
// separate `reasoning_content` field BEFORE the answer. Show it dimly so a
|
|
190
|
+
// switch to a reasoning model is visibly different — but do NOT keep it:
|
|
191
|
+
// the reasoning is not the answer and must not be replayed in later turns.
|
|
192
|
+
const reasoning = delta.reasoning_content;
|
|
193
|
+
if (reasoning) {
|
|
194
|
+
streamedChars += reasoning.length; // count reasoning toward the live token estimate
|
|
195
|
+
if (spinner?.isSpinning)
|
|
196
|
+
spinner.stop();
|
|
197
|
+
if (!opts.quiet && !opts.subAgent) {
|
|
198
|
+
if (!printedThinking) {
|
|
199
|
+
process.stdout.write("\n" + chalk.dim("💭 thinking: ")); // label the trace once
|
|
200
|
+
printedThinking = true;
|
|
201
|
+
}
|
|
202
|
+
process.stdout.write(chalk.dim(reasoning)); // stream the thinking, dimmed
|
|
203
|
+
}
|
|
204
|
+
}
|
|
126
205
|
if (delta.content) {
|
|
206
|
+
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
127
207
|
if (spinner?.isSpinning)
|
|
128
208
|
spinner.stop(); // first token: replace the spinner with real output
|
|
129
209
|
if (!opts.quiet && !opts.subAgent) {
|
|
130
210
|
// Only the top-level agent streams to the screen — a sub-agent's
|
|
131
211
|
// inner monologue would be mistaken for the answer.
|
|
212
|
+
if (printedThinking && !printedPrefix)
|
|
213
|
+
process.stdout.write("\n"); // end the thinking block before the answer
|
|
132
214
|
if (!printedPrefix) {
|
|
133
|
-
process.stdout.write("\n
|
|
215
|
+
process.stdout.write("\n" + mark.answer); // prefix once (⏺), then stream raw
|
|
134
216
|
printedPrefix = true;
|
|
135
217
|
}
|
|
136
218
|
process.stdout.write(delta.content); // print the token immediately — this IS the streaming UX
|
|
137
219
|
}
|
|
138
|
-
content += delta.content; // always keep it for the history
|
|
220
|
+
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
139
221
|
}
|
|
140
222
|
for (const tc of delta.tool_calls ?? []) {
|
|
141
223
|
if (spinner?.isSpinning)
|
|
@@ -159,11 +241,116 @@ opts) {
|
|
|
159
241
|
spinner.stop(); // and never leave a zombie spinner
|
|
160
242
|
}
|
|
161
243
|
}
|
|
244
|
+
// Run one tool call end to end: log it, run it through the permission gate,
|
|
245
|
+
// ask the human if needed, execute, and return the paired result. Read-only
|
|
246
|
+
// calls (allow/deny only, never "ask") are safe to run via this from inside a
|
|
247
|
+
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
248
|
+
async function runOneCall(call, opts) {
|
|
249
|
+
const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
|
|
250
|
+
if (!opts.quiet) {
|
|
251
|
+
// Compact one-line summary — long args are hidden behind Tab.
|
|
252
|
+
const summary = indent + mark.tool(call.name, call.args.length > 60 ? call.args.slice(0, 57) + "..." : call.args);
|
|
253
|
+
const full = `tool: ${call.name}\nargs: ${call.args}`;
|
|
254
|
+
printToolSummary(summary, full);
|
|
255
|
+
}
|
|
256
|
+
// The permission gate sits between the model's intent and execution.
|
|
257
|
+
const v = checkPermission(call.name, call.args);
|
|
258
|
+
emit("agent_tool_call", { tool: call.name }); // every attempt, allowed or not
|
|
259
|
+
// For file-changing tools, show the diff BEFORE the gate decides. A filename
|
|
260
|
+
// ("edit_file: cart.js") is not enough to approve safely; the actual +/- lines
|
|
261
|
+
// are. Skipped for a hard deny (nothing will run) and in quiet sub-agent runs.
|
|
262
|
+
if (!opts.quiet && v.decision !== "deny" && (call.name === "write_file" || call.name === "edit_file")) {
|
|
263
|
+
const preview = previewChange(call.name, call.args);
|
|
264
|
+
if (preview)
|
|
265
|
+
console.log(preview.replace(/^/gm, indent)); // nest under the sub-agent marker if any
|
|
266
|
+
}
|
|
267
|
+
let content; // what goes back to the model as the tool result
|
|
268
|
+
if (v.decision === "deny") {
|
|
269
|
+
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
270
|
+
if (!opts.quiet)
|
|
271
|
+
console.log(mark.denied(v.reason)); // tell the user we blocked it
|
|
272
|
+
content = `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`; // teach the model the boundary
|
|
273
|
+
}
|
|
274
|
+
else if (v.decision === "ask") {
|
|
275
|
+
// Optional LLM judge: for a run_bash command the rules couldn't classify,
|
|
276
|
+
// ask the judge first. It can only DOWNGRADE ask→allow for the clearly
|
|
277
|
+
// safe; anything else still goes to the human. The judge never sees a deny.
|
|
278
|
+
let autoAllowed = false;
|
|
279
|
+
if (opts.judge && call.name === "run_bash") {
|
|
280
|
+
let cmd = "";
|
|
281
|
+
try {
|
|
282
|
+
cmd = JSON.parse(call.args).command ?? "";
|
|
283
|
+
}
|
|
284
|
+
catch { /* leave empty → judge will ask */ }
|
|
285
|
+
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
286
|
+
autoAllowed = true;
|
|
287
|
+
if (!opts.quiet)
|
|
288
|
+
console.log(mark.judge); // visible — the user can see the judge worked
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const ok = autoAllowed || (await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`, call.name)); // judge-allowed, or pause and ask the human
|
|
292
|
+
if (!ok)
|
|
293
|
+
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
294
|
+
if (!ok && !opts.quiet)
|
|
295
|
+
console.log(mark.declined); // make the refusal visible
|
|
296
|
+
content = ok
|
|
297
|
+
? await runWithHooks(call, opts) // approved — run it (PreToolUse can still block)
|
|
298
|
+
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
|
|
302
|
+
}
|
|
303
|
+
return { id: call.id, content }; // paired by id for the API
|
|
304
|
+
}
|
|
305
|
+
// Run an approved tool call, surrounded by the user's lifecycle hooks.
|
|
306
|
+
// PreToolUse can block (exit 2) — its stderr is fed back to the model as the
|
|
307
|
+
// reason, exactly like a permission denial, so the model adapts instead of
|
|
308
|
+
// retrying. PostToolUse is observational; if it blocks, its message is appended
|
|
309
|
+
// to the tool result as extra context (e.g. "lint failed on the file you just
|
|
310
|
+
// wrote"). Sub-agents skip hooks: hooks are about the human's project policy,
|
|
311
|
+
// not internal delegation.
|
|
312
|
+
async function runWithHooks(call, opts) {
|
|
313
|
+
if (opts.subAgent)
|
|
314
|
+
return execute(call, opts); // sub-agents run hook-free
|
|
315
|
+
const pre = await runHooks("PreToolUse", { tool: call.name, args: call.args }); // before
|
|
316
|
+
if (pre.block) {
|
|
317
|
+
emit("agent_hook_block", { event: "PreToolUse", tool: call.name }); // a hook said no
|
|
318
|
+
if (!opts.quiet)
|
|
319
|
+
console.log(mark.hookBlock("PreToolUse")); // make it visible
|
|
320
|
+
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
321
|
+
}
|
|
322
|
+
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
323
|
+
// permission gate already ran on the original args; the rewrite only narrows or
|
|
324
|
+
// annotates, never escalates past a deny. Validate it's parseable, then use it.
|
|
325
|
+
if (pre.rewrite) {
|
|
326
|
+
try {
|
|
327
|
+
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
328
|
+
if (!opts.quiet)
|
|
329
|
+
console.log(chalk.dim(` ⎿ PreToolUse hook rewrote the arguments`));
|
|
330
|
+
call = { ...call, args: pre.rewrite };
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
/* malformed rewrite — ignore, run the original args */
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const result = await execute(call, opts); // the actual work
|
|
337
|
+
const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
|
|
338
|
+
if (post.block) {
|
|
339
|
+
// PostToolUse cannot undo the action, but it can tell the model something
|
|
340
|
+
// is wrong with the result — surface that as appended context.
|
|
341
|
+
return `${result}\n\n[hook] PostToolUse: ${post.feedback}`;
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
162
345
|
// Execute one approved tool call. The task tool is special — it is not in the
|
|
163
346
|
// registry because running it needs the loop itself (it IS a loop).
|
|
164
347
|
async function execute(call, opts) {
|
|
348
|
+
// ask_user is special like task: running it needs the CLI's prompt, not the
|
|
349
|
+
// tool registry. It presents a form and feeds the selections back to the model.
|
|
350
|
+
if (call.name === "ask_user")
|
|
351
|
+
return runAskUser(call, opts);
|
|
165
352
|
if (call.name !== "task")
|
|
166
|
-
return dispatch(call.name, call.args); // ordinary tools go through the registry
|
|
353
|
+
return dispatch(call.name, call.args, opts.signal); // ordinary tools go through the registry (signal lets Ctrl+C kill run_bash)
|
|
167
354
|
if (opts.subAgent)
|
|
168
355
|
return "[error] Sub-agents cannot spawn sub-agents. Do the work yourself."; // one level of delegation only
|
|
169
356
|
let description = ""; // the sub-task text
|
|
@@ -177,6 +364,29 @@ async function execute(call, opts) {
|
|
|
177
364
|
return "[error] task requires a non-empty description argument.";
|
|
178
365
|
return runSubAgent(description, opts); // spawn the worker
|
|
179
366
|
}
|
|
367
|
+
// Present the ask_user form and turn the selections into a tool result. The
|
|
368
|
+
// model called this to get a decision; we give it clean question→answer pairs.
|
|
369
|
+
async function runAskUser(call, opts) {
|
|
370
|
+
if (opts.subAgent)
|
|
371
|
+
return "[error] Sub-agents cannot ask the user. Decide yourself, or report back to the parent."; // only the top-level agent talks to the human
|
|
372
|
+
if (!opts.askUser)
|
|
373
|
+
return "[error] No interactive prompt is available in this session — ask in plain text instead.";
|
|
374
|
+
let questions = [];
|
|
375
|
+
try {
|
|
376
|
+
questions = JSON.parse(call.args).questions ?? [];
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
/* fall through */
|
|
380
|
+
}
|
|
381
|
+
// Keep only well-formed questions (text + at least two options).
|
|
382
|
+
questions = (questions ?? []).filter((q) => q && typeof q.question === "string" && Array.isArray(q.options) && q.options.length >= 2);
|
|
383
|
+
if (!questions.length)
|
|
384
|
+
return "[error] ask_user needs at least one question with two or more options.";
|
|
385
|
+
const answers = await opts.askUser(questions);
|
|
386
|
+
if (!answers)
|
|
387
|
+
return "The user dismissed the question form without answering. Proceed with your best judgment, or ask again if you truly need their input."; // cancelled / non-interactive
|
|
388
|
+
return `The user answered:\n${answers.map((a) => `- ${a.question} → ${a.answer}`).join("\n")}`; // organized, unambiguous
|
|
389
|
+
}
|
|
180
390
|
// Run one compaction attempt and keep score. Returns true on success.
|
|
181
391
|
// Every automatic behavior keeps a failure count — that is what lets the
|
|
182
392
|
// caller stop a hot failure loop instead of compacting forever.
|
|
@@ -184,13 +394,19 @@ async function tryCompact(messages, // history to compact (mutated in place)
|
|
|
184
394
|
opts, // for client/model/signal
|
|
185
395
|
compaction) {
|
|
186
396
|
try {
|
|
397
|
+
if (!opts.subAgent)
|
|
398
|
+
await runHooks("PreCompact", {}); // lifecycle hook (top-level only; hooks are the human's project policy)
|
|
187
399
|
await compactHistory(messages, opts.client, opts.model, opts.signal); // do the actual work
|
|
188
400
|
compaction.count++; // one more successful compaction this query
|
|
189
401
|
compaction.failures = 0; // success resets the failure streak
|
|
402
|
+
emit("agent_compaction_ok"); // worth counting — frequent compaction means tasks are too big
|
|
403
|
+
if (!opts.subAgent)
|
|
404
|
+
await runHooks("PostCompact", {}); // observational
|
|
190
405
|
return true;
|
|
191
406
|
}
|
|
192
407
|
catch {
|
|
193
408
|
compaction.failures++; // failed — count it against the breaker
|
|
409
|
+
emit("agent_compaction_failed"); // a failure streak here trips the breaker
|
|
194
410
|
return false;
|
|
195
411
|
}
|
|
196
412
|
}
|
|
@@ -201,7 +417,7 @@ opts) {
|
|
|
201
417
|
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
202
418
|
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
203
419
|
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
204
|
-
for (let round = 1
|
|
420
|
+
for (let round = 1;; round++) {
|
|
205
421
|
// One round = one successful model call + its tool results.
|
|
206
422
|
// The inner loop retries the model call until it succeeds or a budget dies.
|
|
207
423
|
while (true) {
|
|
@@ -240,6 +456,7 @@ opts) {
|
|
|
240
456
|
}
|
|
241
457
|
if (!e.retryable)
|
|
242
458
|
return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
|
|
459
|
+
emit("agent_api_error", { kind: e.kind, attempt: attempts.total + 1 }); // classified, greppable
|
|
243
460
|
attempts.total++; // every failure consumes the overall budget
|
|
244
461
|
attempts.consecutive++; // ...and the breaker counter
|
|
245
462
|
if (e.kind === ApiErrorKind.RateLimited)
|
|
@@ -276,36 +493,47 @@ opts) {
|
|
|
276
493
|
: {}), // omit the field entirely when there were no calls
|
|
277
494
|
});
|
|
278
495
|
if (!out.toolCalls.length) {
|
|
496
|
+
// The model wants to stop. A Stop hook gets the last word: if it exits
|
|
497
|
+
// 2, the agent is NOT done — its stderr becomes a new instruction and
|
|
498
|
+
// the loop continues. This is how you build test-driven AI: a Stop hook
|
|
499
|
+
// runs the tests, blocks while they fail, and the agent keeps fixing.
|
|
500
|
+
// Sub-agents are exempt — Stop hooks are the human's project policy.
|
|
501
|
+
if (!opts.subAgent) {
|
|
502
|
+
const stop = await runHooks("Stop", { finalText: out.content }); // give hooks the last word
|
|
503
|
+
if (stop.block) {
|
|
504
|
+
emit("agent_hook_block", { event: "Stop" }); // the agent was sent back to work
|
|
505
|
+
if (!opts.quiet)
|
|
506
|
+
console.log(chalk.yellow(`\n↩ Stop hook: not done yet — ${stop.feedback.slice(0, 120)}`)); // show why
|
|
507
|
+
messages.push({ role: "user", content: `[Stop hook] You are not finished: ${stop.feedback}` }); // inject the instruction
|
|
508
|
+
break; // exit the inner while → advance the round counter
|
|
509
|
+
}
|
|
510
|
+
}
|
|
279
511
|
return { reason: TerminateReason.Done, finalText: out.content }; // no tool calls = final answer (already streamed to the screen)
|
|
280
512
|
}
|
|
281
|
-
// Execute
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
content = ok
|
|
299
|
-
? await execute(call, opts) // approved — actually run it
|
|
300
|
-
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
content = await execute(call, opts); // allow — run without ceremony
|
|
513
|
+
// Execute the tool calls. Read-only calls (read_file/search) in a
|
|
514
|
+
// contiguous run are executed CONCURRENTLY — they cannot conflict and
|
|
515
|
+
// never need an interactive prompt. Anything that writes or executes runs
|
|
516
|
+
// ALONE and in order: writes can depend on each other, and we can only ask
|
|
517
|
+
// the human one question at a time. This greedy batching mirrors how
|
|
518
|
+
// Claude Code parallelizes safe reads while serializing risky work.
|
|
519
|
+
let i = 0; // index into out.toolCalls
|
|
520
|
+
while (i < out.toolCalls.length) {
|
|
521
|
+
const batch = []; // a run of safe, parallelizable calls
|
|
522
|
+
while (i < out.toolCalls.length && isReadOnlyTool(out.toolCalls[i].name))
|
|
523
|
+
batch.push(out.toolCalls[i++]);
|
|
524
|
+
if (batch.length) {
|
|
525
|
+
// Run the whole read-only batch at once, preserving result order.
|
|
526
|
+
const results = await Promise.all(batch.map((c) => runOneCall(c, opts)));
|
|
527
|
+
for (const r of results)
|
|
528
|
+
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
529
|
+
continue; // back to the top — the next call is non-read-only
|
|
304
530
|
}
|
|
305
|
-
|
|
531
|
+
// A single non-read-only call: gate, maybe ask, execute — all serial.
|
|
532
|
+
const r = await runOneCall(out.toolCalls[i++], opts);
|
|
533
|
+
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
306
534
|
}
|
|
307
535
|
break; // round complete, move to the next one
|
|
308
536
|
}
|
|
309
537
|
}
|
|
310
|
-
|
|
538
|
+
// loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
|
|
311
539
|
}
|