This commit is contained in:
168
packages/agent/tests/loop-guard.test.ts
Normal file
168
packages/agent/tests/loop-guard.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LoopGuard } from '../src/loop-guard.js';
|
||||
|
||||
describe('LoopGuard', () => {
|
||||
it('allows unique tool calls', () => {
|
||||
const guard = new LoopGuard({ maxRepeats: 3 });
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
expect(guard.check('bash', { command: 'echo 2' })).toBe(true);
|
||||
expect(guard.check('read_file', { path: 'foo.ts' })).toBe(true);
|
||||
});
|
||||
|
||||
it('detects repeated identical tool calls', () => {
|
||||
const guard = new LoopGuard({ maxRepeats: 3 });
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
// 4th identical call — blocked
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('resets on different call', () => {
|
||||
const guard = new LoopGuard({ maxRepeats: 2 });
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
// Different call resets streak
|
||||
expect(guard.check('bash', { command: 'echo 2' })).toBe(true);
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks consecutive repeats, not total', () => {
|
||||
const guard = new LoopGuard({ maxRepeats: 2 });
|
||||
guard.check('bash', { command: 'echo 1' });
|
||||
guard.check('read_file', { path: 'a.ts' }); // break streak
|
||||
guard.check('bash', { command: 'echo 1' });
|
||||
guard.check('bash', { command: 'echo 1' });
|
||||
expect(guard.check('bash', { command: 'echo 1' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoopGuard graduated tiers (steal #9)', () => {
|
||||
const args = { command: 'echo 1' };
|
||||
|
||||
function recordFailures(guard: LoopGuard, tool: string, n: number, mkArgs: (i: number) => Record<string, unknown>) {
|
||||
for (let i = 0; i < n; i++) guard.record(tool, mkArgs(i), false);
|
||||
}
|
||||
|
||||
it('allows when there is no failure history', () => {
|
||||
const guard = new LoopGuard();
|
||||
expect(guard.checkTiered('bash', args)).toEqual({ action: 'allow' });
|
||||
});
|
||||
|
||||
it('T3 aborts at exactly 8 same-tool consecutive failures', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 7, (i) => ({ command: `c${i}` }));
|
||||
expect(guard.checkTiered('bash', { command: 'c7' }).action).not.toBe('abort');
|
||||
guard.record('bash', { command: 'c7' }, false); // 8th failure
|
||||
const v = guard.checkTiered('bash', { command: 'c8' });
|
||||
expect(v.action).toBe('abort');
|
||||
if (v.action === 'abort') expect(v.tier).toBe('T3');
|
||||
});
|
||||
|
||||
it('T3 wins over T4 when the streak crosses both thresholds', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 8, (i) => ({ command: `c${i}` }));
|
||||
// 8 same-tool failures satisfies both T4 (>=6) and T3 (>=8) — T3 first.
|
||||
const v = guard.checkTiered('bash', args);
|
||||
expect(v.action).toBe('abort');
|
||||
});
|
||||
|
||||
it('T1 blocks at exactly 5 identical calls (any outcome)', () => {
|
||||
const guard = new LoopGuard();
|
||||
for (let i = 0; i < 4; i++) guard.record('bash', args, true); // successes, identical
|
||||
expect(guard.checkTiered('bash', args).action).toBe('allow');
|
||||
guard.record('bash', args, true); // 5th identical
|
||||
const v = guard.checkTiered('bash', args);
|
||||
expect(v.action).toBe('block');
|
||||
if (v.action === 'block') expect(v.tier).toBe('T1');
|
||||
});
|
||||
|
||||
it('T2 blocks at exactly 3 identical consecutive failures', () => {
|
||||
const guard = new LoopGuard();
|
||||
guard.record('bash', args, false);
|
||||
guard.record('bash', args, false);
|
||||
expect(guard.checkTiered('bash', args).action).toBe('allow');
|
||||
guard.record('bash', args, false); // 3rd identical failure
|
||||
const v = guard.checkTiered('bash', args);
|
||||
expect(v.action).toBe('block');
|
||||
if (v.action === 'block') expect(v.tier).toBe('T2');
|
||||
});
|
||||
|
||||
it('T4 blocks at 6 same-tool failures with varying args', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 6, (i) => ({ command: `c${i}` }));
|
||||
const v = guard.checkTiered('bash', { command: 'c6' });
|
||||
expect(v.action).toBe('block');
|
||||
if (v.action === 'block') expect(v.tier).toBe('T4');
|
||||
});
|
||||
|
||||
it('a success resets the T2 identical-failure streak', () => {
|
||||
const guard = new LoopGuard();
|
||||
guard.record('bash', args, false);
|
||||
guard.record('bash', args, false);
|
||||
guard.record('bash', args, true); // success breaks the streak
|
||||
guard.record('bash', args, false);
|
||||
expect(guard.checkTiered('bash', args).action).toBe('allow');
|
||||
});
|
||||
|
||||
it('a success resets the T4 same-tool-failure streak', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 5, (i) => ({ command: `c${i}` }));
|
||||
guard.record('bash', { command: 'ok' }, true); // success breaks the streak
|
||||
recordFailures(guard, 'bash', 2, (i) => ({ command: `d${i}` }));
|
||||
expect(guard.checkTiered('bash', { command: 'd2' }).action).toBe('allow');
|
||||
});
|
||||
|
||||
it('switching tools resets the T1 identical-call streak', () => {
|
||||
const guard = new LoopGuard();
|
||||
for (let i = 0; i < 5; i++) guard.record('bash', args, true);
|
||||
// Same args but a different tool — the tail entry differs, streak resets.
|
||||
expect(guard.checkTiered('read_file', args).action).toBe('allow');
|
||||
});
|
||||
|
||||
it('switching tools resets the T3/T4 same-tool-failure streak', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 8, (i) => ({ command: `c${i}` }));
|
||||
expect(guard.checkTiered('read_file', { path: 'a.ts' }).action).toBe('allow');
|
||||
});
|
||||
|
||||
it('escalates T4 blocks into a T3 abort when blocked attempts are recorded (executor contract)', () => {
|
||||
// Mirrors tool-executor's loop: checkTiered first; a block is recorded as
|
||||
// a failed attempt; an executed failure is also recorded. Without
|
||||
// record-on-block the same-tool tail freezes at 6 and T3 never fires.
|
||||
const guard = new LoopGuard();
|
||||
let aborted = false;
|
||||
for (let attempt = 0; attempt < 12 && !aborted; attempt++) {
|
||||
const verdict = guard.checkTiered('bash', { command: `try ${attempt}` });
|
||||
if (verdict.action === 'abort') {
|
||||
expect(verdict.tier).toBe('T3');
|
||||
aborted = true;
|
||||
break;
|
||||
}
|
||||
// block or allow -> the model tried again and it failed again.
|
||||
guard.record('bash', { command: `try ${attempt}` }, false);
|
||||
}
|
||||
expect(aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('counts a returned "Error:" string as a failure via the executor convention', () => {
|
||||
// The executor records success as !/^Error\b/.test(result); this pins the
|
||||
// convention so string-failure tools feed the failure tiers.
|
||||
expect(/^Error\b/.test('Error: Skill name must be kebab-case')).toBe(true);
|
||||
expect(/^Error\b/.test('Created skill successfully')).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the outcome history at 50 records', () => {
|
||||
const guard = new LoopGuard();
|
||||
for (let i = 0; i < 60; i++) guard.record('bash', { command: `c${i}` }, false);
|
||||
expect(guard.historySize).toBe(50);
|
||||
});
|
||||
|
||||
it('reset() clears the tiered history', () => {
|
||||
const guard = new LoopGuard();
|
||||
recordFailures(guard, 'bash', 8, (i) => ({ command: `c${i}` }));
|
||||
guard.reset();
|
||||
expect(guard.historySize).toBe(0);
|
||||
expect(guard.checkTiered('bash', args).action).toBe('allow');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user