@nanhara/hara 0.122.3 → 0.122.4
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/CHANGELOG.md +98 -0
- package/README.md +15 -2
- package/dist/agent/limits.js +51 -0
- package/dist/agent/loop.js +367 -112
- package/dist/agent/repeat-guard.js +49 -12
- package/dist/checkpoints.js +9 -0
- package/dist/cli.js +2 -1
- package/dist/config.js +10 -2
- package/dist/context/agents-md.js +21 -2
- package/dist/context/mentions.js +84 -1
- package/dist/context/workspace-scope.js +49 -0
- package/dist/cron/deliver.js +61 -38
- package/dist/cron/runner.js +423 -65
- package/dist/cron/schedule.js +23 -7
- package/dist/cron/store.js +709 -43
- package/dist/fs-walk.js +279 -7
- package/dist/fs-write.js +9 -0
- package/dist/gateway/feishu.js +351 -64
- package/dist/gateway/flows-pending.js +28 -14
- package/dist/gateway/flows.js +306 -31
- package/dist/gateway/outbound-files.js +20 -6
- package/dist/gateway/runtime-state.js +1485 -0
- package/dist/gateway/serve.js +550 -242
- package/dist/gateway/telegram.js +279 -29
- package/dist/gateway/tts.js +182 -38
- package/dist/hooks.js +22 -22
- package/dist/index.js +466 -158
- package/dist/memory/store.js +8 -5
- package/dist/org/planner.js +11 -6
- package/dist/process-identity.js +52 -0
- package/dist/providers/bounded-turn.js +42 -0
- package/dist/recall.js +69 -1
- package/dist/runtime.js +24 -0
- package/dist/sandbox.js +54 -4
- package/dist/search/embed.js +13 -2
- package/dist/search/hybrid.js +6 -3
- package/dist/search/semindex.js +87 -5
- package/dist/security/guardian.js +3 -15
- package/dist/security/permissions.js +11 -0
- package/dist/serve/server.js +70 -42
- package/dist/serve/sessions.js +4 -3
- package/dist/tools/agent.js +1 -1
- package/dist/tools/ask_user.js +5 -1
- package/dist/tools/builtin.js +5 -2
- package/dist/tools/codebase.js +67 -18
- package/dist/tools/computer.js +149 -68
- package/dist/tools/cron.js +72 -18
- package/dist/tools/edit.js +3 -2
- package/dist/tools/external_agent.js +66 -12
- package/dist/tools/memory.js +25 -7
- package/dist/tools/patch.js +11 -2
- package/dist/tools/registry.js +16 -1
- package/dist/tools/search.js +68 -9
- package/dist/tools/send.js +1 -1
- package/dist/tools/skill.js +1 -1
- package/dist/tools/web.js +43 -13
- package/dist/tui/App.js +93 -25
- package/dist/vision.js +5 -6
- package/package.json +2 -2
- package/runtime-bootstrap.cjs +22 -0
package/dist/agent/loop.js
CHANGED
|
@@ -8,7 +8,8 @@ import { runHooks } from "../hooks.js";
|
|
|
8
8
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
9
|
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
10
|
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
11
|
-
import { recordCall } from "./repeat-guard.js";
|
|
11
|
+
import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
|
|
12
|
+
import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
|
|
12
13
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
13
14
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
14
15
|
import { currentTodos, renderTodos } from "../tools/todo.js";
|
|
@@ -135,9 +136,114 @@ function composeSystem(cwd, projectContext, override, memory) {
|
|
|
135
136
|
(memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
|
|
136
137
|
(skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
|
|
137
138
|
}
|
|
139
|
+
const RUN_STOPPED = Symbol("agent-run-stopped");
|
|
140
|
+
const REPEATED_FAILURE_LIMIT = 3;
|
|
141
|
+
function showRunNotice(opts, message, critical = false) {
|
|
142
|
+
if (opts.quiet)
|
|
143
|
+
return;
|
|
144
|
+
if (opts.ctx.ui)
|
|
145
|
+
opts.ctx.ui.notice(message);
|
|
146
|
+
else {
|
|
147
|
+
try {
|
|
148
|
+
const rendered = process.stderr.isTTY ? (critical ? c.red(message) : c.yellow(message)) : message;
|
|
149
|
+
process.stderr.write(rendered + "\n");
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* diagnostics must never break lifecycle enforcement */
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function warnRun(opts, life) {
|
|
157
|
+
if (life.disposed || life.warned || life.signal.aborted)
|
|
158
|
+
return;
|
|
159
|
+
life.warned = true;
|
|
160
|
+
showRunNotice(opts, `⚠ agent still running: ${formatAgentDuration(Date.now() - life.startedAt)} elapsed, round ${life.rounds}/${life.maxRounds}; hard stop at ${formatAgentDuration(life.timeoutMs)}.`);
|
|
161
|
+
}
|
|
162
|
+
function createRunLifecycle(opts) {
|
|
163
|
+
const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
|
|
164
|
+
const maxRounds = agentMaxRounds(opts.maxRounds);
|
|
165
|
+
const timeoutController = new AbortController();
|
|
166
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
|
|
167
|
+
const startedAt = Date.now();
|
|
168
|
+
const life = {};
|
|
169
|
+
const timeoutTimer = setTimeout(() => {
|
|
170
|
+
if (life.disposed || signal.aborted)
|
|
171
|
+
return;
|
|
172
|
+
life.timedOut = true;
|
|
173
|
+
timeoutController.abort(new Error("agent run deadline reached"));
|
|
174
|
+
}, timeoutMs);
|
|
175
|
+
// This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
|
|
176
|
+
// referenced while the run is active; unref would let headless Node exit with the run still unresolved.
|
|
177
|
+
// Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
|
|
178
|
+
const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
|
|
179
|
+
const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
|
|
180
|
+
warningTimer.unref?.();
|
|
181
|
+
let removeStopListener = () => { };
|
|
182
|
+
const stopPromise = new Promise((resolveStopped) => {
|
|
183
|
+
const stopped = () => resolveStopped(RUN_STOPPED);
|
|
184
|
+
removeStopListener = () => signal.removeEventListener("abort", stopped);
|
|
185
|
+
if (signal.aborted)
|
|
186
|
+
stopped();
|
|
187
|
+
else
|
|
188
|
+
signal.addEventListener("abort", stopped, { once: true });
|
|
189
|
+
});
|
|
190
|
+
Object.assign(life, {
|
|
191
|
+
signal,
|
|
192
|
+
timeoutController,
|
|
193
|
+
timeoutTimer,
|
|
194
|
+
warningTimer,
|
|
195
|
+
stopPromise,
|
|
196
|
+
removeStopListener,
|
|
197
|
+
startedAt,
|
|
198
|
+
timeoutMs,
|
|
199
|
+
maxRounds,
|
|
200
|
+
rounds: 0,
|
|
201
|
+
timedOut: false,
|
|
202
|
+
warned: false,
|
|
203
|
+
limitAnnounced: false,
|
|
204
|
+
disposed: false,
|
|
205
|
+
failedCalls: new Map(),
|
|
206
|
+
});
|
|
207
|
+
return life;
|
|
208
|
+
}
|
|
209
|
+
function disposeRunLifecycle(life) {
|
|
210
|
+
life.disposed = true;
|
|
211
|
+
clearTimeout(life.timeoutTimer);
|
|
212
|
+
clearTimeout(life.warningTimer);
|
|
213
|
+
life.removeStopListener();
|
|
214
|
+
}
|
|
215
|
+
function hardStop(opts, life, kind, detail) {
|
|
216
|
+
const elapsedMs = Date.now() - life.startedAt;
|
|
217
|
+
const message = kind === "deadline"
|
|
218
|
+
? `⛔ agent run stopped: total deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). No further model or tool calls will start. Increase it with \`hara config set runTimeoutMs 45m\` (maximum 2h) only for intentional long work.`
|
|
219
|
+
: kind === "max_rounds"
|
|
220
|
+
? `⛔ agent run stopped: ${life.maxRounds}-round safety limit reached after ${formatAgentDuration(elapsedMs)}. This usually means the model is looping. Increase it with \`hara config set maxAgentRounds <n>\` (maximum 256) only if the extra rounds are intentional.`
|
|
221
|
+
: `⛔ agent run stopped: the same failing ${detail?.tool ?? "tool"} call repeated ${detail?.count ?? REPEATED_FAILURE_LIMIT} times. Change the approach or fix the reported cause before retrying.`;
|
|
222
|
+
const event = { kind, message, elapsedMs, rounds: life.rounds, timeoutMs: life.timeoutMs, maxRounds: life.maxRounds };
|
|
223
|
+
if (!life.limitAnnounced) {
|
|
224
|
+
life.limitAnnounced = true;
|
|
225
|
+
showRunNotice(opts, message, true);
|
|
226
|
+
try {
|
|
227
|
+
opts.onLimit?.(event);
|
|
228
|
+
}
|
|
229
|
+
catch { /* observers cannot weaken the hard stop */ }
|
|
230
|
+
}
|
|
231
|
+
return { status: "halted", error: message, stopReason: kind };
|
|
232
|
+
}
|
|
138
233
|
/** Provider-agnostic agentic loop. Mutates `history` in place. */
|
|
139
234
|
export async function runAgent(history, opts) {
|
|
235
|
+
const life = createRunLifecycle(opts);
|
|
236
|
+
try {
|
|
237
|
+
return await runAgentInner(history, opts, life);
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
disposeRunLifecycle(life);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function runAgentInner(history, opts, life) {
|
|
140
244
|
const { provider, ctx } = opts;
|
|
245
|
+
const runSignal = life.signal;
|
|
246
|
+
const toolCtx = { ...ctx, signal: runSignal };
|
|
141
247
|
const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
|
|
142
248
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
143
249
|
let triedFallback = false;
|
|
@@ -152,6 +258,21 @@ export async function runAgent(history, opts) {
|
|
|
152
258
|
}
|
|
153
259
|
return { status: "error", error: msg };
|
|
154
260
|
};
|
|
261
|
+
const stoppedOutcome = () => life.timedOut
|
|
262
|
+
? hardStop(opts, life, "deadline")
|
|
263
|
+
: interruptedOutcome();
|
|
264
|
+
const bounded = (promise) => {
|
|
265
|
+
if (runSignal.aborted)
|
|
266
|
+
return Promise.resolve(RUN_STOPPED);
|
|
267
|
+
return Promise.race([promise, life.stopPromise]);
|
|
268
|
+
};
|
|
269
|
+
const interactionFailure = (label, error) => {
|
|
270
|
+
const raw = error instanceof Error ? error.message : String(error ?? "unknown error");
|
|
271
|
+
const detail = redactSensitiveText(raw).text.trim().slice(0, 500) || "unknown error";
|
|
272
|
+
const message = `Interactive ${label} failed: ${detail}`;
|
|
273
|
+
showRunNotice(opts, message, true);
|
|
274
|
+
return { status: "error", error: message };
|
|
275
|
+
};
|
|
155
276
|
// Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
|
|
156
277
|
// transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
|
|
157
278
|
const latestUser = [...history].reverse().find((m) => m.role === "user");
|
|
@@ -179,10 +300,32 @@ export async function runAgent(history, opts) {
|
|
|
179
300
|
// unfinished items exist. Main loop only — quiet (sub-agent) runs share the global list and must not nag.
|
|
180
301
|
let todoIdleRounds = 0;
|
|
181
302
|
for (;;) {
|
|
303
|
+
// A cancellation that already happened is authoritative: do not start pending-input work, a provider
|
|
304
|
+
// request, or any later tool round merely to give it an already-aborted signal.
|
|
305
|
+
if (runSignal.aborted)
|
|
306
|
+
return stoppedOutcome();
|
|
307
|
+
if (life.rounds >= life.maxRounds)
|
|
308
|
+
return hardStop(opts, life, "max_rounds");
|
|
309
|
+
life.rounds += 1;
|
|
310
|
+
if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
|
|
311
|
+
warnRun(opts, life);
|
|
182
312
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
183
313
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
184
|
-
if (opts.pendingInput) {
|
|
185
|
-
|
|
314
|
+
if (opts.pendingInput && !runSignal.aborted) {
|
|
315
|
+
let pending;
|
|
316
|
+
try {
|
|
317
|
+
// Defer the callback by one microtask so a synchronous throw follows the same explicit error path
|
|
318
|
+
// as a rejected Promise instead of escaping runAgent and leaving the caller to guess what failed.
|
|
319
|
+
pending = await bounded(Promise.resolve().then(() => opts.pendingInput()));
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
if (runSignal.aborted)
|
|
323
|
+
return stoppedOutcome();
|
|
324
|
+
return interactionFailure("pending-input channel", error);
|
|
325
|
+
}
|
|
326
|
+
if (pending === RUN_STOPPED)
|
|
327
|
+
return stoppedOutcome();
|
|
328
|
+
for (const m of pending)
|
|
186
329
|
history.push(m);
|
|
187
330
|
}
|
|
188
331
|
// system-reminder injection: event-driven context queued since the last call (todo staleness today)
|
|
@@ -230,17 +373,17 @@ export async function runAgent(history, opts) {
|
|
|
230
373
|
}, 100);
|
|
231
374
|
}
|
|
232
375
|
// Stall watchdog: any stream event resets the clock; STALL_MS of silence aborts THIS attempt via
|
|
233
|
-
// its own controller (the
|
|
376
|
+
// its own controller (the combined run signal chains into it, so Esc/deadline both interrupt). The abort
|
|
234
377
|
// is then rewritten from "interrupted" to a timeout-class error so failover can take over.
|
|
235
378
|
const STALL_MS = stallMs();
|
|
236
379
|
const attempt = new AbortController();
|
|
237
|
-
const
|
|
380
|
+
const onRunAbort = () => attempt.abort();
|
|
238
381
|
// AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
|
|
239
382
|
// while provider routing is still refreshing, so inherit that state synchronously before the call.
|
|
240
|
-
if (
|
|
383
|
+
if (runSignal.aborted)
|
|
241
384
|
attempt.abort();
|
|
242
385
|
else
|
|
243
|
-
|
|
386
|
+
runSignal.addEventListener("abort", onRunAbort, { once: true });
|
|
244
387
|
let lastEvent = Date.now();
|
|
245
388
|
let stalled = false;
|
|
246
389
|
const stallTimer = setInterval(() => {
|
|
@@ -270,92 +413,109 @@ export async function runAgent(history, opts) {
|
|
|
270
413
|
else
|
|
271
414
|
attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
|
|
272
415
|
});
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
stopSpin();
|
|
295
|
-
flushReasoningTail();
|
|
296
|
-
if (md)
|
|
297
|
-
md.push(d);
|
|
298
|
-
else
|
|
299
|
-
out(d);
|
|
300
|
-
},
|
|
301
|
-
onReasoning: sink || tty
|
|
302
|
-
? (d) => {
|
|
416
|
+
// Enter through a microtask so a cancellation/deadline that lands during routing/setup is observed
|
|
417
|
+
// immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
|
|
418
|
+
// insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
|
|
419
|
+
const providerTurn = Promise.resolve().then(() => {
|
|
420
|
+
if (attempt.signal.aborted || runSignal.aborted) {
|
|
421
|
+
return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
422
|
+
}
|
|
423
|
+
return activeProvider.turn({
|
|
424
|
+
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
425
|
+
history,
|
|
426
|
+
tools: specs,
|
|
427
|
+
// Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
|
|
428
|
+
// reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
|
|
429
|
+
onActivity: () => {
|
|
430
|
+
if (attempt.signal.aborted)
|
|
431
|
+
return;
|
|
432
|
+
lastEvent = Date.now();
|
|
433
|
+
},
|
|
434
|
+
onText: (d) => {
|
|
303
435
|
if (attempt.signal.aborted)
|
|
304
436
|
return;
|
|
305
437
|
alive();
|
|
306
438
|
if (opts.quiet)
|
|
307
439
|
return;
|
|
308
440
|
if (sink) {
|
|
309
|
-
sink.
|
|
441
|
+
sink.text(d);
|
|
310
442
|
return;
|
|
311
443
|
}
|
|
312
|
-
// Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
|
|
313
|
-
// line is committed once and never overwritten — so a subsequent spinner tick can't
|
|
314
|
-
// clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
|
|
315
|
-
// current line resumes mid-output when the next delta arrives.
|
|
316
444
|
stopSpin();
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
445
|
+
flushReasoningTail();
|
|
446
|
+
if (md)
|
|
447
|
+
md.push(d);
|
|
448
|
+
else
|
|
449
|
+
out(d);
|
|
450
|
+
},
|
|
451
|
+
onReasoning: sink || tty
|
|
452
|
+
? (d) => {
|
|
453
|
+
if (attempt.signal.aborted)
|
|
454
|
+
return;
|
|
455
|
+
alive();
|
|
456
|
+
if (opts.quiet)
|
|
457
|
+
return;
|
|
458
|
+
if (sink) {
|
|
459
|
+
sink.reasoning(d);
|
|
460
|
+
return;
|
|
322
461
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
462
|
+
// Terminal mode: render reasoning on its own dim lines (prefix `│ ` per line). Each
|
|
463
|
+
// line is committed once and never overwritten — so a subsequent spinner tick can't
|
|
464
|
+
// clobber it (the old `out(c.dim(d))` bug). Multi-line deltas split cleanly; the
|
|
465
|
+
// current line resumes mid-output when the next delta arrives.
|
|
466
|
+
stopSpin();
|
|
467
|
+
const lines = d.split("\n");
|
|
468
|
+
for (let i = 0; i < lines.length; i++) {
|
|
469
|
+
if (!reasoningOpen) {
|
|
470
|
+
out(c.dim("│ "));
|
|
471
|
+
reasoningOpen = true;
|
|
472
|
+
}
|
|
473
|
+
out(c.dim(lines[i]));
|
|
474
|
+
if (i < lines.length - 1) {
|
|
475
|
+
out("\n");
|
|
476
|
+
reasoningOpen = false;
|
|
477
|
+
}
|
|
327
478
|
}
|
|
328
479
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
480
|
+
: () => {
|
|
481
|
+
if (!attempt.signal.aborted)
|
|
482
|
+
alive();
|
|
483
|
+
}, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
|
|
484
|
+
signal: attempt.signal,
|
|
485
|
+
});
|
|
335
486
|
});
|
|
336
487
|
opts.onProviderTurn?.(providerTurn);
|
|
337
488
|
r = await Promise.race([providerTurn, attemptStopped]);
|
|
338
489
|
}
|
|
339
490
|
catch (error) {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
r =
|
|
491
|
+
// Provider launch/stream failures are turn results, not uncaught loop exceptions. This preserves the
|
|
492
|
+
// normal classifyError → fallback path for both synchronous throws and rejected provider promises.
|
|
493
|
+
r = runSignal.aborted
|
|
494
|
+
? { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" }
|
|
495
|
+
: {
|
|
496
|
+
text: "",
|
|
497
|
+
toolUses: [],
|
|
498
|
+
stop: "error",
|
|
499
|
+
errorMsg: error instanceof Error ? error.message : String(error),
|
|
500
|
+
};
|
|
343
501
|
}
|
|
344
502
|
finally {
|
|
345
503
|
clearInterval(stallTimer);
|
|
346
504
|
removeAttemptStop();
|
|
347
|
-
|
|
505
|
+
runSignal.removeEventListener("abort", onRunAbort);
|
|
506
|
+
// Every exit path (sync throw, rejected promise, watchdog, Esc, deadline) owns the same terminal
|
|
507
|
+
// teardown. Leaving any of these after the try makes a failed provider strand the spinner/markdown.
|
|
508
|
+
stopSpin();
|
|
509
|
+
flushReasoningTail();
|
|
510
|
+
md?.end();
|
|
511
|
+
if (!opts.quiet && !sink)
|
|
512
|
+
out("\n");
|
|
348
513
|
}
|
|
349
514
|
// A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
|
|
350
515
|
// error (unless the USER really did interrupt) so classifyError → failover/fallback handles it.
|
|
351
|
-
if (stalled && r.stop === "error" && !
|
|
516
|
+
if (stalled && r.stop === "error" && !runSignal.aborted) {
|
|
352
517
|
r = { ...r, errorMsg: `model stream timeout — no output for ${Math.round(STALL_MS / 1000)}s (stalled connection?)` };
|
|
353
518
|
}
|
|
354
|
-
stopSpin();
|
|
355
|
-
flushReasoningTail();
|
|
356
|
-
md?.end();
|
|
357
|
-
if (!opts.quiet && !sink)
|
|
358
|
-
out("\n");
|
|
359
519
|
if (r.usage && opts.stats) {
|
|
360
520
|
opts.stats.input += r.usage.input;
|
|
361
521
|
opts.stats.output += r.usage.output;
|
|
@@ -363,8 +523,8 @@ export async function runAgent(history, opts) {
|
|
|
363
523
|
}
|
|
364
524
|
// A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
|
|
365
525
|
// The original run signal is authoritative: do not append/approve/execute any late response.
|
|
366
|
-
if (
|
|
367
|
-
return
|
|
526
|
+
if (runSignal.aborted)
|
|
527
|
+
return stoppedOutcome();
|
|
368
528
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
369
529
|
if (r.stop === "error") {
|
|
370
530
|
const kind = classifyError(r.errorMsg ?? "");
|
|
@@ -440,26 +600,64 @@ export async function runAgent(history, opts) {
|
|
|
440
600
|
// while planning, approving, or executing, so finalize the round with real results for work that already
|
|
441
601
|
// completed and explicit interruption errors for everything else before persisting the session.
|
|
442
602
|
const results = new Array(r.toolUses.length);
|
|
443
|
-
const
|
|
603
|
+
const finalizeStoppedToolRound = () => {
|
|
604
|
+
const pendingMessage = life.timedOut
|
|
605
|
+
? `Error: agent run deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
|
|
606
|
+
: "Error: interrupted before this tool call completed.";
|
|
607
|
+
history.push({
|
|
608
|
+
role: "tool",
|
|
609
|
+
results: r.toolUses.map((tu, idx) => results[idx] ?? ({
|
|
610
|
+
id: tu.id,
|
|
611
|
+
name: tu.name,
|
|
612
|
+
content: pendingMessage,
|
|
613
|
+
isError: true,
|
|
614
|
+
})),
|
|
615
|
+
});
|
|
616
|
+
return stoppedOutcome();
|
|
617
|
+
};
|
|
618
|
+
const finalizeInteractionError = (label, error) => {
|
|
619
|
+
const outcome = interactionFailure(label, error);
|
|
620
|
+
const pendingMessage = `Error: ${outcome.error}. This tool call was not executed.`;
|
|
444
621
|
history.push({
|
|
445
622
|
role: "tool",
|
|
446
623
|
results: r.toolUses.map((tu, idx) => results[idx] ?? ({
|
|
447
624
|
id: tu.id,
|
|
448
625
|
name: tu.name,
|
|
449
|
-
content:
|
|
626
|
+
content: pendingMessage,
|
|
450
627
|
isError: true,
|
|
451
628
|
})),
|
|
452
629
|
});
|
|
453
|
-
return
|
|
630
|
+
return outcome;
|
|
631
|
+
};
|
|
632
|
+
if (runSignal.aborted)
|
|
633
|
+
return finalizeStoppedToolRound();
|
|
634
|
+
let repeatHalt = null;
|
|
635
|
+
const noteCall = (name, input, content, isError = false) => {
|
|
636
|
+
const note = recordCall(name, input, content, isError, ctx.todoScope);
|
|
637
|
+
const key = keyOf(name, input);
|
|
638
|
+
if (isError || looksFailed(content, name)) {
|
|
639
|
+
const count = (life.failedCalls.get(key) ?? 0) + 1;
|
|
640
|
+
// This is a *consecutive no-progress* streak, not a lifetime counter for the call. A different
|
|
641
|
+
// failure is a changed attempt; keep only that new streak instead of letting an old failure
|
|
642
|
+
// silently accumulate across intervening work.
|
|
643
|
+
life.failedCalls.clear();
|
|
644
|
+
life.failedCalls.set(key, count);
|
|
645
|
+
if (count >= REPEATED_FAILURE_LIMIT && !repeatHalt)
|
|
646
|
+
repeatHalt = { tool: name, count };
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
// Any successful action is progress (in particular edit/exec calls that may have fixed the
|
|
650
|
+
// underlying cause), so a later retry starts a fresh failure streak.
|
|
651
|
+
life.failedCalls.clear();
|
|
652
|
+
}
|
|
653
|
+
return note;
|
|
454
654
|
};
|
|
455
|
-
if (opts.signal?.aborted)
|
|
456
|
-
return finalizeInterruptedToolRound();
|
|
457
655
|
const plans = [];
|
|
458
656
|
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
459
657
|
const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
|
|
460
658
|
for (const tu of r.toolUses) {
|
|
461
|
-
if (
|
|
462
|
-
return
|
|
659
|
+
if (runSignal.aborted)
|
|
660
|
+
return finalizeStoppedToolRound();
|
|
463
661
|
if (breakerHalt) {
|
|
464
662
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
465
663
|
// (no hang, no further tools) so the model + user get a definitive stop.
|
|
@@ -503,9 +701,18 @@ export async function runAgent(history, opts) {
|
|
|
503
701
|
if (risk.level === "high") {
|
|
504
702
|
const safeRiskReason = redactToolSubprocessOutput(risk.reason);
|
|
505
703
|
const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
704
|
+
let verdictResult;
|
|
705
|
+
try {
|
|
706
|
+
verdictResult = await bounded(Promise.resolve().then(() => guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: runSignal })));
|
|
707
|
+
}
|
|
708
|
+
catch (error) {
|
|
709
|
+
if (runSignal.aborted)
|
|
710
|
+
return finalizeStoppedToolRound();
|
|
711
|
+
return finalizeInteractionError("guardian check", error);
|
|
712
|
+
}
|
|
713
|
+
if (verdictResult === RUN_STOPPED)
|
|
714
|
+
return finalizeStoppedToolRound();
|
|
715
|
+
const verdict = verdictResult;
|
|
509
716
|
if (verdict.decision === "block") {
|
|
510
717
|
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
511
718
|
plans.push({
|
|
@@ -526,11 +733,20 @@ export async function runAgent(history, opts) {
|
|
|
526
733
|
// (gateway/cron/-p, where `confirm` is auto-yes and there's no real user), abort SAFELY —
|
|
527
734
|
// never auto-continue past the breaker, and never hang.
|
|
528
735
|
const interactive = !!ctx.ask;
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
736
|
+
let contResult;
|
|
737
|
+
try {
|
|
738
|
+
contResult = interactive
|
|
739
|
+
? await bounded(Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal)))
|
|
740
|
+
: false;
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
if (runSignal.aborted)
|
|
744
|
+
return finalizeStoppedToolRound();
|
|
745
|
+
return finalizeInteractionError("guardian confirmation", error);
|
|
746
|
+
}
|
|
747
|
+
if (contResult === RUN_STOPPED)
|
|
748
|
+
return finalizeStoppedToolRound();
|
|
749
|
+
const cont = contResult;
|
|
534
750
|
if (cont === false) {
|
|
535
751
|
breakerHalt = true;
|
|
536
752
|
}
|
|
@@ -545,9 +761,18 @@ export async function runAgent(history, opts) {
|
|
|
545
761
|
}
|
|
546
762
|
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
|
|
547
763
|
if (shouldConfirm) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
764
|
+
let replyResult;
|
|
765
|
+
try {
|
|
766
|
+
replyResult = await bounded(Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal)));
|
|
767
|
+
}
|
|
768
|
+
catch (error) {
|
|
769
|
+
if (runSignal.aborted)
|
|
770
|
+
return finalizeStoppedToolRound();
|
|
771
|
+
return finalizeInteractionError("approval prompt", error);
|
|
772
|
+
}
|
|
773
|
+
if (replyResult === RUN_STOPPED)
|
|
774
|
+
return finalizeStoppedToolRound();
|
|
775
|
+
const reply = replyResult;
|
|
551
776
|
if (reply === false) {
|
|
552
777
|
plans.push({ tu, tool, denied: "User denied this action." });
|
|
553
778
|
continue;
|
|
@@ -565,13 +790,22 @@ export async function runAgent(history, opts) {
|
|
|
565
790
|
}
|
|
566
791
|
}
|
|
567
792
|
// Execute: read-only tools run concurrently; edit/exec run alone, in order.
|
|
568
|
-
if (
|
|
569
|
-
return
|
|
793
|
+
if (runSignal.aborted)
|
|
794
|
+
return finalizeStoppedToolRound();
|
|
570
795
|
const runOne = async (idx, p) => {
|
|
571
|
-
if (
|
|
796
|
+
if (runSignal.aborted)
|
|
797
|
+
return;
|
|
798
|
+
if (repeatHalt) {
|
|
799
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: "Error: not executed because the repeated-failure circuit-breaker stopped this run.", isError: true };
|
|
572
800
|
return;
|
|
801
|
+
}
|
|
573
802
|
if (p.denied !== undefined) {
|
|
574
|
-
results[idx] = {
|
|
803
|
+
results[idx] = {
|
|
804
|
+
id: p.tu.id,
|
|
805
|
+
name: p.tu.name,
|
|
806
|
+
content: p.denied + noteCall(p.tu.name, p.tu.input, p.denied, true),
|
|
807
|
+
isError: true,
|
|
808
|
+
};
|
|
575
809
|
return;
|
|
576
810
|
}
|
|
577
811
|
activity.inc();
|
|
@@ -583,42 +817,59 @@ export async function runAgent(history, opts) {
|
|
|
583
817
|
if (missing.length) {
|
|
584
818
|
const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
|
|
585
819
|
`${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
|
|
586
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg +
|
|
820
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
587
821
|
return;
|
|
588
822
|
}
|
|
589
|
-
if (
|
|
823
|
+
if (runSignal.aborted)
|
|
590
824
|
return;
|
|
591
825
|
const pre = opts.hooks === false
|
|
592
826
|
? { block: false, message: "" }
|
|
593
|
-
: runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
|
|
827
|
+
: await runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd, 30_000, runSignal); // a hook may veto the call
|
|
594
828
|
if (pre.block) {
|
|
595
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
|
|
829
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message + noteCall(p.tu.name, p.tu.input, pre.message, true), isError: true };
|
|
596
830
|
return;
|
|
597
831
|
}
|
|
598
|
-
if (
|
|
832
|
+
if (runSignal.aborted)
|
|
599
833
|
return;
|
|
600
834
|
// Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
|
|
601
835
|
// sub-agents read broadly — their files aren't "what the user was working on").
|
|
602
836
|
if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
|
|
603
837
|
recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
|
|
604
838
|
}
|
|
605
|
-
|
|
839
|
+
// If a tool completes a side effect and aborts the parent synchronously, preserve that real result.
|
|
840
|
+
// A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
|
|
841
|
+
// completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
|
|
842
|
+
let settled;
|
|
843
|
+
const observedTool = p.tool.run(p.tu.input, toolCtx).then((value) => { settled = { ok: true, value }; return value; }, (error) => { settled = { ok: false, error }; throw error; });
|
|
844
|
+
try {
|
|
845
|
+
opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.tool.kind });
|
|
846
|
+
}
|
|
847
|
+
catch { /* observers cannot affect execution */ }
|
|
848
|
+
const toolResult = await bounded(observedTool);
|
|
849
|
+
if (toolResult === RUN_STOPPED) {
|
|
850
|
+
await Promise.resolve(); // allow an already-completed async tool's fulfillment handler to publish
|
|
851
|
+
if (!settled)
|
|
852
|
+
return;
|
|
853
|
+
if (!settled.ok)
|
|
854
|
+
throw settled.error;
|
|
855
|
+
}
|
|
856
|
+
const res = toolResult === RUN_STOPPED ? settled.value : toolResult;
|
|
606
857
|
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
607
858
|
// + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
|
|
608
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) +
|
|
859
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + noteCall(p.tu.name, p.tu.input, res) };
|
|
609
860
|
// The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
|
|
610
861
|
// actual result in the closing tool round, but do not run any post hook or later tool afterward.
|
|
611
|
-
if (
|
|
862
|
+
if (runSignal.aborted)
|
|
612
863
|
return;
|
|
613
864
|
if (opts.hooks !== false) {
|
|
614
|
-
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
865
|
+
await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
|
|
615
866
|
}
|
|
616
867
|
}
|
|
617
868
|
catch (e) {
|
|
618
|
-
|
|
619
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
|
|
620
|
-
if (opts.signal?.aborted)
|
|
869
|
+
if (runSignal.aborted)
|
|
621
870
|
return;
|
|
871
|
+
const msg = `Error: ${e.message}`;
|
|
872
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
622
873
|
}
|
|
623
874
|
finally {
|
|
624
875
|
activity.dec();
|
|
@@ -633,25 +884,29 @@ export async function runAgent(history, opts) {
|
|
|
633
884
|
await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
|
|
634
885
|
};
|
|
635
886
|
for (let i = 0; i < plans.length; i++) {
|
|
636
|
-
if (
|
|
637
|
-
return
|
|
887
|
+
if (runSignal.aborted)
|
|
888
|
+
return finalizeStoppedToolRound();
|
|
638
889
|
const p = plans[i];
|
|
639
|
-
|
|
890
|
+
// ask_user is interaction-safe but not parallel-safe: the TUI deliberately owns one prompt slot.
|
|
891
|
+
// Flush other reads and ask sequentially so two questions cannot overwrite each other and hang.
|
|
892
|
+
if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user") {
|
|
640
893
|
batch.push(i); // safe → accumulate to run concurrently
|
|
641
894
|
}
|
|
642
895
|
else {
|
|
643
896
|
await flush(); // flush pending reads before an edit/exec
|
|
644
|
-
if (
|
|
645
|
-
return
|
|
897
|
+
if (runSignal.aborted)
|
|
898
|
+
return finalizeStoppedToolRound();
|
|
646
899
|
await runOne(i, p);
|
|
647
|
-
if (
|
|
648
|
-
return
|
|
900
|
+
if (runSignal.aborted)
|
|
901
|
+
return finalizeStoppedToolRound();
|
|
649
902
|
}
|
|
650
903
|
}
|
|
651
904
|
await flush();
|
|
652
|
-
if (
|
|
653
|
-
return
|
|
905
|
+
if (runSignal.aborted)
|
|
906
|
+
return finalizeStoppedToolRound();
|
|
654
907
|
history.push({ role: "tool", results });
|
|
908
|
+
if (repeatHalt)
|
|
909
|
+
return hardStop(opts, life, "repeat_loop", repeatHalt);
|
|
655
910
|
// Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
|
|
656
911
|
// produced N independent reports — remind the model to merge/reconcile them before acting, instead
|
|
657
912
|
// of anchoring on whichever report happens to sit last in context.
|