@nanhara/hara 0.125.3 → 0.126.1
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 +59 -1
- package/README.md +10 -4
- package/SECURITY.md +7 -3
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +270 -98
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +372 -159
- package/dist/mcp/client.js +2 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/openai.js +6 -1
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +139 -35
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +19 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +5 -1
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getTool, toolSpecs,
|
|
1
|
+
import { approvalKindForOperation, getTool, missingRequired, toolOperationTraits, toolSpecs, } from "../tools/registry.js";
|
|
2
|
+
import { limitToolResultBatch } from "../tools/result-limit.js";
|
|
2
3
|
import { stdout } from "node:process";
|
|
3
4
|
import { c, out } from "../ui.js";
|
|
4
5
|
import { activity } from "../activity.js";
|
|
@@ -22,8 +23,12 @@ import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
|
22
23
|
import { prepareHistoryForModel } from "./context-budget.js";
|
|
23
24
|
import { rolesDigest } from "../org/roles.js";
|
|
24
25
|
import { applyTaskBrief } from "../session/task.js";
|
|
26
|
+
import { askUserTool } from "../tools/ask_user.js";
|
|
25
27
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
26
28
|
const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
29
|
+
/** Engine-owned, non-authority helpers. Role filters still govern every deferred target activated by
|
|
30
|
+
* tool_search; these two only reveal an allowed schema or page an already-redacted result. */
|
|
31
|
+
const RUNTIME_HELPER_TOOLS = new Set(["tool_search", "tool_result_read"]);
|
|
27
32
|
/** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
|
|
28
33
|
* stalled connection and aborted into the normal error→failover path — instead of hanging on
|
|
29
34
|
* "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
|
|
@@ -90,6 +95,9 @@ are disabled without an interactive approval channel unless the user launched wi
|
|
|
90
95
|
HARA_ALLOW_TRUSTED_EXTENSIONS=1. Configured MCP servers stay stopped by default; when a task materially needs
|
|
91
96
|
one, call \`mcp_connect\` for that server only, then use the newly available tools on the next round. Never
|
|
92
97
|
connect every configured server speculatively.
|
|
98
|
+
Optional web, desktop, scheduler, external-agent, and connected-MCP schemas may be deferred to keep context
|
|
99
|
+
focused. If a needed capability is absent from the current tool list, call \`tool_search\` once with the
|
|
100
|
+
capability/service name; use the activated tool on the next round. Do not search speculatively.
|
|
93
101
|
For broad,
|
|
94
102
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
95
103
|
independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
|
|
@@ -181,7 +189,7 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
|
|
|
181
189
|
const RUN_STOPPED = Symbol("agent-run-stopped");
|
|
182
190
|
const REPEATED_FAILURE_LIMIT = 3;
|
|
183
191
|
export function deadlineCheckpointReminder(timeoutMs) {
|
|
184
|
-
return (`Turn budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
|
|
192
|
+
return (`Turn active-execution budget checkpoint: about 20% remains before the ${formatAgentDuration(timeoutMs)} safety pause. ` +
|
|
185
193
|
"Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
|
|
186
194
|
"and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
|
|
187
195
|
"install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
|
|
@@ -206,39 +214,149 @@ function requestRunCheckpoint(opts, life) {
|
|
|
206
214
|
if (life.disposed || life.checkpointDue || life.signal.aborted)
|
|
207
215
|
return;
|
|
208
216
|
life.checkpointDue = true;
|
|
209
|
-
const remainingMs = Math.max(0, life.timeoutMs - (
|
|
210
|
-
showRunNotice(opts, `⚠
|
|
217
|
+
const remainingMs = Math.max(0, life.timeoutMs - runActiveElapsedMs(life));
|
|
218
|
+
showRunNotice(opts, `⚠ active turn budget is 80% used: about ${formatAgentDuration(remainingMs)} of active execution remains. The agent will be told to finish the current atomic step and checkpoint; use \`/continue\` for the next expensive stage.`);
|
|
211
219
|
}
|
|
212
220
|
function warnRun(opts, life) {
|
|
213
221
|
if (life.disposed || life.warned || life.signal.aborted)
|
|
214
222
|
return;
|
|
215
223
|
life.warned = true;
|
|
216
|
-
const elapsedMs =
|
|
224
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
217
225
|
const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
|
|
218
|
-
showRunNotice(opts, `⚠ agent still
|
|
226
|
+
showRunNotice(opts, `⚠ agent is still actively working: ${formatAgentDuration(elapsedMs)} active execution elapsed, round ${life.rounds}/${life.maxRounds}; ${formatAgentDuration(remainingMs)} remains before this turn pauses. Finish the current step or leave a checklist checkpoint; unfinished session work can resume with \`/continue\`.`);
|
|
227
|
+
}
|
|
228
|
+
function runActiveElapsedMs(life) {
|
|
229
|
+
const current = life.activeStartedAt === null ? 0 : Date.now() - life.activeStartedAt;
|
|
230
|
+
return Math.max(0, life.activeElapsedMs + current);
|
|
231
|
+
}
|
|
232
|
+
function clearRunTimers(life) {
|
|
233
|
+
if (life.timeoutTimer)
|
|
234
|
+
clearTimeout(life.timeoutTimer);
|
|
235
|
+
if (life.warningTimer)
|
|
236
|
+
clearTimeout(life.warningTimer);
|
|
237
|
+
if (life.checkpointTimer)
|
|
238
|
+
clearTimeout(life.checkpointTimer);
|
|
239
|
+
life.timeoutTimer = null;
|
|
240
|
+
life.warningTimer = null;
|
|
241
|
+
life.checkpointTimer = null;
|
|
242
|
+
}
|
|
243
|
+
/** Timer callbacks are macrotasks. A synchronous provider can overrun the budget and return a tool request
|
|
244
|
+
* before that callback gets CPU, so every authority boundary also performs this synchronous check. */
|
|
245
|
+
function expireRunBudgetIfNeeded(life) {
|
|
246
|
+
if (life.signal.aborted)
|
|
247
|
+
return true;
|
|
248
|
+
if (life.disposed || life.pauseDepth > 0)
|
|
249
|
+
return false;
|
|
250
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
251
|
+
if (elapsedMs < life.timeoutMs)
|
|
252
|
+
return false;
|
|
253
|
+
life.activeElapsedMs = elapsedMs;
|
|
254
|
+
life.activeStartedAt = null;
|
|
255
|
+
clearRunTimers(life);
|
|
256
|
+
life.timedOut = true;
|
|
257
|
+
life.timeoutController.abort(new Error("agent active-execution deadline reached"));
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
/** Re-arm every threshold from the active clock. Human wait time is excluded, but active provider/tool
|
|
261
|
+
* promises remain bounded even when they ignore AbortSignal or own no event-loop handles. */
|
|
262
|
+
function armRunTimers(opts, life) {
|
|
263
|
+
clearRunTimers(life);
|
|
264
|
+
if (life.disposed || life.signal.aborted || life.pauseDepth > 0 || life.activeStartedAt === null)
|
|
265
|
+
return;
|
|
266
|
+
if (expireRunBudgetIfNeeded(life))
|
|
267
|
+
return;
|
|
268
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
269
|
+
const timeoutDelay = life.timeoutMs - elapsedMs;
|
|
270
|
+
life.timeoutTimer = setTimeout(() => {
|
|
271
|
+
if (life.disposed || life.signal.aborted || life.pauseDepth > 0)
|
|
272
|
+
return;
|
|
273
|
+
if (!expireRunBudgetIfNeeded(life))
|
|
274
|
+
armRunTimers(opts, life);
|
|
275
|
+
}, timeoutDelay);
|
|
276
|
+
// The hard timer stays referenced while the agent is actively executing. Human prompts have their own
|
|
277
|
+
// visible UI plus Esc/shutdown cancellation and deliberately do not keep this active budget running.
|
|
278
|
+
const warningAt = Math.min(5 * 60_000, Math.max(250, Math.floor(life.timeoutMs * 0.8)));
|
|
279
|
+
if (!life.warned) {
|
|
280
|
+
life.warningTimer = setTimeout(() => warnRun(opts, life), Math.max(0, warningAt - elapsedMs));
|
|
281
|
+
life.warningTimer.unref?.();
|
|
282
|
+
}
|
|
283
|
+
const checkpointAt = Math.max(250, Math.floor(life.timeoutMs * 0.8));
|
|
284
|
+
if (!life.checkpointDue) {
|
|
285
|
+
life.checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), Math.max(0, checkpointAt - elapsedMs));
|
|
286
|
+
life.checkpointTimer.unref?.();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
function pauseRunBudget(life) {
|
|
290
|
+
if (life.disposed || life.signal.aborted || expireRunBudgetIfNeeded(life))
|
|
291
|
+
return false;
|
|
292
|
+
life.pauseDepth += 1;
|
|
293
|
+
if (life.pauseDepth > 1)
|
|
294
|
+
return true;
|
|
295
|
+
life.activeElapsedMs = runActiveElapsedMs(life);
|
|
296
|
+
life.activeStartedAt = null;
|
|
297
|
+
clearRunTimers(life);
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
300
|
+
function resumeRunBudget(opts, life, paused) {
|
|
301
|
+
if (!paused || life.pauseDepth <= 0)
|
|
302
|
+
return;
|
|
303
|
+
life.pauseDepth -= 1;
|
|
304
|
+
if (life.pauseDepth > 0 || life.disposed || life.signal.aborted)
|
|
305
|
+
return;
|
|
306
|
+
life.activeStartedAt = Date.now();
|
|
307
|
+
if (expireRunBudgetIfNeeded(life))
|
|
308
|
+
return;
|
|
309
|
+
armRunTimers(opts, life);
|
|
310
|
+
}
|
|
311
|
+
/** Only engine-owned human interaction may suspend the active clock. A plugin cannot acquire this authority
|
|
312
|
+
* by labelling an arbitrary long-running operation "interactive". Parent Esc/shutdown cancellation remains
|
|
313
|
+
* connected through life.signal and dismisses the prompt immediately. */
|
|
314
|
+
async function withAbortSignal(signal, action) {
|
|
315
|
+
let removeAbort = () => { };
|
|
316
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
317
|
+
const stop = () => reject(signal.reason ?? new Error("agent run interrupted"));
|
|
318
|
+
removeAbort = () => signal.removeEventListener("abort", stop);
|
|
319
|
+
if (signal.aborted)
|
|
320
|
+
stop();
|
|
321
|
+
else
|
|
322
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
323
|
+
});
|
|
324
|
+
try {
|
|
325
|
+
const guardedAction = Promise.resolve().then(() => {
|
|
326
|
+
// Promise.race does not cancel its losing branch. Re-check inside the queued microtask so an Esc
|
|
327
|
+
// that lands after waitForHuman() is entered but before the UI callback runs cannot open a stale prompt.
|
|
328
|
+
if (signal.aborted)
|
|
329
|
+
throw signal.reason ?? new Error("agent run interrupted");
|
|
330
|
+
return action();
|
|
331
|
+
});
|
|
332
|
+
return await Promise.race([guardedAction, aborted]);
|
|
333
|
+
}
|
|
334
|
+
finally {
|
|
335
|
+
removeAbort();
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function waitForHuman(opts, life, action) {
|
|
339
|
+
const outermost = life.pauseDepth === 0;
|
|
340
|
+
const paused = pauseRunBudget(life);
|
|
341
|
+
if (!paused)
|
|
342
|
+
throw life.signal.reason ?? new Error("agent run ended before human interaction");
|
|
343
|
+
if (outermost && !opts.quiet)
|
|
344
|
+
setTurnPhase("awaiting_user");
|
|
345
|
+
try {
|
|
346
|
+
return await withAbortSignal(life.signal, action);
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
resumeRunBudget(opts, life, paused);
|
|
350
|
+
if (outermost && !opts.quiet)
|
|
351
|
+
setTurnPhase("streaming");
|
|
352
|
+
}
|
|
219
353
|
}
|
|
220
354
|
function createRunLifecycle(opts) {
|
|
221
355
|
const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
|
|
222
356
|
const maxRounds = agentMaxRounds(opts.maxRounds);
|
|
223
357
|
const timeoutController = new AbortController();
|
|
224
358
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
|
|
225
|
-
const
|
|
226
|
-
const life = {};
|
|
227
|
-
const timeoutTimer = setTimeout(() => {
|
|
228
|
-
if (life.disposed || signal.aborted)
|
|
229
|
-
return;
|
|
230
|
-
life.timedOut = true;
|
|
231
|
-
timeoutController.abort(new Error("agent run deadline reached"));
|
|
232
|
-
}, timeoutMs);
|
|
233
|
-
// This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
|
|
234
|
-
// referenced while the run is active; unref would let headless Node exit with the run still unresolved.
|
|
235
|
-
// Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
|
|
236
|
-
const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
|
|
237
|
-
const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
|
|
238
|
-
warningTimer.unref?.();
|
|
239
|
-
const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
|
|
240
|
-
const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
|
|
241
|
-
checkpointTimer.unref?.();
|
|
359
|
+
const activeStartedAt = Date.now();
|
|
242
360
|
let removeStopListener = () => { };
|
|
243
361
|
const stopPromise = new Promise((resolveStopped) => {
|
|
244
362
|
const stopped = () => resolveStopped(RUN_STOPPED);
|
|
@@ -248,15 +366,17 @@ function createRunLifecycle(opts) {
|
|
|
248
366
|
else
|
|
249
367
|
signal.addEventListener("abort", stopped, { once: true });
|
|
250
368
|
});
|
|
251
|
-
|
|
369
|
+
const life = {
|
|
252
370
|
signal,
|
|
253
371
|
timeoutController,
|
|
254
|
-
timeoutTimer,
|
|
255
|
-
warningTimer,
|
|
256
|
-
checkpointTimer,
|
|
372
|
+
timeoutTimer: null,
|
|
373
|
+
warningTimer: null,
|
|
374
|
+
checkpointTimer: null,
|
|
257
375
|
stopPromise,
|
|
258
376
|
removeStopListener,
|
|
259
|
-
|
|
377
|
+
activeStartedAt,
|
|
378
|
+
activeElapsedMs: 0,
|
|
379
|
+
pauseDepth: 0,
|
|
260
380
|
timeoutMs,
|
|
261
381
|
maxRounds,
|
|
262
382
|
rounds: 0,
|
|
@@ -267,20 +387,19 @@ function createRunLifecycle(opts) {
|
|
|
267
387
|
limitAnnounced: false,
|
|
268
388
|
disposed: false,
|
|
269
389
|
failedCalls: new Map(),
|
|
270
|
-
}
|
|
390
|
+
};
|
|
391
|
+
armRunTimers(opts, life);
|
|
271
392
|
return life;
|
|
272
393
|
}
|
|
273
394
|
function disposeRunLifecycle(life) {
|
|
274
395
|
life.disposed = true;
|
|
275
|
-
|
|
276
|
-
clearTimeout(life.warningTimer);
|
|
277
|
-
clearTimeout(life.checkpointTimer);
|
|
396
|
+
clearRunTimers(life);
|
|
278
397
|
life.removeStopListener();
|
|
279
398
|
}
|
|
280
399
|
function hardStop(opts, life, kind, detail) {
|
|
281
|
-
const elapsedMs =
|
|
400
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
282
401
|
const message = kind === "deadline"
|
|
283
|
-
? `⏸ agent run paused:
|
|
402
|
+
? `⏸ agent run paused: active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached after ${life.rounds} round(s). Waiting for your answers did not consume this budget. No further model or tool calls will start in this turn. Session-backed work keeps its task and checklist checkpoint; type \`/continue\` to resume in a fresh bounded turn. Only for intentionally long single turns, use \`hara config set runTimeoutMs 45m\` (maximum 2h).`
|
|
284
403
|
: kind === "max_rounds"
|
|
285
404
|
? `⛔ 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.`
|
|
286
405
|
: `⛔ 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.`;
|
|
@@ -308,7 +427,33 @@ export async function runAgent(history, opts) {
|
|
|
308
427
|
async function runAgentInner(history, opts, life) {
|
|
309
428
|
const { provider, ctx } = opts;
|
|
310
429
|
const runSignal = life.signal;
|
|
311
|
-
const
|
|
430
|
+
const activatedDeferredTools = new Set();
|
|
431
|
+
const askWithRunCancellation = ctx.ask
|
|
432
|
+
? (question, options, signal) => {
|
|
433
|
+
const combined = signal && signal !== runSignal
|
|
434
|
+
? AbortSignal.any([runSignal, signal])
|
|
435
|
+
: runSignal;
|
|
436
|
+
return withAbortSignal(combined, () => ctx.ask(question, options, combined));
|
|
437
|
+
}
|
|
438
|
+
: undefined;
|
|
439
|
+
const toolCtx = {
|
|
440
|
+
...ctx,
|
|
441
|
+
signal: runSignal,
|
|
442
|
+
...(askWithRunCancellation ? { ask: askWithRunCancellation } : {}),
|
|
443
|
+
activateTools(names) {
|
|
444
|
+
const accepted = [];
|
|
445
|
+
for (const name of names) {
|
|
446
|
+
const tool = getTool(name);
|
|
447
|
+
if (!tool || tool.visibility !== "deferred")
|
|
448
|
+
continue;
|
|
449
|
+
if (opts.toolFilter && !opts.toolFilter(name))
|
|
450
|
+
continue;
|
|
451
|
+
activatedDeferredTools.add(name);
|
|
452
|
+
accepted.push(name);
|
|
453
|
+
}
|
|
454
|
+
return accepted;
|
|
455
|
+
},
|
|
456
|
+
};
|
|
312
457
|
let intakeTask = opts.taskIntake?.task;
|
|
313
458
|
let intakeDirty = false;
|
|
314
459
|
const syncIntakeTask = () => {
|
|
@@ -338,6 +483,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
338
483
|
required: ["intent", "goal", "constraints", "acceptance", "steps"],
|
|
339
484
|
},
|
|
340
485
|
kind: "read",
|
|
486
|
+
classify: () => ({ effect: "state", concurrencySafe: false }),
|
|
341
487
|
run: async (input) => {
|
|
342
488
|
syncIntakeTask();
|
|
343
489
|
const applied = applyTaskBrief(intakeTask, input);
|
|
@@ -418,14 +564,14 @@ async function runAgentInner(history, opts, life) {
|
|
|
418
564
|
for (;;) {
|
|
419
565
|
// A cancellation that already happened is authoritative: do not start pending-input work, a provider
|
|
420
566
|
// request, or any later tool round merely to give it an already-aborted signal.
|
|
421
|
-
if (runSignal.aborted)
|
|
567
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
422
568
|
return stoppedOutcome();
|
|
423
569
|
if (life.rounds >= life.maxRounds)
|
|
424
570
|
return hardStop(opts, life, "max_rounds");
|
|
425
571
|
life.rounds += 1;
|
|
426
572
|
if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
|
|
427
573
|
warnRun(opts, life);
|
|
428
|
-
if (
|
|
574
|
+
if (runActiveElapsedMs(life) >= Math.floor(life.timeoutMs * 0.8))
|
|
429
575
|
requestRunCheckpoint(opts, life);
|
|
430
576
|
if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
|
|
431
577
|
life.checkpointInjected = true;
|
|
@@ -464,7 +610,10 @@ async function runAgentInner(history, opts, life) {
|
|
|
464
610
|
if (reminders.length)
|
|
465
611
|
history.push({ role: "user", content: wrapReminders(reminders) });
|
|
466
612
|
}
|
|
467
|
-
const
|
|
613
|
+
const visibleSpecs = toolSpecs({ activatedDeferred: activatedDeferredTools });
|
|
614
|
+
const baseSpecs = opts.toolFilter
|
|
615
|
+
? visibleSpecs.filter((t) => RUNTIME_HELPER_TOOLS.has(t.name) || opts.toolFilter(t.name))
|
|
616
|
+
: visibleSpecs;
|
|
468
617
|
const specs = runExtraTools.length
|
|
469
618
|
? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
|
|
470
619
|
: baseSpecs;
|
|
@@ -560,7 +709,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
560
709
|
// immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
|
|
561
710
|
// insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
|
|
562
711
|
const providerTurn = Promise.resolve().then(() => {
|
|
563
|
-
if (attempt.signal.aborted || runSignal.aborted) {
|
|
712
|
+
if (expireRunBudgetIfNeeded(life) || attempt.signal.aborted || runSignal.aborted) {
|
|
564
713
|
return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
565
714
|
}
|
|
566
715
|
return activeProvider.turn({
|
|
@@ -666,7 +815,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
666
815
|
}
|
|
667
816
|
// A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
|
|
668
817
|
// The original run signal is authoritative: do not append/approve/execute any late response.
|
|
669
|
-
if (runSignal.aborted)
|
|
818
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
670
819
|
return stoppedOutcome();
|
|
671
820
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
672
821
|
if (r.stop === "error") {
|
|
@@ -758,7 +907,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
758
907
|
const results = new Array(r.toolUses.length);
|
|
759
908
|
const finalizeStoppedToolRound = () => {
|
|
760
909
|
const pendingMessage = life.timedOut
|
|
761
|
-
? `Error: agent
|
|
910
|
+
? `Error: agent active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
|
|
762
911
|
: "Error: interrupted before this tool call completed.";
|
|
763
912
|
history.push({
|
|
764
913
|
role: "tool",
|
|
@@ -785,7 +934,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
785
934
|
});
|
|
786
935
|
return outcome;
|
|
787
936
|
};
|
|
788
|
-
if (runSignal.aborted)
|
|
937
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
789
938
|
return finalizeStoppedToolRound();
|
|
790
939
|
let repeatHalt = null;
|
|
791
940
|
const noteCall = (name, input, content, isError = false) => {
|
|
@@ -810,14 +959,22 @@ async function runAgentInner(history, opts, life) {
|
|
|
810
959
|
};
|
|
811
960
|
const plans = [];
|
|
812
961
|
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
813
|
-
const resolveTool = (name) =>
|
|
962
|
+
const resolveTool = (name) => {
|
|
963
|
+
const extra = runExtraTools.find((tool) => tool.name === name);
|
|
964
|
+
if (extra)
|
|
965
|
+
return extra;
|
|
966
|
+
const registered = getTool(name);
|
|
967
|
+
if (registered?.visibility === "deferred" && !activatedDeferredTools.has(name))
|
|
968
|
+
return undefined;
|
|
969
|
+
return registered;
|
|
970
|
+
};
|
|
814
971
|
// Planning happens before dispatch, so a previously accepted `change` brief must not let the model
|
|
815
972
|
// revise that brief and perform a side effect in the same response. Treat every intake call as a
|
|
816
973
|
// transaction boundary for the whole response: accept/checkpoint the interpretation first, then let
|
|
817
974
|
// the next model round act against the newly authoritative brief.
|
|
818
975
|
const taskBriefTransitionInRound = r.toolUses.some((tu) => tu.name === "task_intake");
|
|
819
976
|
for (const tu of r.toolUses) {
|
|
820
|
-
if (runSignal.aborted)
|
|
977
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
821
978
|
return finalizeStoppedToolRound();
|
|
822
979
|
if (breakerHalt) {
|
|
823
980
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
@@ -831,42 +988,42 @@ async function runAgentInner(history, opts, life) {
|
|
|
831
988
|
continue;
|
|
832
989
|
}
|
|
833
990
|
const input = tu.input;
|
|
991
|
+
const command = tool.kind === "exec" && typeof input.command === "string" ? input.command : null;
|
|
992
|
+
let operation = toolOperationTraits(tool, input, toolCtx);
|
|
993
|
+
// One-release compatibility for embedders/older plugins that only implement static kind. New built-ins
|
|
994
|
+
// declare classify(); legacy command/action tools retain the established safe semantics until migrated.
|
|
995
|
+
if (!tool.classify) {
|
|
996
|
+
if (command !== null) {
|
|
997
|
+
const parts = splitCompound(command);
|
|
998
|
+
const readOnly = !Boolean(input.background)
|
|
999
|
+
&& !!parts?.length
|
|
1000
|
+
&& parts.every(isReadOnlyCommand);
|
|
1001
|
+
operation = readOnly
|
|
1002
|
+
? { effect: "read", concurrencySafe: true }
|
|
1003
|
+
: { effect: "exec", concurrencySafe: false };
|
|
1004
|
+
}
|
|
1005
|
+
else if ((tool.name === "task" || tool.name === "cronjob") && input.action === "list") {
|
|
1006
|
+
operation = { effect: "read", concurrencySafe: true };
|
|
1007
|
+
}
|
|
1008
|
+
else if (tool.name === "job" && input.action === "kill") {
|
|
1009
|
+
operation = { effect: "exec", concurrencySafe: false, destructive: true };
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const approvalKind = approvalKindForOperation(operation);
|
|
834
1013
|
const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? input.server ?? "")
|
|
835
1014
|
.replace(/\s+/g, " ")
|
|
836
1015
|
.trim());
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
// an explicit `change` brief. Opaque external tools always require a brief and retain their separate
|
|
840
|
-
// per-action approval boundary.
|
|
841
|
-
const command = tool.kind === "exec" && typeof input.command === "string" ? input.command : null;
|
|
1016
|
+
// Command rules apply only to the concrete shell tool. Input-level traits independently answer whether
|
|
1017
|
+
// this exact operation mutates state; an allow rule must never turn a mutation into an investigation.
|
|
842
1018
|
const cmdDecision = command !== null ? decideCommand(command, permRules) : null;
|
|
843
|
-
// Permission rules answer whether a command may auto-run; they do not answer whether it mutates state.
|
|
844
|
-
// In particular, an explicit `allow: ["git commit"]` must not let an `investigate` brief perform a
|
|
845
|
-
// commit. Classify read-only semantics independently for the understanding boundary.
|
|
846
|
-
const commandParts = command !== null ? splitCompound(command) : null;
|
|
847
|
-
const commandReadOnly = !!commandParts?.length && commandParts.every(isReadOnlyCommand);
|
|
848
|
-
// Bash intentionally treats `background` as a boolean. Be conservative at the policy boundary even
|
|
849
|
-
// for a malformed model call: any truthy value would otherwise reach a truthiness-based/custom exec
|
|
850
|
-
// implementation as a process start and bypass an investigate brief.
|
|
851
|
-
const startsBackgroundProcess = tool.kind === "exec" && Boolean(input.background);
|
|
852
|
-
const stopsBackgroundProcess = tool.name === "job" && input.action === "kill";
|
|
853
1019
|
if (opts.taskIntake && tool.name !== "task_intake") {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
const readOnlyAction = (tool.name === "task" || tool.name === "cronjob") &&
|
|
858
|
-
input.action === "list";
|
|
859
|
-
const operationReadOnly = readOnlyAction ||
|
|
860
|
-
(tool.kind === "read" && !stopsBackgroundProcess) ||
|
|
861
|
-
(tool.kind === "exec" && commandReadOnly && !startsBackgroundProcess);
|
|
1020
|
+
const operationReadOnly = operation.effect === "read"
|
|
1021
|
+
|| operation.effect === "state"
|
|
1022
|
+
|| operation.effect === "interactive";
|
|
862
1023
|
const needsBrief = tool.trustBoundary === "external" || !operationReadOnly;
|
|
863
1024
|
const requiresChange = tool.trustBoundary !== "external" &&
|
|
864
1025
|
!operationReadOnly &&
|
|
865
|
-
(
|
|
866
|
-
tool.kind === "computer" ||
|
|
867
|
-
stopsBackgroundProcess ||
|
|
868
|
-
startsBackgroundProcess ||
|
|
869
|
-
(tool.kind === "exec" && !commandReadOnly));
|
|
1026
|
+
(operation.effect === "edit" || operation.effect === "exec" || operation.effect === "computer");
|
|
870
1027
|
if (taskBriefTransitionInRound && (requiresChange || tool.trustBoundary === "external")) {
|
|
871
1028
|
plans.push({
|
|
872
1029
|
tu,
|
|
@@ -898,7 +1055,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
898
1055
|
}
|
|
899
1056
|
// Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
|
|
900
1057
|
// and even full-auto must never silently turn them into a side channel.
|
|
901
|
-
const alwaysGate =
|
|
1058
|
+
const alwaysGate = approvalKind === "computer" || tool.trustBoundary === "external";
|
|
902
1059
|
if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
903
1060
|
plans.push({
|
|
904
1061
|
tu,
|
|
@@ -919,13 +1076,16 @@ async function runAgentInner(history, opts, life) {
|
|
|
919
1076
|
// commands classify `low` (pure Node, no LLM) and skip everything below — zero added latency. Only a
|
|
920
1077
|
// genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
|
|
921
1078
|
if (guardianOn && !breakerHalt) {
|
|
922
|
-
const risk = classifyRisk(tu.name,
|
|
1079
|
+
const risk = classifyRisk(tu.name, approvalKind, input, ctx.cwd);
|
|
1080
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1081
|
+
return finalizeStoppedToolRound();
|
|
923
1082
|
if (risk.level === "high") {
|
|
924
1083
|
const safeRiskReason = redactToolSubprocessOutput(risk.reason);
|
|
925
1084
|
const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
|
|
926
1085
|
let verdictResult;
|
|
927
1086
|
try {
|
|
928
|
-
|
|
1087
|
+
const guardianTurn = Promise.resolve().then(() => guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: runSignal, onProviderTurn: opts.onProviderTurn }));
|
|
1088
|
+
verdictResult = await bounded(guardianTurn);
|
|
929
1089
|
}
|
|
930
1090
|
catch (error) {
|
|
931
1091
|
if (runSignal.aborted)
|
|
@@ -934,6 +1094,8 @@ async function runAgentInner(history, opts, life) {
|
|
|
934
1094
|
}
|
|
935
1095
|
if (verdictResult === RUN_STOPPED)
|
|
936
1096
|
return finalizeStoppedToolRound();
|
|
1097
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1098
|
+
return finalizeStoppedToolRound();
|
|
937
1099
|
const verdict = verdictResult;
|
|
938
1100
|
if (verdict.decision === "block") {
|
|
939
1101
|
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
@@ -958,7 +1120,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
958
1120
|
let contResult;
|
|
959
1121
|
try {
|
|
960
1122
|
contResult = interactive
|
|
961
|
-
? await bounded(Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal)))
|
|
1123
|
+
? await bounded(waitForHuman(opts, life, () => Promise.resolve().then(() => opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`, runSignal))))
|
|
962
1124
|
: false;
|
|
963
1125
|
}
|
|
964
1126
|
catch (error) {
|
|
@@ -981,11 +1143,11 @@ async function runAgentInner(history, opts, life) {
|
|
|
981
1143
|
}
|
|
982
1144
|
}
|
|
983
1145
|
}
|
|
984
|
-
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(
|
|
1146
|
+
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(approvalKind, opts.approval) && !opts.autoApprove?.has(tu.name));
|
|
985
1147
|
if (shouldConfirm) {
|
|
986
1148
|
let replyResult;
|
|
987
1149
|
try {
|
|
988
|
-
replyResult = await bounded(Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal)));
|
|
1150
|
+
replyResult = await bounded(waitForHuman(opts, life, () => Promise.resolve().then(() => opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`, runSignal))));
|
|
989
1151
|
}
|
|
990
1152
|
catch (error) {
|
|
991
1153
|
if (runSignal.aborted)
|
|
@@ -1002,7 +1164,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
1002
1164
|
if (reply === "always" && !alwaysGate)
|
|
1003
1165
|
opts.autoApprove?.add(tu.name); // computer: treat "always" as one-time yes
|
|
1004
1166
|
}
|
|
1005
|
-
plans.push({ tu, tool });
|
|
1167
|
+
plans.push({ tu, tool, operation, approvalKind });
|
|
1006
1168
|
if (!opts.quiet) {
|
|
1007
1169
|
const pv = preview ? preview.slice(0, 80) : "";
|
|
1008
1170
|
if (sink)
|
|
@@ -1012,10 +1174,10 @@ async function runAgentInner(history, opts, life) {
|
|
|
1012
1174
|
}
|
|
1013
1175
|
}
|
|
1014
1176
|
// Execute: read-only tools run concurrently; edit/exec run alone, in order.
|
|
1015
|
-
if (runSignal.aborted)
|
|
1177
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1016
1178
|
return finalizeStoppedToolRound();
|
|
1017
1179
|
const runOne = async (idx, p) => {
|
|
1018
|
-
if (runSignal.aborted)
|
|
1180
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1019
1181
|
return;
|
|
1020
1182
|
if (repeatHalt) {
|
|
1021
1183
|
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 };
|
|
@@ -1042,7 +1204,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
1042
1204
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
1043
1205
|
return;
|
|
1044
1206
|
}
|
|
1045
|
-
if (runSignal.aborted)
|
|
1207
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1046
1208
|
return;
|
|
1047
1209
|
const pre = opts.hooks === false
|
|
1048
1210
|
? { block: false, message: "" }
|
|
@@ -1051,7 +1213,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
1051
1213
|
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 };
|
|
1052
1214
|
return;
|
|
1053
1215
|
}
|
|
1054
|
-
if (runSignal.aborted)
|
|
1216
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1055
1217
|
return;
|
|
1056
1218
|
// Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
|
|
1057
1219
|
// sub-agents read broadly — their files aren't "what the user was working on").
|
|
@@ -1062,9 +1224,17 @@ async function runAgentInner(history, opts, life) {
|
|
|
1062
1224
|
// A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
|
|
1063
1225
|
// completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
|
|
1064
1226
|
let settled;
|
|
1065
|
-
|
|
1227
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1228
|
+
return;
|
|
1229
|
+
const executionToolCtx = p.tool === askUserTool && askWithRunCancellation
|
|
1230
|
+
? {
|
|
1231
|
+
...toolCtx,
|
|
1232
|
+
ask: (question, options, signal) => waitForHuman(opts, life, () => askWithRunCancellation(question, options, signal)),
|
|
1233
|
+
}
|
|
1234
|
+
: toolCtx;
|
|
1235
|
+
const observedTool = p.tool.run(p.tu.input, executionToolCtx).then((value) => { settled = { ok: true, value }; return value; }, (error) => { settled = { ok: false, error }; throw error; });
|
|
1066
1236
|
try {
|
|
1067
|
-
opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.
|
|
1237
|
+
opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.approvalKind });
|
|
1068
1238
|
}
|
|
1069
1239
|
catch { /* observers cannot affect execution */ }
|
|
1070
1240
|
const toolResult = await bounded(observedTool);
|
|
@@ -1081,7 +1251,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
1081
1251
|
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) };
|
|
1082
1252
|
// The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
|
|
1083
1253
|
// actual result in the closing tool round, but do not run any post hook or later tool afterward.
|
|
1084
|
-
if (runSignal.aborted)
|
|
1254
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1085
1255
|
return;
|
|
1086
1256
|
if (opts.hooks !== false) {
|
|
1087
1257
|
await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
|
|
@@ -1097,7 +1267,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
1097
1267
|
activity.dec();
|
|
1098
1268
|
}
|
|
1099
1269
|
};
|
|
1100
|
-
let batch = []; // indices of
|
|
1270
|
+
let batch = []; // indices of input-classified concurrency-safe operations
|
|
1101
1271
|
const flush = async () => {
|
|
1102
1272
|
if (!batch.length)
|
|
1103
1273
|
return;
|
|
@@ -1106,27 +1276,27 @@ async function runAgentInner(history, opts, life) {
|
|
|
1106
1276
|
await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
|
|
1107
1277
|
};
|
|
1108
1278
|
for (let i = 0; i < plans.length; i++) {
|
|
1109
|
-
if (runSignal.aborted)
|
|
1279
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1110
1280
|
return finalizeStoppedToolRound();
|
|
1111
1281
|
const p = plans[i];
|
|
1112
|
-
|
|
1113
|
-
// task_intake is also a state transition: run it alone so its persisted brief has a deterministic
|
|
1114
|
-
// boundary before the next model round. Flush other reads before either.
|
|
1115
|
-
if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user" && p.tool.name !== "task_intake") {
|
|
1282
|
+
if (p.denied === undefined && p.operation?.concurrencySafe === true) {
|
|
1116
1283
|
batch.push(i); // safe → accumulate to run concurrently
|
|
1117
1284
|
}
|
|
1118
1285
|
else {
|
|
1119
|
-
await flush(); // flush
|
|
1120
|
-
if (runSignal.aborted)
|
|
1286
|
+
await flush(); // flush safe operations before a serial/shared-state operation
|
|
1287
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1121
1288
|
return finalizeStoppedToolRound();
|
|
1122
1289
|
await runOne(i, p);
|
|
1123
|
-
if (runSignal.aborted)
|
|
1290
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1124
1291
|
return finalizeStoppedToolRound();
|
|
1125
1292
|
}
|
|
1126
1293
|
}
|
|
1127
1294
|
await flush();
|
|
1128
|
-
if (runSignal.aborted)
|
|
1295
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1129
1296
|
return finalizeStoppedToolRound();
|
|
1297
|
+
const boundedContents = limitToolResultBatch(results.map((result) => result.content));
|
|
1298
|
+
for (let i = 0; i < results.length; i++)
|
|
1299
|
+
results[i].content = boundedContents[i];
|
|
1130
1300
|
history.push({ role: "tool", results });
|
|
1131
1301
|
if (intakeDirty && intakeTask) {
|
|
1132
1302
|
try {
|
|
@@ -1189,9 +1359,11 @@ async function runAgentInner(history, opts, life) {
|
|
|
1189
1359
|
return { status: "halted" };
|
|
1190
1360
|
}
|
|
1191
1361
|
if (guard && !nudged) {
|
|
1192
|
-
for (const p of plans)
|
|
1193
|
-
if (p.tool && p.
|
|
1362
|
+
for (const p of plans) {
|
|
1363
|
+
if (p.tool && p.operation && p.operation.effect !== "read" && p.operation.effect !== "state" && p.operation.effect !== "interactive") {
|
|
1194
1364
|
toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1195
1367
|
for (const res of results)
|
|
1196
1368
|
if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
|
|
1197
1369
|
blindShots++;
|