agent-from-zero 0.34.2 → 0.35.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 +147 -0
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +159 -34
- package/dist/auto-budget.js +15 -0
- package/dist/auto-context.js +125 -0
- package/dist/auto-debug.js +87 -0
- package/dist/auto-providers.js +154 -0
- package/dist/auto-review.js +113 -0
- package/dist/auto.js +295 -0
- package/dist/background.js +2 -1
- package/dist/cli.js +83 -0
- package/dist/clipboard.js +88 -0
- package/dist/completion.js +65 -0
- package/dist/config.js +8 -0
- package/dist/context.js +9 -3
- package/dist/cost.js +4 -0
- package/dist/effort.js +81 -0
- package/dist/follow-up.js +22 -0
- package/dist/form.js +8 -6
- package/dist/images.js +43 -0
- package/dist/ink/activity.js +62 -0
- package/dist/ink/agents.js +24 -0
- package/dist/ink/app.js +384 -171
- package/dist/ink/chat.js +12 -1
- package/dist/ink/commands.js +16 -3
- package/dist/ink/completion.js +43 -0
- package/dist/ink/setup.js +9 -4
- package/dist/ink/sink.js +16 -6
- package/dist/ink/viewport.js +15 -0
- package/dist/loop.js +251 -106
- package/dist/mcp.js +1 -0
- package/dist/menu.js +50 -8
- package/dist/output.js +1 -1
- package/dist/pasted-text.js +32 -0
- package/dist/permissions.js +467 -12
- package/dist/print.js +16 -0
- package/dist/prompt.js +7 -4
- package/dist/status.js +80 -0
- package/dist/team.js +9 -3
- package/dist/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +4 -3
package/dist/loop.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
+
import { AutoReviewBudget } from "./auto-budget.js";
|
|
1
2
|
import chalk from "chalk"; // terminal colors for status lines
|
|
2
|
-
import {
|
|
3
|
+
import { effortMessages, effortParameters, retainsReasoning } from "./effort.js";
|
|
4
|
+
import { toolDefinitions, dispatch, snapshotFileState, restoreFileState, isReadOnlyTool, declaresRecoverable } from "./tools.js"; // the tool manuals + the executor + file-state isolation
|
|
3
5
|
import { classifyError, ApiErrorKind } from "./errors.js"; // failure taxonomy
|
|
4
6
|
import { checkPermission } from "./permissions.js"; // the allow/ask/deny gate
|
|
5
7
|
import { previewChange } from "./diff.js"; // show the diff before a write so approval is informed
|
|
6
8
|
import { estimateHistoryTokens, compactHistory, COMPACT_AT, MAX_COMPACTIONS_PER_QUERY, MAX_COMPACT_FAILURES } from "./context.js"; // context management
|
|
7
9
|
import { SUB_AGENT_PROMPT, TEAMMATE_PROMPT } from "./prompt.js"; // the sub-agent + teammate constitutions
|
|
8
|
-
import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
|
|
10
|
+
import { LEAD, MAX_TEAMMATES, sendMessage, sendProtocol, readInbox, inboxCount, registerTeammate, finishTeammate, teammateExists, teammateCount, createRequest, resolveResponse, setTeammateState, anyTeammateBusy, runningTeammates, markShutdown, shutdownRequestId, resetTeam, listTeammateViews } from "./team.js"; // agent teams (Day 38) + team protocols (Day 39): mailboxes, registry, request/response contracts
|
|
9
11
|
import { createTask, claimTask, completeTask, claimNextAvailable, boardSummary, resetBoard } from "./board.js"; // the shared task board (Day 40): autonomous work claiming
|
|
10
12
|
import { emit } from "./telemetry.js"; // local-only event log (no-op unless the CLI armed it)
|
|
11
13
|
import { runHooks } from "./hooks.js"; // user lifecycle hooks (PreToolUse / PostToolUse / Stop)
|
|
14
|
+
import { reviewHistory } from "./auto-context.js";
|
|
15
|
+
import { AgentProgress } from "./agent-progress.js";
|
|
12
16
|
import { recordUsage } from "./cost.js"; // meter token usage from the stream
|
|
13
17
|
import { mark, thinkingWord, spinnerText } from "./ui.js"; // centralized terminal styling (markers, spinner)
|
|
14
|
-
import { recordToolCall, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
|
|
18
|
+
import { recordToolCall, recordToolResult, recordToolDetail, recordReasoning } from "./tui.js"; // folded tool-call trace (Ctrl+T) + the model's hidden thinking (Ctrl+R)
|
|
15
19
|
import { STDOUT_OUTPUT } from "./output.js"; // where the loop's screen output goes (stdout by default, Ink REPL passes its own)
|
|
16
20
|
import { todoNag, getTodos, renderTodos } from "./todos.js"; // the agent's plan: show it on screen + nag when it goes stale
|
|
17
21
|
import { pendingNotifications } from "./background.js"; // background tasks (Day 37): surface finished jobs as a turn
|
|
@@ -27,12 +31,14 @@ const STALL_WARN_MS = 30_000; // events arriving slowly → log only. Slow is no
|
|
|
27
31
|
// user-facing explanation and exit code, instead of a generic "error".
|
|
28
32
|
export var TerminateReason;
|
|
29
33
|
(function (TerminateReason) {
|
|
34
|
+
TerminateReason["ReviewLimit"] = "review_limit";
|
|
30
35
|
TerminateReason["Done"] = "done";
|
|
31
36
|
TerminateReason["CircuitBreaker"] = "circuit_breaker";
|
|
32
37
|
TerminateReason["RetryBudgetExhausted"] = "retry_budget_exhausted";
|
|
33
38
|
TerminateReason["RateLimitBudgetExhausted"] = "rate_limit_budget_exhausted";
|
|
34
39
|
TerminateReason["ContextTooLong"] = "context_too_long";
|
|
35
40
|
TerminateReason["CompactionFailed"] = "compaction_failed";
|
|
41
|
+
TerminateReason["ImageInputRejected"] = "image_input_rejected";
|
|
36
42
|
TerminateReason["FatalApiError"] = "fatal_api_error";
|
|
37
43
|
TerminateReason["UserInterrupt"] = "user_interrupt";
|
|
38
44
|
})(TerminateReason || (TerminateReason = {}));
|
|
@@ -304,8 +310,6 @@ function injectSubAgentResults(messages, opts) {
|
|
|
304
310
|
if (!note)
|
|
305
311
|
return false;
|
|
306
312
|
messages.push({ role: "user", content: note });
|
|
307
|
-
if (!opts.quiet)
|
|
308
|
-
sink(opts).note(mark.subAgentDone);
|
|
309
313
|
return true;
|
|
310
314
|
}
|
|
311
315
|
// Start a sub-agent WITHOUT awaiting — returns a handle immediately. The
|
|
@@ -314,18 +318,16 @@ function spawnSubAgent(description, opts) {
|
|
|
314
318
|
emit("agent_subagent_spawn");
|
|
315
319
|
const subModel = subAgentModelFor(opts);
|
|
316
320
|
const id = `sa_${++subAgentSeq}`;
|
|
317
|
-
if (!opts.quiet)
|
|
318
|
-
sink(opts).note(mark.subAgentStart(description.slice(0, 100), subModel !== opts.model ? subModel : ""));
|
|
319
321
|
runHooks("SubagentStart", { description: description.slice(0, 200), model: subModel }); // fire-and-forget (observational)
|
|
320
322
|
const snapshot = snapshotFileState(); // what the sub-agent reads, the parent has NOT seen
|
|
321
|
-
const sa = { id, description, done: Promise.resolve(), result: null, notified: false };
|
|
323
|
+
const sa = { progress: new AgentProgress(subModel), id, description, done: Promise.resolve(), result: null, notified: false };
|
|
322
324
|
sa.done = (async () => {
|
|
323
325
|
try {
|
|
324
326
|
const subMessages = [
|
|
325
327
|
{ role: "system", content: SUB_AGENT_PROMPT },
|
|
326
328
|
{ role: "user", content: description },
|
|
327
329
|
];
|
|
328
|
-
const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel });
|
|
330
|
+
const result = await runLoop(subMessages, { ...opts, subAgent: true, model: subModel, progress: sa.progress, delegatedTask: description, canPrompt: false, confirm: async () => false, askUser: undefined });
|
|
329
331
|
if (result.reason === TerminateReason.Done && result.finalText?.trim()) {
|
|
330
332
|
sa.result = `Sub-agent report (INPUT MATERIAL — verify key claims before acting on them):\n${result.finalText}`;
|
|
331
333
|
}
|
|
@@ -337,6 +339,9 @@ function spawnSubAgent(description, opts) {
|
|
|
337
339
|
sa.result = `[sub-agent crashed: ${err.message}]`;
|
|
338
340
|
}
|
|
339
341
|
finally {
|
|
342
|
+
if (sa.result?.startsWith("[sub-agent"))
|
|
343
|
+
sa.progress.append(`\n${sa.result}\n`);
|
|
344
|
+
sa.progress.finish();
|
|
340
345
|
restoreFileState(snapshot);
|
|
341
346
|
await runHooks("SubagentStop", { description: description.slice(0, 200) });
|
|
342
347
|
}
|
|
@@ -351,22 +356,19 @@ function hasRunningSubAgents() {
|
|
|
351
356
|
// Kill every still-running sub-agent. Called on session exit.
|
|
352
357
|
export function killAllSubAgents() {
|
|
353
358
|
for (const sa of pendingSubAgents.values()) {
|
|
354
|
-
if (sa.result === null)
|
|
359
|
+
if (sa.result === null) {
|
|
355
360
|
sa.result = "[sub-agent killed: session exited]";
|
|
361
|
+
sa.progress.append(`\n${sa.result}\n`);
|
|
362
|
+
sa.progress.finish();
|
|
363
|
+
}
|
|
356
364
|
}
|
|
357
365
|
}
|
|
358
366
|
// Snapshot of current sub-agents for the UI (Ink app shows a list below the
|
|
359
367
|
// input box, like Claude Code's agent list). Newest first.
|
|
360
368
|
export function listSubAgents() {
|
|
361
|
-
return [...pendingSubAgents.values()]
|
|
362
|
-
.reverse()
|
|
363
|
-
.map((sa) => ({
|
|
364
|
-
id: sa.id,
|
|
365
|
-
description: sa.description.slice(0, 80),
|
|
366
|
-
status: sa.result === null ? "running" : sa.result.startsWith("[sub-agent failed") || sa.result.startsWith("[sub-agent crashed") || sa.result.startsWith("[sub-agent killed") ? "failed" : "done",
|
|
367
|
-
result: sa.result ?? undefined,
|
|
368
|
-
}));
|
|
369
|
+
return [...pendingSubAgents.values()].map((sa) => sa.progress.view(sa.id, sa.id, sa.description, sa.result === null ? "running" : /^\[sub-agent (failed|crashed|killed)/.test(sa.result) ? "failed" : "done"));
|
|
369
370
|
}
|
|
371
|
+
export function listAgentViews() { return [...listSubAgents(), ...listTeammateViews()]; }
|
|
370
372
|
// Exponential backoff with jitter: 500ms, 1s, 2s, ... ±25%, capped.
|
|
371
373
|
// Without jitter, every client that failed at the same second retries at the
|
|
372
374
|
// same second — a thundering herd against an already-struggling server.
|
|
@@ -390,15 +392,21 @@ opts) {
|
|
|
390
392
|
const signal = AbortSignal.any([opts.signal, idleAbort.signal]); // either the user or the watchdog can abort
|
|
391
393
|
const word = thinkingWord(modelCallSeq++); // a rotating "thinking" word for this call
|
|
392
394
|
const startedAt = Date.now(); // for the spinner's live elapsed counter
|
|
395
|
+
let reportedTokens;
|
|
396
|
+
if (opts.progress) {
|
|
397
|
+
opts.progress.activity = "Thinking";
|
|
398
|
+
opts.progress.liveTokens = 0;
|
|
399
|
+
}
|
|
393
400
|
let streamedChars = 0; // bytes of content+reasoning streamed this call → a live token estimate
|
|
394
401
|
// Reasoning is COLLAPSED by default: while the model thinks, the spinner keeps
|
|
395
402
|
// spinning (with a 💭 hint) instead of dumping the trace; we stash it for Ctrl+R.
|
|
396
403
|
let reasoningBuf = ""; // the accumulated thinking, hidden behind the spinner
|
|
397
404
|
let reasoningActive = false; // true while reasoning tokens are arriving → spinner shows 💭
|
|
398
|
-
let reasoningShown = false; //
|
|
399
|
-
const spinner = opts.quiet
|
|
405
|
+
let reasoningShown = false; // has this trace been stored for Ctrl+R?
|
|
406
|
+
const spinner = opts.quiet || opts.subAgent
|
|
400
407
|
? null // the eval harness wants silence
|
|
401
|
-
: sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent
|
|
408
|
+
: sink(opts).spinner(spinnerText(word, 0, !!opts.subAgent)); // a fresh spinner from the sink (stdout → ora; Ink → React state)
|
|
409
|
+
let ans = null; // always close the live renderer, including interrupted streams
|
|
402
410
|
let lastEvent = Date.now(); // when did we last hear ANYTHING from the stream?
|
|
403
411
|
let stallWarned = false; // only warn once per quiet stretch
|
|
404
412
|
// The two-level watchdog, checked once per second:
|
|
@@ -409,7 +417,7 @@ opts) {
|
|
|
409
417
|
// While the model is reasoning, prefix the spinner with 💭 so "it's thinking"
|
|
410
418
|
// reads differently from "it's waiting on the first token".
|
|
411
419
|
if (spinner?.spinning)
|
|
412
|
-
spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent,
|
|
420
|
+
spinner.set((reasoningActive ? chalk.dim("💭 ") : "") + spinnerText(word, Math.floor((Date.now() - startedAt) / 1000), !!opts.subAgent, Math.round(streamedChars / 4)));
|
|
413
421
|
const quietMs = Date.now() - lastEvent; // ms since the last event
|
|
414
422
|
if (quietMs > IDLE_TIMEOUT_MS) {
|
|
415
423
|
emit("agent_watchdog_idle"); // record the cut — these should be rare
|
|
@@ -431,53 +439,48 @@ opts) {
|
|
|
431
439
|
// The tool manual depends on the agent kind (Lead / teammate / sub-agent)
|
|
432
440
|
// — see toolsFor(). This is also where nested spawning is prevented: a
|
|
433
441
|
// teammate's manual simply omits spawn_teammate and task.
|
|
434
|
-
{ model: opts.model, messages, tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
442
|
+
{ model: opts.model, messages: effortMessages(opts.model, messages), ...effortParameters(opts.model), tools: toolsFor(opts), stream: true, stream_options: { include_usage: true } }, { signal });
|
|
435
443
|
let content = ""; // accumulated answer text
|
|
436
|
-
let ans = null; // renders the streamed answer (stdout: markdown block by block; Ink: React state) — non-null once the answer starts
|
|
437
444
|
const calls = []; // tool calls under assembly, indexed by delta.index
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
// shortcut to reveal it. Idempotent + collapses to nothing for sub-agents and
|
|
441
|
-
// the eval harness (they never show thinking).
|
|
445
|
+
// Stash thinking without appending one indicator per model round. The live
|
|
446
|
+
// status keeps animating while tool arguments or answer tokens arrive.
|
|
442
447
|
const flushReasoning = () => {
|
|
443
448
|
if (!reasoningBuf || reasoningShown)
|
|
444
449
|
return;
|
|
445
450
|
reasoningShown = true;
|
|
446
451
|
reasoningActive = false;
|
|
447
|
-
if (spinner?.spinning)
|
|
448
|
-
spinner.stop();
|
|
449
452
|
if (opts.quiet || opts.subAgent)
|
|
450
453
|
return; // no UI for silent / nested runs
|
|
451
454
|
recordReasoning(reasoningBuf); // keep it for Ctrl+R (never goes into history)
|
|
452
|
-
const secs = Math.round((Date.now() - startedAt) / 1000);
|
|
453
|
-
const toks = Math.round(reasoningBuf.length / 4); // same ~chars/4 estimate as the spinner
|
|
454
|
-
const tokStr = toks >= 1000 ? `${(toks / 1000).toFixed(1)}k` : `${toks}`;
|
|
455
|
-
sink(opts).reasoning(chalk.dim(`💭 thought for ${secs}s (~${tokStr} tokens) · Ctrl+R to view`));
|
|
456
455
|
};
|
|
457
456
|
for await (const chunk of stream) {
|
|
458
457
|
lastEvent = Date.now(); // feed the watchdog
|
|
459
458
|
stallWarned = false; // the stream spoke — reset the stall warning
|
|
460
|
-
if (chunk.usage)
|
|
461
|
-
recordUsage(chunk.usage);
|
|
459
|
+
if (chunk.usage) {
|
|
460
|
+
recordUsage(chunk.usage);
|
|
461
|
+
if (typeof chunk.usage.completion_tokens === "number")
|
|
462
|
+
reportedTokens = chunk.usage.completion_tokens;
|
|
463
|
+
} // the final usage chunk — meter it
|
|
462
464
|
const delta = chunk.choices[0]?.delta; // this chunk's increment
|
|
463
465
|
if (!delta)
|
|
464
466
|
continue; // keep-alive or usage chunk — nothing to do
|
|
465
467
|
// Reasoning models (e.g. deepseek-reasoner / R1) stream their thinking in a
|
|
466
468
|
// separate `reasoning_content` field BEFORE the answer. We do NOT print it
|
|
467
|
-
// (it buries the actual reply)
|
|
468
|
-
//
|
|
469
|
-
// surface it only behind Ctrl+R.
|
|
469
|
+
// (it buries the actual reply). Provider capabilities determine whether it
|
|
470
|
+
// must be retained for tool-call continuation; the UI stays collapsed.
|
|
470
471
|
const reasoning = delta.reasoning_content;
|
|
471
472
|
if (reasoning) {
|
|
472
473
|
streamedChars += reasoning.length; // count reasoning toward the live token estimate
|
|
474
|
+
if (opts.progress)
|
|
475
|
+
opts.progress.liveTokens = Math.ceil(streamedChars / 4);
|
|
473
476
|
reasoningBuf += reasoning; // stash it for Ctrl+R instead of streaming it
|
|
474
477
|
reasoningActive = true; // the spinner now shows 💭 (set by the watchdog)
|
|
475
478
|
}
|
|
476
479
|
if (delta.content) {
|
|
477
|
-
flushReasoning();
|
|
480
|
+
flushReasoning();
|
|
478
481
|
streamedChars += delta.content.length; // count the answer toward the live token estimate
|
|
479
|
-
if (spinner?.spinning)
|
|
480
|
-
spinner.stop();
|
|
482
|
+
if (spinner?.spinning && !sink(opts).streamingStatus)
|
|
483
|
+
spinner.stop();
|
|
481
484
|
if (!opts.quiet && !opts.subAgent) {
|
|
482
485
|
// Only the top-level agent streams to the screen — a sub-agent's
|
|
483
486
|
// inner monologue would be mistaken for the answer. The sink decides
|
|
@@ -488,28 +491,37 @@ opts) {
|
|
|
488
491
|
ans = sink(opts).answer(); // first token: start the answer renderer
|
|
489
492
|
ans.push(delta.content); // hand the token to the renderer (it decides when to paint)
|
|
490
493
|
}
|
|
494
|
+
if (opts.progress) {
|
|
495
|
+
opts.progress.activity = "Writing";
|
|
496
|
+
opts.progress.append(delta.content);
|
|
497
|
+
opts.progress.liveTokens = Math.ceil(streamedChars / 4);
|
|
498
|
+
}
|
|
491
499
|
content += delta.content; // always keep it for the history (the answer only, never the reasoning)
|
|
492
500
|
}
|
|
493
501
|
if (delta.tool_calls?.length)
|
|
494
|
-
flushReasoning();
|
|
502
|
+
flushReasoning();
|
|
495
503
|
for (const tc of delta.tool_calls ?? []) {
|
|
496
|
-
if (spinner?.spinning)
|
|
497
|
-
spinner.stop(); // tool call starting — spinner served its purpose
|
|
498
504
|
const slot = (calls[tc.index] ??= { id: "", name: "", args: "" }); // create the slot on first fragment
|
|
499
505
|
if (tc.id)
|
|
500
506
|
slot.id = tc.id; // id arrives once
|
|
501
507
|
if (tc.function?.name)
|
|
502
508
|
slot.name += tc.function.name; // name usually arrives whole; += is safe either way
|
|
509
|
+
streamedChars += tc.function?.arguments?.length ?? 0;
|
|
510
|
+
if (opts.progress)
|
|
511
|
+
opts.progress.liveTokens = Math.ceil(streamedChars / 4);
|
|
503
512
|
if (tc.function?.arguments)
|
|
504
513
|
slot.args += tc.function.arguments; // arguments stream in fragments — concatenate
|
|
505
514
|
}
|
|
506
515
|
}
|
|
507
|
-
flushReasoning();
|
|
508
|
-
|
|
509
|
-
ans.end(); // flush the final (un-terminated) block + end the line cleanly (the sink owns the trailing newline)
|
|
510
|
-
return { content, toolCalls: calls.filter(Boolean) }; // sparse array → dense
|
|
516
|
+
flushReasoning();
|
|
517
|
+
return { content, toolCalls: calls.filter(Boolean), ...(retainsReasoning(opts.model) ? { reasoning: reasoningBuf } : {}) };
|
|
511
518
|
}
|
|
512
519
|
finally {
|
|
520
|
+
ans?.end(); // cancel delayed repaints even when the API throws or is interrupted
|
|
521
|
+
if (opts.progress) {
|
|
522
|
+
opts.progress.finishModelCall(reportedTokens);
|
|
523
|
+
opts.progress.append("\n");
|
|
524
|
+
}
|
|
513
525
|
clearInterval(watchdog); // always stop the timer
|
|
514
526
|
if (spinner?.spinning)
|
|
515
527
|
spinner.stop(); // and never leave a zombie spinner
|
|
@@ -527,38 +539,105 @@ function agentIndent(opts) {
|
|
|
527
539
|
// Promise.all batch; calls that can prompt must be awaited one at a time.
|
|
528
540
|
async function runOneCall(call, opts) {
|
|
529
541
|
const indent = agentIndent(opts);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
542
|
+
const trace = !opts.quiet && !opts.subAgent ? recordToolCall(indent + mark.tool(call.name, call.args.length > 300 ? call.args.slice(0, 297) + "..." : call.args), `tool: ${call.name}\nargs: ${call.args}`, call) : undefined;
|
|
543
|
+
if (opts.progress) {
|
|
544
|
+
opts.progress.toolCalls++;
|
|
545
|
+
opts.progress.activity = call.name;
|
|
546
|
+
opts.progress.append(`\n[${call.name}]\nargs: ${call.args}\n`);
|
|
547
|
+
}
|
|
548
|
+
const started = Date.now();
|
|
549
|
+
const status = () => `${call.name}… (${Math.floor((Date.now() - started) / 1000)}s)`;
|
|
550
|
+
const spinner = !opts.quiet && !opts.subAgent ? sink(opts).spinner(status()) : null;
|
|
551
|
+
const timer = spinner ? setInterval(() => spinner.set(status()), 1000) : undefined;
|
|
552
|
+
emit("agent_tool_call", { tool: call.name });
|
|
553
|
+
try {
|
|
554
|
+
const refusal = await authorizeCall(call, opts);
|
|
555
|
+
const content = refusal ?? await runWithHooks(call, opts);
|
|
556
|
+
if (!/^\[(?:permission|hook|error|follow-up)\]/.test(content) && !["task", "spawn_teammate", "send_message", "ask_user"].includes(call.name))
|
|
557
|
+
opts.reviewBudget?.executed();
|
|
558
|
+
if (trace)
|
|
559
|
+
recordToolResult(trace, content);
|
|
560
|
+
opts.progress?.append(`result: ${content}\n`);
|
|
561
|
+
if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
|
|
562
|
+
sink(opts).note(renderTodos(getTodos()));
|
|
563
|
+
}
|
|
564
|
+
return { id: call.id, content };
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (trace)
|
|
568
|
+
recordToolResult(trace, `[error] ${error instanceof Error ? error.message : String(error)}`);
|
|
569
|
+
throw error;
|
|
537
570
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
571
|
+
finally {
|
|
572
|
+
clearInterval(timer);
|
|
573
|
+
spinner?.stop();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
// null grants this exact call; any returned text is a refusal for the model.
|
|
577
|
+
// Hook rewrites use the same gate, so approval of one input cannot grant a
|
|
578
|
+
// different action. There is no recursive hook invocation on re-authorization.
|
|
579
|
+
async function authorizeCall(call, opts) {
|
|
580
|
+
if (opts.signal.aborted || opts.isInterrupted())
|
|
581
|
+
return "[permission] Interrupted before execution.";
|
|
582
|
+
if (opts.reviewBudget?.exhausted)
|
|
583
|
+
return "[permission] Review limit reached. Stop and wait for human direction; this action was not executed.";
|
|
584
|
+
const auto = opts.autoMode?.enabled ?? false;
|
|
585
|
+
const v = checkPermission(call.name, call.args, auto);
|
|
586
|
+
// Auto-mode fast path: calls a reviewer would otherwise see, allowed because
|
|
587
|
+
// they are recoverable or pre-granted. Counted so the report shows the saving.
|
|
588
|
+
if (auto && v.decision === "allow" && (["run_bash", "run_bash_background", "write_file", "edit_file"].includes(call.name) || call.name.startsWith("mcp__"))) {
|
|
589
|
+
emit("agent_auto_skipped", { tool: call.name, reason: v.reason });
|
|
548
590
|
}
|
|
549
|
-
let content; // what goes back to the model as the tool result
|
|
550
591
|
if (v.decision === "deny") {
|
|
592
|
+
if (auto && opts.autoMode?.policy === "rules")
|
|
593
|
+
opts.reviewBudget?.deny();
|
|
551
594
|
emit("agent_tool_denied", { tool: call.name }); // hard blocks, per tool
|
|
552
595
|
if (!opts.quiet)
|
|
553
596
|
sink(opts).note(mark.denied(v.reason)); // tell the user we blocked it
|
|
554
|
-
|
|
597
|
+
return `[permission] Denied: ${v.reason}. This is a hard rule — do not try to work around it; pick a different approach or ask the user.`;
|
|
555
598
|
}
|
|
556
599
|
else if (v.decision === "ask") {
|
|
557
600
|
// Optional LLM judge: for a run_bash command the rules couldn't classify,
|
|
558
601
|
// ask the judge first. It can only DOWNGRADE ask→allow for the clearly
|
|
559
602
|
// safe; anything else still goes to the human. The judge never sees a deny.
|
|
560
603
|
let autoAllowed = false;
|
|
561
|
-
|
|
604
|
+
let reviewReason = "";
|
|
605
|
+
let reviewId = ""; // set when auto review ran; joins the human's answer to that review
|
|
606
|
+
// Auto mode's line is recoverability: a tool its server declares read-only
|
|
607
|
+
// or merely additive loses nothing, so it runs without review (plan mode
|
|
608
|
+
// already denied above).
|
|
609
|
+
const recoverable = auto && !v.requiresHuman ? declaresRecoverable(call.name) : null;
|
|
610
|
+
if (recoverable) {
|
|
611
|
+
autoAllowed = true;
|
|
612
|
+
emit("agent_auto_skipped", { tool: call.name, reason: recoverable });
|
|
613
|
+
if (!opts.quiet)
|
|
614
|
+
recordToolDetail(call.id, `${recoverable[0].toUpperCase()}${recoverable.slice(1)}; no review needed`);
|
|
615
|
+
// Exiting plan mode always belongs to the human, never to a classifier.
|
|
616
|
+
}
|
|
617
|
+
else if (!v.requiresHuman && opts.autoMode && (auto ? call.name !== "exit_plan_mode" : !!opts.judge && call.name === "run_bash")) {
|
|
618
|
+
const definition = toolsFor(opts).find((t) => t.type === "function" && t.function.name === call.name);
|
|
619
|
+
const description = definition?.type === "function" ? definition.function.description : undefined;
|
|
620
|
+
const review = await opts.autoMode.classify(call.name, call.args, opts.autoRequests ?? [], opts.signal, {
|
|
621
|
+
description, history: opts.autoHistory, delegatedTask: opts.delegatedTask,
|
|
622
|
+
notify: (message) => { if (!opts.quiet)
|
|
623
|
+
sink(opts).note(chalk.yellow(` ⎿ ${message}`)); },
|
|
624
|
+
});
|
|
625
|
+
if (opts.signal.aborted || opts.isInterrupted())
|
|
626
|
+
return "[permission] Interrupted before execution.";
|
|
627
|
+
reviewId = review.reviewId ?? "";
|
|
628
|
+
if (review.decision === "deny") {
|
|
629
|
+
opts.reviewBudget?.deny();
|
|
630
|
+
emit("agent_tool_auto_denied", { tool: call.name, rules: (review.ruleIds ?? []).join(","), ...(reviewId ? { reviewId } : {}) });
|
|
631
|
+
if (!opts.quiet)
|
|
632
|
+
recordToolDetail(call.id, review.reason);
|
|
633
|
+
return `[permission] Auto-denied (not a user refusal): ${review.reason}. Not executed. Choose a permitted approach or report the restriction. Do not retry the same prohibited effect through another tool, wrapper or worker.`;
|
|
634
|
+
}
|
|
635
|
+
autoAllowed = review.decision === "allow";
|
|
636
|
+
reviewReason = review.reason;
|
|
637
|
+
if (!opts.quiet)
|
|
638
|
+
recordToolDetail(call.id, review.reason);
|
|
639
|
+
}
|
|
640
|
+
else if (!auto && opts.judge && call.name === "run_bash") {
|
|
562
641
|
let cmd = "";
|
|
563
642
|
try {
|
|
564
643
|
cmd = JSON.parse(call.args).command ?? "";
|
|
@@ -567,27 +646,38 @@ async function runOneCall(call, opts) {
|
|
|
567
646
|
if (cmd && (await opts.judge.classify(cmd)) === "allow") {
|
|
568
647
|
autoAllowed = true;
|
|
569
648
|
if (!opts.quiet)
|
|
570
|
-
|
|
649
|
+
recordToolDetail(call.id, "Model judge auto-approved");
|
|
571
650
|
}
|
|
572
651
|
}
|
|
573
|
-
|
|
652
|
+
if (opts.signal.aborted || opts.isInterrupted())
|
|
653
|
+
return "[permission] Interrupted before execution.";
|
|
654
|
+
// An unattended callback may contain the old blanket auto-approve policy.
|
|
655
|
+
// Auto mode never uses that policy as a fallback when Jev did not approve.
|
|
656
|
+
// A diff is needed when the human is deciding; automatic edits stay in
|
|
657
|
+
// the collapsed tool trace and remain available through /diff.
|
|
658
|
+
if (!autoAllowed && !opts.quiet && (call.name === "write_file" || call.name === "edit_file")) {
|
|
659
|
+
const preview = previewChange(call.name, call.args);
|
|
660
|
+
if (preview)
|
|
661
|
+
sink(opts).note(preview.replace(/^/gm, agentIndent(opts)));
|
|
662
|
+
}
|
|
663
|
+
const ok = autoAllowed || (!(auto && opts.canPrompt === false) && await opts.confirm(`${call.name} (${v.reason}):\n ${v.summary}${reviewReason ? `\n ${reviewReason}` : ""}`, call.name));
|
|
664
|
+
// Every human prompt in auto mode is an interruption worth counting, with
|
|
665
|
+
// where it came from: the reviewer asked ("review", joinable by reviewId),
|
|
666
|
+
// or a rule required a human without review ("requires_human"). Approved is
|
|
667
|
+
// a likely false block, declined a correct one; "unattended" reached no one.
|
|
668
|
+
if (auto && !autoAllowed)
|
|
669
|
+
emit("agent_auto_human", { tool: call.name, source: reviewId ? "review" : v.requiresHuman ? "requires_human" : "no_review",
|
|
670
|
+
decision: ok ? "approved" : opts.canPrompt === false ? "unattended" : "declined", ...(reviewId ? { reviewId } : {}) });
|
|
574
671
|
if (!ok)
|
|
575
|
-
emit("agent_tool_declined", { tool: call.name }); // the human said no — that is signal
|
|
672
|
+
emit("agent_tool_declined", { tool: call.name, ...(reviewId ? { reviewId } : {}) }); // the human said no — that is signal
|
|
576
673
|
if (!ok && !opts.quiet)
|
|
577
674
|
sink(opts).note(mark.declined); // make the refusal visible
|
|
578
|
-
|
|
579
|
-
?
|
|
580
|
-
: `[permission] The user declined this action. Ask them how to proceed, or choose a safer alternative.`; // declined — tell the model
|
|
581
|
-
}
|
|
582
|
-
else {
|
|
583
|
-
content = await runWithHooks(call, opts); // allow — run it (PreToolUse can still block)
|
|
584
|
-
}
|
|
585
|
-
// todo_write's whole point is the VISIBLE plan: the model got a one-line tally,
|
|
586
|
-
// the human gets the rendered checklist (quiet runs — eval/sub-agents — stay silent).
|
|
587
|
-
if (call.name === "todo_write" && !opts.quiet && !content.startsWith("[error]")) {
|
|
588
|
-
sink(opts).note(renderTodos(getTodos()));
|
|
675
|
+
if (!ok)
|
|
676
|
+
return `[permission] Action not approved${reviewReason ? `: ${reviewReason}` : ". The user declined this action"}. Ask the user how to proceed, or choose a safer alternative.`;
|
|
589
677
|
}
|
|
590
|
-
|
|
678
|
+
if (opts.reviewBudget?.exhausted)
|
|
679
|
+
return "[permission] Review limit reached; not executed. Wait for human direction.";
|
|
680
|
+
return opts.signal.aborted || opts.isInterrupted() ? "[permission] Interrupted before execution." : null;
|
|
591
681
|
}
|
|
592
682
|
// Run an approved tool call, surrounded by the user's lifecycle hooks.
|
|
593
683
|
// PreToolUse can block (exit 2) — its stderr is fed back to the model as the
|
|
@@ -597,6 +687,8 @@ async function runOneCall(call, opts) {
|
|
|
597
687
|
// wrote"). Sub-agents skip hooks: hooks are about the human's project policy,
|
|
598
688
|
// not internal delegation.
|
|
599
689
|
async function runWithHooks(call, opts) {
|
|
690
|
+
if (opts.reviewBudget?.exhausted)
|
|
691
|
+
return "[permission] Review limit reached; not executed.";
|
|
600
692
|
if (opts.subAgent)
|
|
601
693
|
return execute(call, opts); // sub-agents run hook-free
|
|
602
694
|
const pre = await runHooks("PreToolUse", { tool: call.name, args: call.args }); // before
|
|
@@ -607,19 +699,26 @@ async function runWithHooks(call, opts) {
|
|
|
607
699
|
return `[hook] A PreToolUse hook blocked this call: ${pre.feedback}. Treat this as a hard boundary — adjust your approach.`;
|
|
608
700
|
}
|
|
609
701
|
// A PreToolUse hook may REWRITE the arguments (e.g. add a commit trailer). The
|
|
610
|
-
// permission gate
|
|
611
|
-
//
|
|
612
|
-
if (pre.rewrite) {
|
|
702
|
+
// permission gate ran on the original args. A rewrite is a NEW action and
|
|
703
|
+
// must pass the gate again; hooks cannot turn an approved read into a write.
|
|
704
|
+
if (pre.rewrite && pre.rewrite !== call.args) {
|
|
613
705
|
try {
|
|
614
706
|
JSON.parse(pre.rewrite); // must be valid JSON args
|
|
615
707
|
if (!opts.quiet)
|
|
616
|
-
|
|
708
|
+
recordToolDetail(call.id, `PreToolUse rewrote args: ${pre.rewrite}`);
|
|
617
709
|
call = { ...call, args: pre.rewrite };
|
|
618
710
|
}
|
|
619
711
|
catch {
|
|
620
712
|
/* malformed rewrite — ignore, run the original args */
|
|
621
713
|
}
|
|
714
|
+
const refusal = await authorizeCall(call, opts);
|
|
715
|
+
if (refusal)
|
|
716
|
+
return refusal;
|
|
622
717
|
}
|
|
718
|
+
if (opts.signal.aborted || opts.isInterrupted())
|
|
719
|
+
return "[permission] Interrupted before execution.";
|
|
720
|
+
if (opts.reviewBudget?.exhausted)
|
|
721
|
+
return "[permission] Review limit reached; not executed.";
|
|
623
722
|
const result = await execute(call, opts); // the actual work
|
|
624
723
|
const post = await runHooks("PostToolUse", { tool: call.name, args: call.args, result }); // after
|
|
625
724
|
if (post.block) {
|
|
@@ -871,10 +970,9 @@ async function runSpawnTeammate(call, opts) {
|
|
|
871
970
|
// concurrent. It progresses whenever the event loop is free (e.g. while the
|
|
872
971
|
// lead awaits its own API stream). We keep the promise so the lead and
|
|
873
972
|
// shutdown know when it has ended.
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
sink(opts).note(mark.teammateStart(name, role));
|
|
973
|
+
const progress = new AgentProgress(opts.model);
|
|
974
|
+
const done = runTeammate(name, role, task, opts, progress);
|
|
975
|
+
registerTeammate(name, role, done, progress);
|
|
878
976
|
return `Teammate "${name}" (${role}) spawned and is now working in parallel. It will message you (as "lead") with progress and its final result. Reply with send_message; keep coordinating the rest of the team. Do not wait idly — continue your own work.`;
|
|
879
977
|
}
|
|
880
978
|
// Run one teammate to completion: a fresh conversation seeded with its task, a
|
|
@@ -882,7 +980,7 @@ async function runSpawnTeammate(call, opts) {
|
|
|
882
980
|
// a `result` message back to the lead — so the lead always learns the outcome,
|
|
883
981
|
// success or failure. The teammate's file read-state is isolated by snapshot,
|
|
884
982
|
// exactly like a sub-agent (it reads things the lead's conversation never saw).
|
|
885
|
-
async function runTeammate(name, role, task, opts) {
|
|
983
|
+
async function runTeammate(name, role, task, opts, progress) {
|
|
886
984
|
emit("agent_teammate_spawn");
|
|
887
985
|
await runHooks("SubagentStart", { description: `teammate ${name}: ${role}`.slice(0, 200), model: opts.model });
|
|
888
986
|
const snapshot = snapshotFileState();
|
|
@@ -903,8 +1001,11 @@ async function runTeammate(name, role, task, opts) {
|
|
|
903
1001
|
...opts,
|
|
904
1002
|
subAgent: true,
|
|
905
1003
|
teammate: { name },
|
|
1004
|
+
progress,
|
|
1005
|
+
delegatedTask: task,
|
|
906
1006
|
maxRounds: TEAMMATE_MAX_ROUNDS,
|
|
907
1007
|
confirm: teammateConfirm(name, sink(opts)),
|
|
1008
|
+
canPrompt: false,
|
|
908
1009
|
askUser: undefined, // teammates have no line to the human
|
|
909
1010
|
});
|
|
910
1011
|
// How it ended decides what the lead hears. A shutdown handshake gets a
|
|
@@ -917,8 +1018,6 @@ async function runTeammate(name, role, task, opts) {
|
|
|
917
1018
|
else
|
|
918
1019
|
sendMessage(name, LEAD, summary, "result");
|
|
919
1020
|
finishTeammate(name, result.reason === TerminateReason.Done);
|
|
920
|
-
if (!opts.quiet)
|
|
921
|
-
sink(opts).note(mark.teammateDone(name, result.reason === TerminateReason.Done));
|
|
922
1021
|
}
|
|
923
1022
|
catch (err) {
|
|
924
1023
|
// A teammate must never take the whole process down. Report the failure to
|
|
@@ -1028,10 +1127,7 @@ function injectBackgroundNotifications(messages, opts) {
|
|
|
1028
1127
|
const note = pendingNotifications(); // "" unless a task finished since we last checked
|
|
1029
1128
|
if (!note)
|
|
1030
1129
|
return false;
|
|
1031
|
-
const count = (note.match(/<task_notification>/g) ?? []).length; // for the one-line on-screen mark
|
|
1032
1130
|
messages.push({ role: "user", content: note }); // the model reacts to it on the next round
|
|
1033
|
-
if (!opts.quiet)
|
|
1034
|
-
sink(opts).note(mark.bgNote(count));
|
|
1035
1131
|
return true;
|
|
1036
1132
|
}
|
|
1037
1133
|
// Consume this agent's mailbox into its conversation, ROUTING protocol messages
|
|
@@ -1131,6 +1227,8 @@ async function continueForTeam(messages, opts) {
|
|
|
1131
1227
|
return false; // a plain sub-agent has no team
|
|
1132
1228
|
// The lead: wait for the team to act rather than spinning or ending early.
|
|
1133
1229
|
while (inboxCount(LEAD) === 0 && anyTeammateBusy() && !opts.isInterrupted()) {
|
|
1230
|
+
if (opts.followUps?.size)
|
|
1231
|
+
return true;
|
|
1134
1232
|
await interruptibleSleep(TEAM_POLL_MS, opts.isInterrupted); // yield to the teammates; they message us when they have something
|
|
1135
1233
|
}
|
|
1136
1234
|
return consumeInbox(messages, opts); // process whatever is waiting; false if the team is quiescent (→ the lead may finish)
|
|
@@ -1164,6 +1262,39 @@ async function shutdownTeam(opts) {
|
|
|
1164
1262
|
// for a named reason or terminates for a named reason — nothing implicit.
|
|
1165
1263
|
export async function runLoop(messages, // conversation history (mutated in place)
|
|
1166
1264
|
opts) {
|
|
1265
|
+
// Children inherit this snapshot. Later human turns cannot silently grant
|
|
1266
|
+
// broader authorization to already-running background work.
|
|
1267
|
+
opts = { ...opts, reviewBudget: opts.reviewBudget ?? new AutoReviewBudget(), autoRequests: opts.autoRequests ?? opts.autoMode?.snapshot(), followUps: opts.subAgent ? undefined : opts.followUps };
|
|
1268
|
+
const recordHumanInput = (text) => {
|
|
1269
|
+
opts.autoMode?.recordRequest(text);
|
|
1270
|
+
opts.autoRequests = [...(opts.autoRequests ?? []), text];
|
|
1271
|
+
};
|
|
1272
|
+
// Capture at the actual UI callback, never by parsing an ask_user tool result
|
|
1273
|
+
// (which can also be restored or forged). Close over this loop's opts so the
|
|
1274
|
+
// answer reaches subsequent calls despite per-call option copies below.
|
|
1275
|
+
const askUser = opts.askUser;
|
|
1276
|
+
if (askUser && !opts.subAgent)
|
|
1277
|
+
opts.askUser = async (questions) => {
|
|
1278
|
+
const answers = await askUser(questions);
|
|
1279
|
+
if (!opts.signal.aborted && !opts.isInterrupted()) {
|
|
1280
|
+
for (const answer of answers ?? []) {
|
|
1281
|
+
const question = questions.find((q) => q.question === answer.question);
|
|
1282
|
+
if (question && typeof answer.answer === "string" && answer.answer.trim()) {
|
|
1283
|
+
recordHumanInput(`Human form reply (question is context; only the submitted answer expresses the user's choice): ${JSON.stringify({ question: question.question, answer: answer.answer })}`);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return answers;
|
|
1288
|
+
};
|
|
1289
|
+
const receiveFollowUps = () => {
|
|
1290
|
+
const pending = opts.followUps?.drain() ?? [];
|
|
1291
|
+
for (const message of pending) {
|
|
1292
|
+
messages.push({ role: "user", content: message.content });
|
|
1293
|
+
recordHumanInput(message.text);
|
|
1294
|
+
opts.onFollowUp?.(message.displayText ?? message.text);
|
|
1295
|
+
}
|
|
1296
|
+
return pending.length > 0;
|
|
1297
|
+
};
|
|
1167
1298
|
// The loop's mutable state: budgets and counters, rewritten every iteration.
|
|
1168
1299
|
const attempts = { total: 0, rateLimited: 0, consecutive: 0 };
|
|
1169
1300
|
const compaction = { count: 0, failures: 0 }; // compaction score card for this query
|
|
@@ -1180,6 +1311,9 @@ opts) {
|
|
|
1180
1311
|
while (true) {
|
|
1181
1312
|
if (opts.isInterrupted())
|
|
1182
1313
|
return { reason: TerminateReason.UserInterrupt }; // user asked us to stop — obey before spending money
|
|
1314
|
+
if (opts.reviewBudget?.exhausted)
|
|
1315
|
+
return { reason: TerminateReason.ReviewLimit };
|
|
1316
|
+
receiveFollowUps();
|
|
1183
1317
|
// Proactive compaction: act BEFORE the API rejects us. Waiting for the
|
|
1184
1318
|
// hard limit means the failure already happened.
|
|
1185
1319
|
if (estimateHistoryTokens(messages) > COMPACT_AT) {
|
|
@@ -1189,7 +1323,7 @@ opts) {
|
|
|
1189
1323
|
if (!ok && compaction.failures >= MAX_COMPACT_FAILURES)
|
|
1190
1324
|
return { reason: TerminateReason.CompactionFailed, detail: `${compaction.failures} consecutive compaction failures` }; // the compaction circuit breaker
|
|
1191
1325
|
}
|
|
1192
|
-
let out;
|
|
1326
|
+
let out;
|
|
1193
1327
|
try {
|
|
1194
1328
|
out = await streamModelCall(messages, opts); // streaming call with spinner + watchdog
|
|
1195
1329
|
}
|
|
@@ -1211,6 +1345,9 @@ opts) {
|
|
|
1211
1345
|
continue; // history is smaller now — retry the same round
|
|
1212
1346
|
return { reason: TerminateReason.ContextTooLong, detail: e.message }; // compaction could not save us
|
|
1213
1347
|
}
|
|
1348
|
+
if (e.kind === ApiErrorKind.BadRequest && messages.some((m) => Array.isArray(m.content) && m.content.some((part) => part.type === "image_url"))) {
|
|
1349
|
+
return { reason: TerminateReason.ImageInputRejected };
|
|
1350
|
+
}
|
|
1214
1351
|
if (!e.retryable)
|
|
1215
1352
|
return { reason: TerminateReason.FatalApiError, detail: `${e.kind}: ${e.message}` }; // bad key etc. — stop now
|
|
1216
1353
|
emit("agent_api_error", { kind: e.kind, attempt: attempts.total + 1 }); // classified, greppable
|
|
@@ -1245,6 +1382,7 @@ opts) {
|
|
|
1245
1382
|
messages.push({
|
|
1246
1383
|
role: "assistant", // the model's turn
|
|
1247
1384
|
content: out.content || null, // null when the turn was tool calls only
|
|
1385
|
+
...(out.reasoning !== undefined ? { reasoning_content: out.reasoning } : {}),
|
|
1248
1386
|
...(out.toolCalls.length
|
|
1249
1387
|
? { tool_calls: out.toolCalls.map((c) => ({ id: c.id, type: "function", function: { name: c.name, arguments: c.args } })) }
|
|
1250
1388
|
: {}), // omit the field entirely when there were no calls
|
|
@@ -1252,6 +1390,8 @@ opts) {
|
|
|
1252
1390
|
if (out.content)
|
|
1253
1391
|
lastText = out.content; // remember the latest answer text — the round-cap path returns it
|
|
1254
1392
|
if (!out.toolCalls.length) {
|
|
1393
|
+
if (receiveFollowUps())
|
|
1394
|
+
break; // new human input takes precedence over finishing
|
|
1255
1395
|
// The model wants to stop. A Stop hook gets the last word: if it exits
|
|
1256
1396
|
// 2, the agent is NOT done — its stderr becomes a new instruction and
|
|
1257
1397
|
// the loop continues. This is how you build test-driven AI: a Stop hook
|
|
@@ -1283,6 +1423,8 @@ opts) {
|
|
|
1283
1423
|
// process, run another round instead of stopping.
|
|
1284
1424
|
if (await continueForTeam(messages, opts))
|
|
1285
1425
|
break; // exit the inner while → advance the round counter
|
|
1426
|
+
if (receiveFollowUps())
|
|
1427
|
+
break;
|
|
1286
1428
|
// The lead is really finishing: disband any teammates still idling so
|
|
1287
1429
|
// they exit cleanly (Day 39) instead of being orphaned.
|
|
1288
1430
|
if (!opts.subAgent && !opts.teammate)
|
|
@@ -1297,25 +1439,28 @@ opts) {
|
|
|
1297
1439
|
// Claude Code parallelizes safe reads while serializing risky work.
|
|
1298
1440
|
let i = 0; // index into out.toolCalls
|
|
1299
1441
|
while (i < out.toolCalls.length) {
|
|
1442
|
+
if (opts.followUps?.size && !opts.isInterrupted()) {
|
|
1443
|
+
// Resolve every outstanding tool ID BEFORE adding a user message.
|
|
1444
|
+
// Unstarted actions came from the old intent and must be replanned.
|
|
1445
|
+
for (const call of out.toolCalls.slice(i))
|
|
1446
|
+
messages.push({ role: "tool", tool_call_id: call.id, content: "[follow-up] Not executed: new user input arrived. Reconsider this action using the latest instructions." });
|
|
1447
|
+
receiveFollowUps();
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1300
1450
|
const batch = []; // a run of safe, parallelizable calls
|
|
1301
1451
|
while (i < out.toolCalls.length && isReadOnlyTool(out.toolCalls[i].name))
|
|
1302
1452
|
batch.push(out.toolCalls[i++]);
|
|
1303
1453
|
if (batch.length) {
|
|
1304
1454
|
// Run the whole read-only batch at once, preserving result order.
|
|
1305
|
-
const results = await Promise.all(batch.map((c) => runOneCall(c, opts)));
|
|
1455
|
+
const results = await Promise.all(batch.map((c) => runOneCall(c, { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) })));
|
|
1306
1456
|
for (const r of results)
|
|
1307
1457
|
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
1308
1458
|
continue; // back to the top — the next call is non-read-only
|
|
1309
1459
|
}
|
|
1310
1460
|
// A single non-read-only call: gate, maybe ask, execute — all serial.
|
|
1311
|
-
const r = await runOneCall(out.toolCalls[i++], opts);
|
|
1461
|
+
const r = await runOneCall(out.toolCalls[i++], { ...opts, autoHistory: reviewHistory(messages, opts.autoHistory) });
|
|
1312
1462
|
messages.push({ role: "tool", tool_call_id: r.id, content: r.content });
|
|
1313
1463
|
}
|
|
1314
|
-
// Fold this round's tool calls into ONE tally line instead of one line per
|
|
1315
|
-
// call (the per-call announcements were recorded for Ctrl+T in runOneCall).
|
|
1316
|
-
if (!opts.quiet && out.toolCalls.length) {
|
|
1317
|
-
sink(opts).note(agentIndent(opts) + mark.toolTally(out.toolCalls.map((c) => c.name)) + chalk.dim(" · Ctrl+T"));
|
|
1318
|
-
}
|
|
1319
1464
|
// Cron (Day s14): deliver any triggered scheduled jobs right after this
|
|
1320
1465
|
// round's results so the model sees them on the next round.
|
|
1321
1466
|
injectCronMessages(messages, opts);
|