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/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
- export const MAX_ROUNDS = 15; // model→tool round cap per query
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(chalk.blue(` ⎿ sub-agent started: ${description.slice(0, 100)}`)); // show the delegation
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, flagged as sub-agent
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(chalk.blue(" ⎿ sub-agent done")); // close the bracket
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 ? "sub-agent working..." : "thinking...", discardStdin: false }).start(); // discardStdin:false — ora would otherwise eat Ctrl+C
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
- { model: opts.model, messages, tools: opts.subAgent ? toolDefinitions : [...toolDefinitions, taskTool], stream: true }, { signal });
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 🤖 prefix yet?
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🤖 "); // prefix once, then stream raw
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; round <= MAX_ROUNDS; round++) {
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 every tool call, each behind the permission gate.
282
- for (const call of out.toolCalls) {
283
- const indent = opts.subAgent ? chalk.blue(" ⎿ ") : ""; // sub-agent activity is visually nested
284
- if (!opts.quiet)
285
- console.log(indent + chalk.cyan(`🔧 ${call.name}`) + chalk.dim(` ${call.args.slice(0, 120)}`)); // show what the model wants to do
286
- // The permission gate sits between the model's intent and execution.
287
- const v = checkPermission(call.name, call.args);
288
- let content; // what goes back to the model as the tool result
289
- if (v.decision === "deny") {
290
- if (!opts.quiet)
291
- console.log(chalk.red(` ⛔ denied: ${v.reason}`)); // tell the user we blocked it
292
- 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
293
- }
294
- else if (v.decision === "ask") {
295
- const ok = await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}`); // pause and ask the human
296
- if (!ok && !opts.quiet)
297
- console.log(chalk.yellow(" ✋ declined")); // make the refusal visible
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
- messages.push({ role: "tool", tool_call_id: call.id, content }); // feed the result back, paired by id
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
- return { reason: TerminateReason.RoundCap }; // ran out of rounds before a final answer
538
+ // loop is unbounded — other safeguards (circuit breaker, retry budget, compaction guard) still apply
311
539
  }