mirror of
https://github.com/nexu-io/open-design.git
synced 2026-09-20 06:15:06 +08:00
ff41a437cc
* feat(daemon): guard against agents looping on failing tool calls Autonomous chat agents had no bound on repeated tool failures. An agent asked to move a button into a titlebar element it believed was called `titlebar-left` (the real class was `tb-left`) wrote a shell assertion for `titlebar-left`, it failed, the agent concluded the edit had not landed, re-read, and retried the same wrong assumption 80+ times with ~19 errors. The daemon streamed every attempt without noticing. The BYOK proxy path already bounds its tool loop at MAX_BYOK_TOOL_LOOPS; the autonomous agents had no equivalent. Add a run-scoped tool-loop guard that observes the normalized tool_use/tool_result events every agent path emits, so one instance covers Claude, Codex/OpenCode, Copilot, and ACP. Two triggers: N consecutive errored results (reset by any success), and K repeats of the same failing action signature (the fixation case). Two tiers: a one-shot `tool_loop` warning surfaced in the chat, then a hard-ceiling halt that terminates the run. Mode via OD_TOOL_LOOP_GUARD (halt|warn|off), default halt. - packages/contracts: `tool_loop` SSE payload and a TOOL_LOOP_DETECTED error. - apps/daemon: pure tool-loop-guard.ts plus tests, wired into both agent event paths in server.ts with an abortForToolLoop mirroring abortForRoleMarker. - apps/web: render the warning as a status line in the transcript. * fix(daemon): scope tool-loop repeated-failure to lack of progress, key on full signature Addresses the two blockers in the #3375 review. 1. The repeated-failure counter was cumulative across the whole run, so a progressing workflow (rerun the same check after each successful edit) could reach the halt ceiling even though each retry tested newly-written code. A successful tool result now clears the per-signature tallies, not just the consecutive streak, so repeated-failure means "failures since the last success." I pulled the conversation that motivated the guard: the loop was 11 back-to-back failing Edits with no success between them, so it still trips. 2. The signature was truncated to 160 chars before being used as the dedup key, so two distinct long commands sharing a prefix pooled each other's failures. computeToolSignature now returns the full signature for keying; a new displayToolSignature truncates only the form shown in the tool_loop event. Adds regression tests: same command with intervening successful edits does not halt, and two >160-char inputs sharing a prefix do not collide. * fix(daemon): route every agent runtime through one tool-loop choke point The Copilot (copilot-stream-json) and ACP (acp-json-rpc) branches emitted agent events with a bare send('agent', ev), bypassing observeToolEventForLoop. So a Copilot or ACP run could repeat a failing tool call indefinitely while Claude / OpenCode / Qoder runs were stopped, which contradicted the "every agent path" claim. Add an emitAgentEvent choke point that sends the event and then feeds it to the guard, and route every stream handler through it (sendAgentEvent, the Claude callback, Copilot, ACP). observe runs after the send so a tool_loop warning or halt follows the result that triggered it. A bare send('agent', ...) now only carries guard-internal events (the tool_loop verdict, role-marker warnings), which the guard ignores. Adds a structured-streams regression test that feeds repeated failing Copilot tool executions through the real parser into the real guard and asserts it halts. * fix(daemon): persist tool_loop warnings so they survive transcript replay The tool_loop event was mapped by the live web translator but dropped by the daemon's persisted-event path (daemonAgentPayloadToPersistedAgentEvent had no tool_loop branch), and send() persists every SSE event before emitting it. So the warning was transient: after a reload or history replay it was gone, and in OD_TOOL_LOOP_GUARD=warn there is no terminal TOOL_LOOP_DETECTED error to fall back on, so the user lost the only record that a loop was detected. Mirror the live status mapping in the persist path so the warning is stored as a warning status and replays identically. Export the mapper and add a regression test covering the warn and halt cases plus a malformed-event drop. (PR #3375 review follow-up, non-blocking.) * fix(daemon): only clear the tool-loop tally on progress, not on read-only successes The previous fix cleared failCounts on every successful tool result, which created a false negative: a stuck agent that alternates a failing Bash/Edit with a successful Read (re-reading the file and retrying the same wrong assumption) reset the tally every round and never tripped. That is the exact "re-read, retry the same wrong assumption" shape from the motivating report. A success now clears the repeated-failure tally only when it is real progress: a successful mutating tool (Edit/Write/apply_patch/...), or the failing action itself finally succeeding. A read-only success (Read/Glob/LS/Grep/TodoWrite) still breaks the strictly-consecutive streak but keeps the per-signature tally, so a re-read-between-failures loop still trips. The earlier edit -> rerun-check -> fix workflow stays safe because the edit is a mutating success that clears the tally. Adds regression tests: a failing call interleaved with successful reads still halts, and a mutating success clears the tally while a read does not. * fix(daemon): treat state-changing shell commands as tool-loop progress isMutatingToolName was a name-only allowlist that excluded Bash, but agents change state through the shell (sed -i, pnpm install, git commit, a build). So a failing check like `pnpm test`, fixed by a successful Bash command, then rerun, carried the old `Bash pnpm test` failures forward until the halt ceiling stopped a progressing run. Replace the allowlist with a progress classifier: read-only tools (Read/Glob/LS/Grep/TodoWrite) and pure-inspection shell commands (cat/ls/grep, git status/diff, sed without -i, python3 -c) are not progress; a state-changing shell command (sed -i, mv, install, build, a redirect to a file, git add/commit) and any other tool success are. The classifier is conservative: anything not recognised as a pure inspection counts as progress, so it never halts a run that is fixing things, while a re-read-between-failures loop (Read/cat/grep) still trips. Adds regression tests: a failing check fixed by successful mutating Bash does not halt, plus read-only vs state-changing command classification. * fix(daemon): default the tool-loop guard to warn, make halt opt-in The repeated-failure trigger classifies whether a success is progress, and review kept surfacing edge cases where a misclassification could halt a run that was actually making progress. Defaulting to halt put that risk on every run. Default to warn instead: the guard surfaces a one-shot heads-up when a run looks stuck (which already solves the original silent-grinding problem, since the operator sees it and can stop the run), and halting is opt-in via OD_TOOL_LOOP_GUARD=halt. A heuristic guard should warn before it terminates someone's run. The halt path stays fully implemented and tested; only the default changes. resolveToolLoopMode and createToolLoopGuard now default to warn. The halt-mechanism tests opt into { mode: 'halt' } explicitly, plus a test that the default warns but never halts. * fix(daemon): count inline python/node scripts as tool-loop progress isReadOnlyShellCommand treated every `python -c` / `node -e` snippet as inspection-only, but an inline script can write files (`python3 -c "Path(...).write_text(...)"`). A fix loop that uses one between failing checks would carry the old failure count forward toward the halt ceiling. Drop the interpreter special case so inline scripts fall through to the state-change default (progress), matching how any other non-inspection shell command is treated. Adds a regression test: a failing check fixed by a successful inline python write does not halt. Updates the classification matrix. * fix(daemon): unwrap env-prefixed shell commands in tool-loop classifier isReadOnlyShellCommand only looked at each segment's head, and `env` was in the read-only binary set, so `env CI=1 sed -i ...` and `env NODE_ENV=production pnpm install` were classified as read-only inspections. A real fix run that way between failing checks never cleared the repeated-failure tally, so a healthy run could still halt at repeatCount 8, the false positive this heuristic exists to avoid. Unwrap a leading `env [flags] KEY=VALUE...` prefix before classifying the real subcommand, and drop `env` from the read-only set so a bare `env` unwraps to nothing and stays an inspection. Adds the classification cases and an integration test that alternates a failing check with a successful `env ... sed -i` / `env ... pnpm install` fix and asserts no halt. Addresses the #3375 review note on the shell progress heuristic. * fix(daemon): count mutating find and unparseable shell segments as tool-loop progress isReadOnlyShellCommand treated find as a pure inspection and silently skipped any segment whose head it could not parse, so find ... -delete, find ... -exec rm, a subshell like (sed -i ...) && cat x, and quoted heads all read as read-only. In OD_TOOL_LOOP_GUARD=halt those successful fixes never cleared the repeated-failure tally, so a run actually changing files could still hit haltRepeat and be terminated as a false positive. Move find to its own branch that stays read-only only without a mutating action (-delete/-exec/-execdir/-ok/-okdir/-fprint/-fprintf/-fls), and treat a non-empty segment with no parseable head as a state change. Empty segments from trailing separators still skip. Adds matrix rows for these Bash-fix paths. Addresses review feedback on #3375. * fix(daemon): match the whole find -fprint* family in the tool-loop classifier The mutating-action matcher listed -fprint and -fprintf but missed -fprint0, because fprint\b has no word boundary before the digit. A successful find ... -fprint0 out.bin writes a file but read as a read-only inspection, so in OD_TOOL_LOOP_GUARD=halt it left failCounts intact and could false-halt a run making progress. Use -fprint\w* to cover -fprint / -fprintf / -fprint0 in one matcher. Adds matrix rows for -fprint0 and -fprintf. Addresses review feedback on #3375. * fix(daemon): route the tool-loop halt through signalChild so it kills the process group abortForToolLoop used a bare child.kill('SIGTERM'), unlike abortForRoleMarker, cancel, and the inactivity watchdog, which all go through design.runs.signalChild. signalChild escalates to the whole process group when one exists, while child.kill only signals the direct child. In OD_TOOL_LOOP_GUARD=halt that left Bash/build grandchildren alive to keep mutating the workspace until the forced-shutdown timer fired. Route the halt through signalChild to match the sibling shutdown paths. Addresses review feedback on #3375. --------- Co-authored-by: mrcfps <mrc@powerformer.com>
368 lines
16 KiB
TypeScript
368 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
computeToolSignature,
|
|
createToolLoopGuard,
|
|
displayToolSignature,
|
|
isProgressSuccess,
|
|
isReadOnlyShellCommand,
|
|
resolveToolLoopMode,
|
|
} from '../src/tool-loop-guard.js';
|
|
|
|
// Drive a guard through a use+result pair the way the run loop does.
|
|
function fail(guard: ReturnType<typeof createToolLoopGuard>, id: string, name: string, input: unknown) {
|
|
guard.observeToolUse(id, name, input);
|
|
return guard.observeToolResult(id, true, 'boom');
|
|
}
|
|
function ok(guard: ReturnType<typeof createToolLoopGuard>, id: string, name: string, input: unknown) {
|
|
guard.observeToolUse(id, name, input);
|
|
return guard.observeToolResult(id, false, '');
|
|
}
|
|
|
|
describe('computeToolSignature', () => {
|
|
it('uses the command for Bash', () => {
|
|
expect(computeToolSignature('Bash', { command: 'ls -la' })).toBe('Bash ls -la');
|
|
});
|
|
|
|
it('uses file_path (and old_string) for Edit so different edits to one file differ', () => {
|
|
const a = computeToolSignature('Edit', { file_path: '/a.html', old_string: 'foo' });
|
|
const b = computeToolSignature('Edit', { file_path: '/a.html', old_string: 'bar' });
|
|
expect(a).not.toBe(b);
|
|
expect(a).toContain('/a.html');
|
|
});
|
|
|
|
it('collapses whitespace so trivially reformatted actions match', () => {
|
|
expect(computeToolSignature('Bash', { command: 'ls -la\n' })).toBe(
|
|
computeToolSignature('Bash', { command: 'ls -la' }),
|
|
);
|
|
});
|
|
|
|
it('falls back to the tool name when input has nothing usable', () => {
|
|
expect(computeToolSignature('ExitPlanMode', {})).toBe('ExitPlanMode');
|
|
expect(computeToolSignature('Bash', null)).toBe('Bash');
|
|
});
|
|
|
|
it('keeps the full signature for counting and truncates only for display', () => {
|
|
const full = computeToolSignature('Bash', { command: 'x'.repeat(1000) });
|
|
expect(full.length).toBeGreaterThan(160); // full-fidelity dedup key, not capped
|
|
const shown = displayToolSignature(full);
|
|
expect(shown.length).toBeLessThanOrEqual(160);
|
|
expect(shown.endsWith('…')).toBe(true);
|
|
});
|
|
|
|
it('does not collide two distinct long commands sharing a 160-char prefix', () => {
|
|
const prefix = 'run '.repeat(60); // 240 chars, well over the display cap
|
|
const a = computeToolSignature('Bash', { command: `${prefix}alpha` });
|
|
const b = computeToolSignature('Bash', { command: `${prefix}beta` });
|
|
expect(a).not.toBe(b);
|
|
// ...and their truncated display forms DO collide, which is exactly why the
|
|
// display string must not be used as the counting key.
|
|
expect(displayToolSignature(a)).toBe(displayToolSignature(b));
|
|
});
|
|
});
|
|
|
|
describe('createToolLoopGuard — repeated-failure trigger', () => {
|
|
it('warns when the same failing action repeats warnRepeat times', () => {
|
|
const guard = createToolLoopGuard();
|
|
const input = { command: 'python3 verify.py' };
|
|
expect(fail(guard, 't1', 'Bash', input)).toBeNull(); // 1
|
|
expect(fail(guard, 't2', 'Bash', input)).toBeNull(); // 2
|
|
expect(fail(guard, 't3', 'Bash', input)).toBeNull(); // 3
|
|
const verdict = fail(guard, 't4', 'Bash', input); // 4 → warn
|
|
expect(verdict).toMatchObject({
|
|
type: 'tool_loop',
|
|
reason: 'repeated-failure',
|
|
action: 'warn',
|
|
toolName: 'Bash',
|
|
count: 4,
|
|
});
|
|
expect(guard.warned).toBe(true);
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('halts when the same failing action repeats haltRepeat times', () => {
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const input = { file_path: '/x.html', old_string: 'titlebar-left' };
|
|
let halt = null;
|
|
for (let i = 0; i < 8; i += 1) halt = fail(guard, `t${i}`, 'Edit', input);
|
|
expect(halt).toMatchObject({ reason: 'repeated-failure', action: 'halt', count: 8 });
|
|
expect(guard.halted).toBe(true);
|
|
});
|
|
|
|
it('does not warn on a few legitimate retries of the same action', () => {
|
|
const guard = createToolLoopGuard();
|
|
const input = { command: 'pnpm build' };
|
|
expect(fail(guard, 'a', 'Bash', input)).toBeNull();
|
|
expect(fail(guard, 'b', 'Bash', input)).toBeNull();
|
|
expect(ok(guard, 'c', 'Bash', input)).toBeNull(); // fixed it
|
|
expect(guard.warned).toBe(false);
|
|
});
|
|
|
|
it('does not halt when the same check keeps failing but successful edits land between attempts', () => {
|
|
// The progressing-run case the cumulative counter wrongly halted (PR #3375
|
|
// review): rerun the same verification command after each successful edit,
|
|
// each run failing on the next newly-written case. The intervening success
|
|
// is real progress, so the repeated-failure tally must not accumulate.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const check = { command: 'pnpm test' };
|
|
let tripped = null;
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const verdict = fail(guard, `chk-${i}`, 'Bash', check); // same failing check
|
|
if (verdict) tripped = verdict;
|
|
ok(guard, `edit-${i}`, 'Edit', { file_path: '/src/a.ts', old_string: `case-${i}` }); // progress
|
|
}
|
|
expect(tripped).toBeNull();
|
|
expect(guard.warned).toBe(false);
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('still halts when the same failing call is interleaved with successful read-only calls', () => {
|
|
// The false negative that clearing failCounts on EVERY success let through
|
|
// (PR #3375 review): a stuck agent re-reads the file and retries the same
|
|
// wrong assumption. A successful Read is not progress on the failing action,
|
|
// so the repeated-failure tally must survive it and the loop must still trip.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const failing = { command: "python3 -c \"assert 'titlebar-left' in open('v.html').read()\"" };
|
|
for (let i = 0; i < 8; i += 1) {
|
|
fail(guard, `chk-${i}`, 'Bash', failing); // same failing verification
|
|
ok(guard, `read-${i}`, 'Read', { file_path: '/v.html' }); // read-only, NOT progress
|
|
}
|
|
expect(guard.halted).toBe(true);
|
|
});
|
|
|
|
it('a successful mutating tool clears the tally but a successful read does not', () => {
|
|
const failing = { command: 'pnpm test' };
|
|
// Read between failures: tally survives -> trips.
|
|
const reads = createToolLoopGuard();
|
|
for (let i = 0; i < 4; i += 1) {
|
|
fail(reads, `f-${i}`, 'Bash', failing);
|
|
ok(reads, `r-${i}`, 'Read', { file_path: '/x' });
|
|
}
|
|
expect(reads.warned).toBe(true);
|
|
// Edit between failures: tally clears each round -> never trips.
|
|
const edits = createToolLoopGuard();
|
|
for (let i = 0; i < 4; i += 1) {
|
|
fail(edits, `f-${i}`, 'Bash', failing);
|
|
ok(edits, `e-${i}`, 'Edit', { file_path: '/x', old_string: `case-${i}` });
|
|
}
|
|
expect(edits.warned).toBe(false);
|
|
});
|
|
|
|
it('does not halt when a failing check is fixed by successful mutating Bash between attempts', () => {
|
|
// PR #3375 review: agents change state through the shell, so a successful
|
|
// `sed -i` (or install/build/git commit) between failing checks is real
|
|
// progress and must clear the tally even though the tool is Bash.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const check = { command: 'pnpm test' };
|
|
let tripped = null;
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const verdict = fail(guard, `chk-${i}`, 'Bash', check); // same failing check
|
|
if (verdict) tripped = verdict;
|
|
ok(guard, `fix-${i}`, 'Bash', { command: `sed -i 's/old/new/' src/file-${i}.ts` }); // shell fix = progress
|
|
}
|
|
expect(tripped).toBeNull();
|
|
expect(guard.halted).toBe(false);
|
|
expect(guard.warned).toBe(false);
|
|
});
|
|
|
|
it('does not halt when a failing check is fixed by a successful inline python/node script', () => {
|
|
// PR #3375 review: an inline `python3 -c` / `node -e` snippet can write
|
|
// files, so a successful one is a real fix and must clear the tally.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const check = { command: 'pnpm test' };
|
|
let tripped = null;
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const verdict = fail(guard, `chk-${i}`, 'Bash', check);
|
|
if (verdict) tripped = verdict;
|
|
ok(guard, `fix-${i}`, 'Bash', { command: `python3 -c "open('f-${i}.ts','w').write('x')"` }); // inline write = progress
|
|
}
|
|
expect(tripped).toBeNull();
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('does not halt when an env-prefixed mutating Bash fix lands between failing checks', () => {
|
|
// PR #3375 review: `env CI=1 sed -i ...` and `env NODE_ENV=production pnpm install`
|
|
// mutate via the wrapped command, not via `env`. The classifier must unwrap the
|
|
// env prefix so a successful fix clears the tally, instead of reading it as a
|
|
// read-only `env` inspection that lets stale failures accumulate to a halt.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const check = { command: 'pnpm test' };
|
|
let tripped = null;
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const verdict = fail(guard, `chk-${i}`, 'Bash', check); // same failing check
|
|
if (verdict) tripped = verdict;
|
|
const fix = i % 2 === 0
|
|
? `env CI=1 sed -i 's/old/new/' src/file-${i}.ts`
|
|
: 'env NODE_ENV=production pnpm install';
|
|
ok(guard, `fix-${i}`, 'Bash', { command: fix }); // env-wrapped fix = progress
|
|
}
|
|
expect(tripped).toBeNull();
|
|
expect(guard.halted).toBe(false);
|
|
expect(guard.warned).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isReadOnlyShellCommand / isProgressSuccess', () => {
|
|
it('classifies pure inspections as read-only', () => {
|
|
for (const cmd of [
|
|
'cat x.ts', 'ls -la', 'grep foo x', 'rg needle', 'wc -l x', 'sed -n p x',
|
|
'git status', 'git diff HEAD', 'find . -name x',
|
|
'env', 'env FOO=1 cat x', // bare env / env-wrapped inspection stays read-only
|
|
]) {
|
|
expect(isReadOnlyShellCommand(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('classifies state-changing shell commands as not read-only', () => {
|
|
for (const cmd of [
|
|
'sed -i s/a/b/ x', 'mv a b', 'rm x', 'mkdir y', 'pnpm install', 'npm run build',
|
|
'git commit -m x', 'git add .', 'echo hi > f.txt',
|
|
'python3 -c "open(\'x\',\'w\').write(\'1\')"', 'node -e "require(\'fs\').writeFileSync(\'x\',\'1\')"',
|
|
'env CI=1 sed -i s/a/b/ x', 'env NODE_ENV=production pnpm install', // env prefix must unwrap to the real cmd
|
|
]) {
|
|
expect(isReadOnlyShellCommand(cmd)).toBe(false);
|
|
}
|
|
});
|
|
|
|
// PR #3375 review: mutating Bash fixes that were misclassified as read-only,
|
|
// so in halt mode a run actually changing files could still be terminated.
|
|
it('treats mutating find actions and unparseable segment heads as progress', () => {
|
|
for (const cmd of [
|
|
"find . -name '*.pyc' -delete", // -delete mutates
|
|
'find . -exec rm {} \\;', // -exec runs a command
|
|
'find . -execdir rm {} ;',
|
|
"find . -name x -fprint out.txt",
|
|
"find . -name '*.ts' -fprint0 out.bin", // -fprint0 writes a file too
|
|
'find . -fprintf out.txt "%p\\n"',
|
|
'(sed -i s/a/b/ x.ts) && cat x.ts', // subshell head does not parse
|
|
'"sed" -i s/a/b/ x', // quoted head does not parse
|
|
]) {
|
|
expect(isReadOnlyShellCommand(cmd)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('keeps pure find and trailing separators read-only', () => {
|
|
for (const cmd of [
|
|
"find . -type f -name '*.ts'", // inspection only
|
|
'find . -maxdepth 2 -name x',
|
|
'ls;', // trailing separator yields an empty segment, not a mutation
|
|
'cat x.ts ;',
|
|
]) {
|
|
expect(isReadOnlyShellCommand(cmd)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('treats a Bash fix as progress and a Bash read or read-only tool as non-progress', () => {
|
|
expect(isProgressSuccess('Bash', 'Bash sed -i s/a/b/ x')).toBe(true);
|
|
expect(isProgressSuccess('Bash', 'Bash cat x')).toBe(false);
|
|
expect(isProgressSuccess('Read', 'Read /x')).toBe(false);
|
|
expect(isProgressSuccess('Edit', 'Edit /x')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('createToolLoopGuard — consecutive-errors trigger', () => {
|
|
it('warns after warnConsecutive different failing actions in a row', () => {
|
|
const guard = createToolLoopGuard();
|
|
let verdict = null;
|
|
for (let i = 0; i < 5; i += 1) {
|
|
verdict = fail(guard, `t${i}`, 'Bash', { command: `try-${i}` }); // all distinct signatures
|
|
}
|
|
expect(verdict).toMatchObject({ reason: 'consecutive-errors', action: 'warn', count: 5 });
|
|
});
|
|
|
|
it('resets the consecutive streak on a successful tool call', () => {
|
|
const guard = createToolLoopGuard();
|
|
for (let i = 0; i < 4; i += 1) fail(guard, `t${i}`, 'Bash', { command: `try-${i}` });
|
|
ok(guard, 'good', 'Bash', { command: 'works' }); // progress resets streak
|
|
const verdict = fail(guard, 'after', 'Bash', { command: 'try-after' });
|
|
expect(verdict).toBeNull();
|
|
expect(guard.warned).toBe(false);
|
|
});
|
|
|
|
it('halts after haltConsecutive failures in a row', () => {
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
let last = null;
|
|
for (let i = 0; i < 10; i += 1) last = fail(guard, `t${i}`, 'Bash', { command: `distinct-${i}` });
|
|
expect(last).toMatchObject({ reason: 'consecutive-errors', action: 'halt', count: 10 });
|
|
expect(guard.halted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('createToolLoopGuard — latching and modes', () => {
|
|
it('emits warn at most once, then escalates to halt once', () => {
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const input = { command: 'same' };
|
|
const verdicts = [];
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const v = fail(guard, `t${i}`, 'Bash', input);
|
|
if (v) verdicts.push(v.action);
|
|
}
|
|
expect(verdicts).toEqual(['warn', 'halt']);
|
|
});
|
|
|
|
it('warn mode never halts', () => {
|
|
const guard = createToolLoopGuard({ mode: 'warn' });
|
|
const input = { command: 'same' };
|
|
let sawHalt = false;
|
|
for (let i = 0; i < 20; i += 1) {
|
|
const v = fail(guard, `t${i}`, 'Bash', input);
|
|
if (v?.action === 'halt') sawHalt = true;
|
|
}
|
|
expect(sawHalt).toBe(false);
|
|
expect(guard.warned).toBe(true);
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('off mode never trips', () => {
|
|
const guard = createToolLoopGuard({ mode: 'off' });
|
|
const input = { command: 'same' };
|
|
for (let i = 0; i < 30; i += 1) expect(fail(guard, `t${i}`, 'Bash', input)).toBeNull();
|
|
expect(guard.warned).toBe(false);
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('defaults to warn: it warns but never halts', () => {
|
|
const guard = createToolLoopGuard(); // no mode -> warn (the daemon default)
|
|
const input = { command: 'same' };
|
|
for (let i = 0; i < 20; i += 1) fail(guard, `t${i}`, 'Bash', input);
|
|
expect(guard.warned).toBe(true);
|
|
expect(guard.halted).toBe(false);
|
|
});
|
|
|
|
it('is inert after halting', () => {
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const input = { command: 'same' };
|
|
for (let i = 0; i < 8; i += 1) fail(guard, `t${i}`, 'Bash', input);
|
|
expect(guard.halted).toBe(true);
|
|
expect(fail(guard, 'after', 'Bash', input)).toBeNull();
|
|
});
|
|
|
|
it('reproduces the titlebar-left loop: repeated failing assertion halts the run', () => {
|
|
// The exact shape that motivated the guard: the agent re-runs the same
|
|
// shell assertion against an element name that does not exist.
|
|
const guard = createToolLoopGuard({ mode: 'halt' });
|
|
const cmd = { command: "python3 -c \"assert 'titlebar-left' in open('v.html').read()\"" };
|
|
const actions: string[] = [];
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const v = fail(guard, `loop-${i}`, 'Bash', cmd);
|
|
if (v) actions.push(v.action);
|
|
}
|
|
expect(actions).toContain('warn');
|
|
expect(actions).toContain('halt');
|
|
expect(guard.halted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resolveToolLoopMode', () => {
|
|
it('defaults to warn', () => {
|
|
expect(resolveToolLoopMode({})).toBe('warn');
|
|
});
|
|
it('reads off/warn/halt case-insensitively', () => {
|
|
expect(resolveToolLoopMode({ OD_TOOL_LOOP_GUARD: 'OFF' })).toBe('off');
|
|
expect(resolveToolLoopMode({ OD_TOOL_LOOP_GUARD: ' warn ' })).toBe('warn');
|
|
expect(resolveToolLoopMode({ OD_TOOL_LOOP_GUARD: 'HALT' })).toBe('halt');
|
|
});
|
|
it('falls back to warn on an unrecognized value', () => {
|
|
expect(resolveToolLoopMode({ OD_TOOL_LOOP_GUARD: 'disable' })).toBe('warn');
|
|
});
|
|
});
|