@nanhara/hara 0.125.1 → 0.126.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/CHANGELOG.md +76 -0
- package/README.md +22 -8
- package/SECURITY.md +6 -4
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +413 -77
- 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 +439 -227
- package/dist/mcp/client.js +73 -6
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/models.js +39 -6
- package/dist/providers/openai.js +6 -1
- package/dist/providers/reasoning.js +12 -0
- 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 +157 -40
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- 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 +22 -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 +49 -17
- package/dist/tui/model-picker.js +11 -8
- 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";
|
|
@@ -6,7 +7,7 @@ import { makeRenderer } from "../md.js";
|
|
|
6
7
|
import { skillsDigest } from "../skills/skills.js";
|
|
7
8
|
import { runHooks } from "../hooks.js";
|
|
8
9
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
|
-
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
|
+
import { decideCommand, isReadOnlyCommand, loadPermissionRules, splitCompound, } from "../security/permissions.js";
|
|
10
11
|
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
11
12
|
import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
|
|
12
13
|
import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
|
|
@@ -21,8 +22,13 @@ import { redactSensitiveText } from "../security/secrets.js";
|
|
|
21
22
|
import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
22
23
|
import { prepareHistoryForModel } from "./context-budget.js";
|
|
23
24
|
import { rolesDigest } from "../org/roles.js";
|
|
25
|
+
import { applyTaskBrief } from "../session/task.js";
|
|
26
|
+
import { askUserTool } from "../tools/ask_user.js";
|
|
24
27
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
25
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"]);
|
|
26
32
|
/** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
|
|
27
33
|
* stalled connection and aborted into the normal error→failover path — instead of hanging on
|
|
28
34
|
* "working Ns" forever (the "pressed Enter, thought it failed" report). Generous default because
|
|
@@ -86,7 +92,12 @@ mask to existing protected paths; Linux/Windows shell checks are
|
|
|
86
92
|
static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
|
|
87
93
|
only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
|
|
88
94
|
are disabled without an interactive approval channel unless the user launched with
|
|
89
|
-
HARA_ALLOW_TRUSTED_EXTENSIONS=1.
|
|
95
|
+
HARA_ALLOW_TRUSTED_EXTENSIONS=1. Configured MCP servers stay stopped by default; when a task materially needs
|
|
96
|
+
one, call \`mcp_connect\` for that server only, then use the newly available tools on the next round. Never
|
|
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.
|
|
90
101
|
For broad,
|
|
91
102
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
92
103
|
independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
|
|
@@ -142,14 +153,34 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
|
|
|
142
153
|
"re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
|
|
143
154
|
"and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
|
|
144
155
|
"directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
|
|
145
|
-
function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext) {
|
|
156
|
+
function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
|
|
146
157
|
const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
|
|
147
158
|
const skills = skillsDigest(cwd);
|
|
148
159
|
const roles = override ? "" : rolesDigest(cwd);
|
|
160
|
+
const intakeContext = !intake?.enabled
|
|
161
|
+
? ""
|
|
162
|
+
: intake.brief
|
|
163
|
+
? ("\n\n# Understanding → execution boundary\n" +
|
|
164
|
+
"The task brief below is the accepted interpretation for this run. Keep actions inside it. If new " +
|
|
165
|
+
"user input materially changes the goal, constraints, acceptance checks, or intended side effects, " +
|
|
166
|
+
"call `task_intake` again before further side effects.\n" +
|
|
167
|
+
`Intent: ${intake.brief.intent}\n` +
|
|
168
|
+
`Goal: ${intake.brief.goal}\n` +
|
|
169
|
+
`Constraints:\n${intake.brief.constraints.map((item) => `- ${item}`).join("\n")}\n` +
|
|
170
|
+
`Acceptance:\n${intake.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
|
|
171
|
+
`Steps:\n${intake.brief.steps.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
|
|
172
|
+
: ("\n\n# Understanding → execution boundary\n" +
|
|
173
|
+
"Do not jump from a raw request straight into side effects. You may answer, inspect files, search, " +
|
|
174
|
+
"ask a necessary question, or build a todo list first. BEFORE the first edit, non-read-only command, " +
|
|
175
|
+
"background-process start/stop, computer action, external agent, or MCP connection, call `task_intake` in its OWN tool round with " +
|
|
176
|
+
"the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
|
|
177
|
+
"for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
|
|
178
|
+
"you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
|
|
149
179
|
return (head +
|
|
150
180
|
gatewayNote() +
|
|
151
181
|
(continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
|
|
152
182
|
(executionContext ? `\n\n${executionContext}` : "") +
|
|
183
|
+
intakeContext +
|
|
153
184
|
(projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
|
|
154
185
|
(memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
|
|
155
186
|
(roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
|
|
@@ -158,7 +189,7 @@ function composeSystem(cwd, projectContext, override, memory, continuationSessio
|
|
|
158
189
|
const RUN_STOPPED = Symbol("agent-run-stopped");
|
|
159
190
|
const REPEATED_FAILURE_LIMIT = 3;
|
|
160
191
|
export function deadlineCheckpointReminder(timeoutMs) {
|
|
161
|
-
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. ` +
|
|
162
193
|
"Stop expanding scope. Finish only the current atomic step, persist any usable artifact, update todo_write, " +
|
|
163
194
|
"and reply with the completed checkpoint plus the next exact step. Do not start another generation batch, " +
|
|
164
195
|
"install, full validation suite, preview, render, deployment, or other multi-minute stage in this turn. " +
|
|
@@ -183,39 +214,149 @@ function requestRunCheckpoint(opts, life) {
|
|
|
183
214
|
if (life.disposed || life.checkpointDue || life.signal.aborted)
|
|
184
215
|
return;
|
|
185
216
|
life.checkpointDue = true;
|
|
186
|
-
const remainingMs = Math.max(0, life.timeoutMs - (
|
|
187
|
-
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.`);
|
|
188
219
|
}
|
|
189
220
|
function warnRun(opts, life) {
|
|
190
221
|
if (life.disposed || life.warned || life.signal.aborted)
|
|
191
222
|
return;
|
|
192
223
|
life.warned = true;
|
|
193
|
-
const elapsedMs =
|
|
224
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
194
225
|
const remainingMs = Math.max(0, life.timeoutMs - elapsedMs);
|
|
195
|
-
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
|
+
}
|
|
196
353
|
}
|
|
197
354
|
function createRunLifecycle(opts) {
|
|
198
355
|
const timeoutMs = agentRunTimeoutMs(opts.timeoutMs);
|
|
199
356
|
const maxRounds = agentMaxRounds(opts.maxRounds);
|
|
200
357
|
const timeoutController = new AbortController();
|
|
201
358
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutController.signal]) : timeoutController.signal;
|
|
202
|
-
const
|
|
203
|
-
const life = {};
|
|
204
|
-
const timeoutTimer = setTimeout(() => {
|
|
205
|
-
if (life.disposed || signal.aborted)
|
|
206
|
-
return;
|
|
207
|
-
life.timedOut = true;
|
|
208
|
-
timeoutController.abort(new Error("agent run deadline reached"));
|
|
209
|
-
}, timeoutMs);
|
|
210
|
-
// This timer is the hard boundary for a provider/tool Promise that owns no event-loop handles. Keep it
|
|
211
|
-
// referenced while the run is active; unref would let headless Node exit with the run still unresolved.
|
|
212
|
-
// Long legitimate work gets an in-band heads-up; a fast active loop gets the same warning at 75% rounds.
|
|
213
|
-
const warningDelay = Math.min(5 * 60_000, Math.max(250, Math.floor(timeoutMs * 0.8)));
|
|
214
|
-
const warningTimer = setTimeout(() => warnRun(opts, life), warningDelay);
|
|
215
|
-
warningTimer.unref?.();
|
|
216
|
-
const checkpointDelay = Math.max(250, Math.floor(timeoutMs * 0.8));
|
|
217
|
-
const checkpointTimer = setTimeout(() => requestRunCheckpoint(opts, life), checkpointDelay);
|
|
218
|
-
checkpointTimer.unref?.();
|
|
359
|
+
const activeStartedAt = Date.now();
|
|
219
360
|
let removeStopListener = () => { };
|
|
220
361
|
const stopPromise = new Promise((resolveStopped) => {
|
|
221
362
|
const stopped = () => resolveStopped(RUN_STOPPED);
|
|
@@ -225,15 +366,17 @@ function createRunLifecycle(opts) {
|
|
|
225
366
|
else
|
|
226
367
|
signal.addEventListener("abort", stopped, { once: true });
|
|
227
368
|
});
|
|
228
|
-
|
|
369
|
+
const life = {
|
|
229
370
|
signal,
|
|
230
371
|
timeoutController,
|
|
231
|
-
timeoutTimer,
|
|
232
|
-
warningTimer,
|
|
233
|
-
checkpointTimer,
|
|
372
|
+
timeoutTimer: null,
|
|
373
|
+
warningTimer: null,
|
|
374
|
+
checkpointTimer: null,
|
|
234
375
|
stopPromise,
|
|
235
376
|
removeStopListener,
|
|
236
|
-
|
|
377
|
+
activeStartedAt,
|
|
378
|
+
activeElapsedMs: 0,
|
|
379
|
+
pauseDepth: 0,
|
|
237
380
|
timeoutMs,
|
|
238
381
|
maxRounds,
|
|
239
382
|
rounds: 0,
|
|
@@ -244,20 +387,19 @@ function createRunLifecycle(opts) {
|
|
|
244
387
|
limitAnnounced: false,
|
|
245
388
|
disposed: false,
|
|
246
389
|
failedCalls: new Map(),
|
|
247
|
-
}
|
|
390
|
+
};
|
|
391
|
+
armRunTimers(opts, life);
|
|
248
392
|
return life;
|
|
249
393
|
}
|
|
250
394
|
function disposeRunLifecycle(life) {
|
|
251
395
|
life.disposed = true;
|
|
252
|
-
|
|
253
|
-
clearTimeout(life.warningTimer);
|
|
254
|
-
clearTimeout(life.checkpointTimer);
|
|
396
|
+
clearRunTimers(life);
|
|
255
397
|
life.removeStopListener();
|
|
256
398
|
}
|
|
257
399
|
function hardStop(opts, life, kind, detail) {
|
|
258
|
-
const elapsedMs =
|
|
400
|
+
const elapsedMs = runActiveElapsedMs(life);
|
|
259
401
|
const message = kind === "deadline"
|
|
260
|
-
? `⏸ 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).`
|
|
261
403
|
: kind === "max_rounds"
|
|
262
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.`
|
|
263
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.`;
|
|
@@ -285,7 +427,82 @@ export async function runAgent(history, opts) {
|
|
|
285
427
|
async function runAgentInner(history, opts, life) {
|
|
286
428
|
const { provider, ctx } = opts;
|
|
287
429
|
const runSignal = life.signal;
|
|
288
|
-
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
|
+
};
|
|
457
|
+
let intakeTask = opts.taskIntake?.task;
|
|
458
|
+
let intakeDirty = false;
|
|
459
|
+
const syncIntakeTask = () => {
|
|
460
|
+
const current = opts.taskIntake?.current?.();
|
|
461
|
+
if (current && (!intakeTask || current.id === intakeTask.id))
|
|
462
|
+
intakeTask = current;
|
|
463
|
+
};
|
|
464
|
+
const taskIntakeTool = opts.taskIntake
|
|
465
|
+
? {
|
|
466
|
+
name: "task_intake",
|
|
467
|
+
description: "Record or revise your explicit understanding of the active task before side effects. Call this " +
|
|
468
|
+
"in its own tool round only after using the conversation and any needed read-only evidence to identify " +
|
|
469
|
+
"the real goal. Required before edits, non-read-only commands, background-process start/stop, computer actions, external agents, or MCP.",
|
|
470
|
+
input_schema: {
|
|
471
|
+
type: "object",
|
|
472
|
+
properties: {
|
|
473
|
+
intent: {
|
|
474
|
+
type: "string",
|
|
475
|
+
enum: ["answer", "investigate", "change"],
|
|
476
|
+
description: "answer = direct response, investigate = evidence/diagnosis, change = modify or deliver",
|
|
477
|
+
},
|
|
478
|
+
goal: { type: "string", description: "One concrete interpreted outcome; include the actual target, not generic 'help the user'." },
|
|
479
|
+
constraints: { type: "array", items: { type: "string" }, description: "User/project boundaries that must remain true." },
|
|
480
|
+
acceptance: { type: "array", items: { type: "string" }, description: "Observable checks that prove the task is done." },
|
|
481
|
+
steps: { type: "array", items: { type: "string" }, description: "Short ordered approach, normally 2–6 steps." },
|
|
482
|
+
},
|
|
483
|
+
required: ["intent", "goal", "constraints", "acceptance", "steps"],
|
|
484
|
+
},
|
|
485
|
+
kind: "read",
|
|
486
|
+
classify: () => ({ effect: "state", concurrencySafe: false }),
|
|
487
|
+
run: async (input) => {
|
|
488
|
+
syncIntakeTask();
|
|
489
|
+
const applied = applyTaskBrief(intakeTask, input);
|
|
490
|
+
if (!applied.ok)
|
|
491
|
+
return `Error: task brief rejected — ${applied.reason}`;
|
|
492
|
+
intakeTask = applied.task;
|
|
493
|
+
intakeDirty = true;
|
|
494
|
+
return (`Task brief accepted (${applied.brief.intent}).\n` +
|
|
495
|
+
`Goal: ${applied.brief.goal}\n` +
|
|
496
|
+
`Acceptance:\n${applied.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
|
|
497
|
+
"Proceed within this brief; revise task_intake first if the user's intent materially changes.");
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
: undefined;
|
|
501
|
+
// `task_intake` is engine-owned and cannot be shadowed by an ad-hoc tool with the same name.
|
|
502
|
+
const runExtraTools = [
|
|
503
|
+
...(opts.extraTools ?? []).filter((tool) => tool.name !== "task_intake"),
|
|
504
|
+
...(taskIntakeTool ? [taskIntakeTool] : []),
|
|
505
|
+
];
|
|
289
506
|
const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
|
|
290
507
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
291
508
|
let triedFallback = false;
|
|
@@ -347,14 +564,14 @@ async function runAgentInner(history, opts, life) {
|
|
|
347
564
|
for (;;) {
|
|
348
565
|
// A cancellation that already happened is authoritative: do not start pending-input work, a provider
|
|
349
566
|
// request, or any later tool round merely to give it an already-aborted signal.
|
|
350
|
-
if (runSignal.aborted)
|
|
567
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
351
568
|
return stoppedOutcome();
|
|
352
569
|
if (life.rounds >= life.maxRounds)
|
|
353
570
|
return hardStop(opts, life, "max_rounds");
|
|
354
571
|
life.rounds += 1;
|
|
355
572
|
if (life.rounds >= Math.ceil(life.maxRounds * 0.75))
|
|
356
573
|
warnRun(opts, life);
|
|
357
|
-
if (
|
|
574
|
+
if (runActiveElapsedMs(life) >= Math.floor(life.timeoutMs * 0.8))
|
|
358
575
|
requestRunCheckpoint(opts, life);
|
|
359
576
|
if (!opts.quiet && life.checkpointDue && !life.checkpointInjected) {
|
|
360
577
|
life.checkpointInjected = true;
|
|
@@ -381,6 +598,9 @@ async function runAgentInner(history, opts, life) {
|
|
|
381
598
|
return stoppedOutcome();
|
|
382
599
|
for (const m of pending)
|
|
383
600
|
history.push(m);
|
|
601
|
+
// pendingInput may have durably accepted steering into the owner's immutable task snapshot. Refresh
|
|
602
|
+
// before composing the system or applying a later brief so that state is never overwritten.
|
|
603
|
+
syncIntakeTask();
|
|
384
604
|
}
|
|
385
605
|
// system-reminder injection: event-driven context queued since the last call (todo staleness today)
|
|
386
606
|
// lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
|
|
@@ -390,12 +610,15 @@ async function runAgentInner(history, opts, life) {
|
|
|
390
610
|
if (reminders.length)
|
|
391
611
|
history.push({ role: "user", content: wrapReminders(reminders) });
|
|
392
612
|
}
|
|
393
|
-
const
|
|
394
|
-
const
|
|
395
|
-
?
|
|
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;
|
|
617
|
+
const specs = runExtraTools.length
|
|
618
|
+
? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
|
|
396
619
|
: baseSpecs;
|
|
397
620
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
398
|
-
const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext);
|
|
621
|
+
const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
|
|
399
622
|
const prepared = prepareHistoryForModel(history, {
|
|
400
623
|
model: activeProvider.model,
|
|
401
624
|
system,
|
|
@@ -486,7 +709,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
486
709
|
// immediately before the provider side effect starts. Promise.resolve(activeProvider.turn(...)) is
|
|
487
710
|
// insufficient here: a custom provider can throw synchronously before Promise.resolve ever sees it.
|
|
488
711
|
const providerTurn = Promise.resolve().then(() => {
|
|
489
|
-
if (attempt.signal.aborted || runSignal.aborted) {
|
|
712
|
+
if (expireRunBudgetIfNeeded(life) || attempt.signal.aborted || runSignal.aborted) {
|
|
490
713
|
return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
491
714
|
}
|
|
492
715
|
return activeProvider.turn({
|
|
@@ -592,7 +815,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
592
815
|
}
|
|
593
816
|
// A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
|
|
594
817
|
// The original run signal is authoritative: do not append/approve/execute any late response.
|
|
595
|
-
if (runSignal.aborted)
|
|
818
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
596
819
|
return stoppedOutcome();
|
|
597
820
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
598
821
|
if (r.stop === "error") {
|
|
@@ -684,7 +907,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
684
907
|
const results = new Array(r.toolUses.length);
|
|
685
908
|
const finalizeStoppedToolRound = () => {
|
|
686
909
|
const pendingMessage = life.timedOut
|
|
687
|
-
? `Error: agent
|
|
910
|
+
? `Error: agent active-execution deadline ${formatAgentDuration(life.timeoutMs)} reached before this tool call completed.`
|
|
688
911
|
: "Error: interrupted before this tool call completed.";
|
|
689
912
|
history.push({
|
|
690
913
|
role: "tool",
|
|
@@ -711,7 +934,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
711
934
|
});
|
|
712
935
|
return outcome;
|
|
713
936
|
};
|
|
714
|
-
if (runSignal.aborted)
|
|
937
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
715
938
|
return finalizeStoppedToolRound();
|
|
716
939
|
let repeatHalt = null;
|
|
717
940
|
const noteCall = (name, input, content, isError = false) => {
|
|
@@ -736,9 +959,22 @@ async function runAgentInner(history, opts, life) {
|
|
|
736
959
|
};
|
|
737
960
|
const plans = [];
|
|
738
961
|
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
739
|
-
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
|
+
};
|
|
971
|
+
// Planning happens before dispatch, so a previously accepted `change` brief must not let the model
|
|
972
|
+
// revise that brief and perform a side effect in the same response. Treat every intake call as a
|
|
973
|
+
// transaction boundary for the whole response: accept/checkpoint the interpretation first, then let
|
|
974
|
+
// the next model round act against the newly authoritative brief.
|
|
975
|
+
const taskBriefTransitionInRound = r.toolUses.some((tu) => tu.name === "task_intake");
|
|
740
976
|
for (const tu of r.toolUses) {
|
|
741
|
-
if (runSignal.aborted)
|
|
977
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
742
978
|
return finalizeStoppedToolRound();
|
|
743
979
|
if (breakerHalt) {
|
|
744
980
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
@@ -752,12 +988,74 @@ async function runAgentInner(history, opts, life) {
|
|
|
752
988
|
continue;
|
|
753
989
|
}
|
|
754
990
|
const input = tu.input;
|
|
755
|
-
const
|
|
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);
|
|
1013
|
+
const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? input.server ?? "")
|
|
756
1014
|
.replace(/\s+/g, " ")
|
|
757
1015
|
.trim());
|
|
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.
|
|
1018
|
+
const cmdDecision = command !== null ? decideCommand(command, permRules) : null;
|
|
1019
|
+
if (opts.taskIntake && tool.name !== "task_intake") {
|
|
1020
|
+
const operationReadOnly = operation.effect === "read"
|
|
1021
|
+
|| operation.effect === "state"
|
|
1022
|
+
|| operation.effect === "interactive";
|
|
1023
|
+
const needsBrief = tool.trustBoundary === "external" || !operationReadOnly;
|
|
1024
|
+
const requiresChange = tool.trustBoundary !== "external" &&
|
|
1025
|
+
!operationReadOnly &&
|
|
1026
|
+
(operation.effect === "edit" || operation.effect === "exec" || operation.effect === "computer");
|
|
1027
|
+
if (taskBriefTransitionInRound && (requiresChange || tool.trustBoundary === "external")) {
|
|
1028
|
+
plans.push({
|
|
1029
|
+
tu,
|
|
1030
|
+
tool,
|
|
1031
|
+
denied: "Understanding gate: task_intake establishes or revises the brief in this tool round, so this " +
|
|
1032
|
+
"side effect was NOT executed. Wait for the next model round, then act against the checkpointed brief.",
|
|
1033
|
+
});
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
if (needsBrief && !intakeTask?.brief) {
|
|
1037
|
+
plans.push({
|
|
1038
|
+
tu,
|
|
1039
|
+
tool,
|
|
1040
|
+
denied: "Understanding gate: this action was NOT executed. First inspect/ask what is needed, then call " +
|
|
1041
|
+
"task_intake in its own tool round with goal, intent, constraints, acceptance, and steps.",
|
|
1042
|
+
});
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (requiresChange && intakeTask?.brief?.intent !== "change") {
|
|
1046
|
+
plans.push({
|
|
1047
|
+
tu,
|
|
1048
|
+
tool,
|
|
1049
|
+
denied: `Understanding gate: task brief intent is '${intakeTask?.brief?.intent ?? "unset"}', so this ` +
|
|
1050
|
+
"side effect was NOT executed. Revise task_intake to intent 'change' with the user's authorized " +
|
|
1051
|
+
"goal and acceptance checks before trying again.",
|
|
1052
|
+
});
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
758
1056
|
// Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
|
|
759
1057
|
// and even full-auto must never silently turn them into a side channel.
|
|
760
|
-
const alwaysGate =
|
|
1058
|
+
const alwaysGate = approvalKind === "computer" || tool.trustBoundary === "external";
|
|
761
1059
|
if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
762
1060
|
plans.push({
|
|
763
1061
|
tu,
|
|
@@ -769,7 +1067,6 @@ async function runAgentInner(history, opts, life) {
|
|
|
769
1067
|
}
|
|
770
1068
|
// Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
|
|
771
1069
|
// read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
|
|
772
|
-
const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
|
|
773
1070
|
if (cmdDecision === "deny") {
|
|
774
1071
|
plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
|
|
775
1072
|
continue;
|
|
@@ -779,13 +1076,16 @@ async function runAgentInner(history, opts, life) {
|
|
|
779
1076
|
// commands classify `low` (pure Node, no LLM) and skip everything below — zero added latency. Only a
|
|
780
1077
|
// genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
|
|
781
1078
|
if (guardianOn && !breakerHalt) {
|
|
782
|
-
const risk = classifyRisk(tu.name,
|
|
1079
|
+
const risk = classifyRisk(tu.name, approvalKind, input, ctx.cwd);
|
|
1080
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1081
|
+
return finalizeStoppedToolRound();
|
|
783
1082
|
if (risk.level === "high") {
|
|
784
1083
|
const safeRiskReason = redactToolSubprocessOutput(risk.reason);
|
|
785
1084
|
const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
|
|
786
1085
|
let verdictResult;
|
|
787
1086
|
try {
|
|
788
|
-
|
|
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);
|
|
789
1089
|
}
|
|
790
1090
|
catch (error) {
|
|
791
1091
|
if (runSignal.aborted)
|
|
@@ -794,6 +1094,8 @@ async function runAgentInner(history, opts, life) {
|
|
|
794
1094
|
}
|
|
795
1095
|
if (verdictResult === RUN_STOPPED)
|
|
796
1096
|
return finalizeStoppedToolRound();
|
|
1097
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
1098
|
+
return finalizeStoppedToolRound();
|
|
797
1099
|
const verdict = verdictResult;
|
|
798
1100
|
if (verdict.decision === "block") {
|
|
799
1101
|
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
@@ -818,7 +1120,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
818
1120
|
let contResult;
|
|
819
1121
|
try {
|
|
820
1122
|
contResult = interactive
|
|
821
|
-
? 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))))
|
|
822
1124
|
: false;
|
|
823
1125
|
}
|
|
824
1126
|
catch (error) {
|
|
@@ -841,11 +1143,11 @@ async function runAgentInner(history, opts, life) {
|
|
|
841
1143
|
}
|
|
842
1144
|
}
|
|
843
1145
|
}
|
|
844
|
-
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(
|
|
1146
|
+
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(approvalKind, opts.approval) && !opts.autoApprove?.has(tu.name));
|
|
845
1147
|
if (shouldConfirm) {
|
|
846
1148
|
let replyResult;
|
|
847
1149
|
try {
|
|
848
|
-
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))));
|
|
849
1151
|
}
|
|
850
1152
|
catch (error) {
|
|
851
1153
|
if (runSignal.aborted)
|
|
@@ -862,7 +1164,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
862
1164
|
if (reply === "always" && !alwaysGate)
|
|
863
1165
|
opts.autoApprove?.add(tu.name); // computer: treat "always" as one-time yes
|
|
864
1166
|
}
|
|
865
|
-
plans.push({ tu, tool });
|
|
1167
|
+
plans.push({ tu, tool, operation, approvalKind });
|
|
866
1168
|
if (!opts.quiet) {
|
|
867
1169
|
const pv = preview ? preview.slice(0, 80) : "";
|
|
868
1170
|
if (sink)
|
|
@@ -872,10 +1174,10 @@ async function runAgentInner(history, opts, life) {
|
|
|
872
1174
|
}
|
|
873
1175
|
}
|
|
874
1176
|
// Execute: read-only tools run concurrently; edit/exec run alone, in order.
|
|
875
|
-
if (runSignal.aborted)
|
|
1177
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
876
1178
|
return finalizeStoppedToolRound();
|
|
877
1179
|
const runOne = async (idx, p) => {
|
|
878
|
-
if (runSignal.aborted)
|
|
1180
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
879
1181
|
return;
|
|
880
1182
|
if (repeatHalt) {
|
|
881
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 };
|
|
@@ -902,7 +1204,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
902
1204
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + noteCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
903
1205
|
return;
|
|
904
1206
|
}
|
|
905
|
-
if (runSignal.aborted)
|
|
1207
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
906
1208
|
return;
|
|
907
1209
|
const pre = opts.hooks === false
|
|
908
1210
|
? { block: false, message: "" }
|
|
@@ -911,7 +1213,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
911
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 };
|
|
912
1214
|
return;
|
|
913
1215
|
}
|
|
914
|
-
if (runSignal.aborted)
|
|
1216
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
915
1217
|
return;
|
|
916
1218
|
// Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
|
|
917
1219
|
// sub-agents read broadly — their files aren't "what the user was working on").
|
|
@@ -922,9 +1224,17 @@ async function runAgentInner(history, opts, life) {
|
|
|
922
1224
|
// A plain Promise.race can let the abort branch win the same microtask turn and falsely report the
|
|
923
1225
|
// completed action as not run. Non-cooperative pending tools still lose to the hard stop immediately.
|
|
924
1226
|
let settled;
|
|
925
|
-
|
|
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; });
|
|
926
1236
|
try {
|
|
927
|
-
opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.
|
|
1237
|
+
opts.onToolRun?.(observedTool, { name: p.tool.name, kind: p.approvalKind });
|
|
928
1238
|
}
|
|
929
1239
|
catch { /* observers cannot affect execution */ }
|
|
930
1240
|
const toolResult = await bounded(observedTool);
|
|
@@ -941,7 +1251,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
941
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) };
|
|
942
1252
|
// The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
|
|
943
1253
|
// actual result in the closing tool round, but do not run any post hook or later tool afterward.
|
|
944
|
-
if (runSignal.aborted)
|
|
1254
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
945
1255
|
return;
|
|
946
1256
|
if (opts.hooks !== false) {
|
|
947
1257
|
await runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd, 30_000, runSignal); // observe-only
|
|
@@ -957,7 +1267,7 @@ async function runAgentInner(history, opts, life) {
|
|
|
957
1267
|
activity.dec();
|
|
958
1268
|
}
|
|
959
1269
|
};
|
|
960
|
-
let batch = []; // indices of
|
|
1270
|
+
let batch = []; // indices of input-classified concurrency-safe operations
|
|
961
1271
|
const flush = async () => {
|
|
962
1272
|
if (!batch.length)
|
|
963
1273
|
return;
|
|
@@ -966,27 +1276,51 @@ async function runAgentInner(history, opts, life) {
|
|
|
966
1276
|
await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
|
|
967
1277
|
};
|
|
968
1278
|
for (let i = 0; i < plans.length; i++) {
|
|
969
|
-
if (runSignal.aborted)
|
|
1279
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
970
1280
|
return finalizeStoppedToolRound();
|
|
971
1281
|
const p = plans[i];
|
|
972
|
-
|
|
973
|
-
// Flush other reads and ask sequentially so two questions cannot overwrite each other and hang.
|
|
974
|
-
if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user") {
|
|
1282
|
+
if (p.denied === undefined && p.operation?.concurrencySafe === true) {
|
|
975
1283
|
batch.push(i); // safe → accumulate to run concurrently
|
|
976
1284
|
}
|
|
977
1285
|
else {
|
|
978
|
-
await flush(); // flush
|
|
979
|
-
if (runSignal.aborted)
|
|
1286
|
+
await flush(); // flush safe operations before a serial/shared-state operation
|
|
1287
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
980
1288
|
return finalizeStoppedToolRound();
|
|
981
1289
|
await runOne(i, p);
|
|
982
|
-
if (runSignal.aborted)
|
|
1290
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
983
1291
|
return finalizeStoppedToolRound();
|
|
984
1292
|
}
|
|
985
1293
|
}
|
|
986
1294
|
await flush();
|
|
987
|
-
if (runSignal.aborted)
|
|
1295
|
+
if (expireRunBudgetIfNeeded(life) || runSignal.aborted)
|
|
988
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];
|
|
989
1300
|
history.push({ role: "tool", results });
|
|
1301
|
+
if (intakeDirty && intakeTask) {
|
|
1302
|
+
try {
|
|
1303
|
+
// The tool-use/result pair is now protocol-complete. Persist here—never inside the tool—so a crash
|
|
1304
|
+
// cannot leave a session ending in an orphaned tool_use, and no later side effect starts before the
|
|
1305
|
+
// accepted understanding has a durable checkpoint. A steer can be acknowledged while another read
|
|
1306
|
+
// from this same tool round is still settling, so merge from the authoritative owner once more at
|
|
1307
|
+
// this exact boundary instead of overwriting that acknowledged input with the earlier snapshot.
|
|
1308
|
+
const acceptedBrief = intakeTask.brief;
|
|
1309
|
+
syncIntakeTask();
|
|
1310
|
+
// The current owner may still carry the previous brief when this call is a revision. The accepted
|
|
1311
|
+
// brief is the state transition from this round; authoritative refresh contributes newer steering,
|
|
1312
|
+
// while this assignment contributes the new interpretation.
|
|
1313
|
+
if (acceptedBrief && intakeTask) {
|
|
1314
|
+
intakeTask = { ...intakeTask, brief: acceptedBrief };
|
|
1315
|
+
}
|
|
1316
|
+
opts.taskIntake?.onUpdate?.(intakeTask);
|
|
1317
|
+
opts.taskIntake?.onCheckpoint?.(intakeTask);
|
|
1318
|
+
intakeDirty = false;
|
|
1319
|
+
}
|
|
1320
|
+
catch (error) {
|
|
1321
|
+
return interactionFailure("task-intake checkpoint", error);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
990
1324
|
if (repeatHalt)
|
|
991
1325
|
return hardStop(opts, life, "repeat_loop", repeatHalt);
|
|
992
1326
|
// Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
|
|
@@ -1025,9 +1359,11 @@ async function runAgentInner(history, opts, life) {
|
|
|
1025
1359
|
return { status: "halted" };
|
|
1026
1360
|
}
|
|
1027
1361
|
if (guard && !nudged) {
|
|
1028
|
-
for (const p of plans)
|
|
1029
|
-
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") {
|
|
1030
1364
|
toolCounts.set(p.tu.name, (toolCounts.get(p.tu.name) ?? 0) + 1);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1031
1367
|
for (const res of results)
|
|
1032
1368
|
if (typeof res.content === "string" && /Configure a vision model/.test(res.content))
|
|
1033
1369
|
blindShots++;
|