diff --git a/.changeset/stop-repeated-tool-calls.md b/.changeset/stop-repeated-tool-calls.md new file mode 100644 index 00000000..60b816e2 --- /dev/null +++ b/.changeset/stop-repeated-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@open-codesign/core": patch +--- + +Stop a run that keeps repeating the same tool call. When the model makes the same call with identical arguments three times in a row, or repeats the same short sequence of calls three times, the call is blocked and the model reads a reason telling it to change approach. At five repeats the run ends with the incomplete-generation error, no further tool or model request runs, and the workspace files are kept. A call with different arguments starts the count again, `done` keeps its own repair limit, and there is no cap on how many times a tool is called in total. diff --git a/packages/core/src/agent.native-lifecycle.test.ts b/packages/core/src/agent.native-lifecycle.test.ts index bafb6558..5790e52b 100644 --- a/packages/core/src/agent.native-lifecycle.test.ts +++ b/packages/core/src/agent.native-lifecycle.test.ts @@ -73,6 +73,15 @@ const create = (path: string, text: string) => `create-${path}`, ); const done = () => call('done', { path: 'App.jsx' }); +const view = (path = 'App.jsx') => + call('str_replace_based_edit_tool', { command: 'view', path }, `view-${path}`); +const missingEdit = () => + call('str_replace_based_edit_tool', { + command: 'str_replace', + path: 'App.jsx', + old_str: 'not in the file', + new_str: 'unused', + }); const ask = () => call('ask', { questions: [{ id: 'q', type: 'freeform', prompt: 'Required fact?' }] }); @@ -111,11 +120,30 @@ function response( } function script(batches: Array) { - native.stream.mockImplementation((model) => { + const requests: AgentMessage[][] = []; + native.stream.mockImplementation((model, context) => { + requests.push([...context.messages]); const batch = batches.shift(); if (batch === undefined) throw new Error('Unexpected extra provider call'); return response(model, typeof batch === 'string' ? [{ type: 'text', text: batch }] : batch); }); + return requests; +} + +type ToolEnd = { toolName: string; isError: boolean; text: string }; +type OnEvent = NonNullable[1]>['onEvent']>; +function collectToolEnds(into: ToolEnd[], then?: (end: ToolEnd) => void): OnEvent { + return (event) => { + if (event.type !== 'tool_execution_end') return; + const content = (event.result as { content: Array<{ type: string; text?: string }> }).content; + const end = { + toolName: event.toolName, + isError: event.isError, + text: content.map((part) => part.text ?? '').join(''), + }; + into.push(end); + then?.(end); + }; } let root: string; @@ -460,4 +488,204 @@ describe('production generateViaAgent with installed pi', () => { native.agents[0]?.state.messages.filter((message) => message.role === 'user'), ).toHaveLength(1); }); + describe('repeated tool calls', () => { + // Only the repeated call reads this file, so its view count is the number of executions. + const PROBE = 'probe.txt'; + const seedProbe = () => writeFileSync(join(root, PROBE), 'Probe'); + const repeatedViews = (times: number) => Array.from({ length: times }, () => [view(PROBE)]); + const probeViews = (spy: { mock: { calls: unknown[][] } }) => + spy.mock.calls.filter(([path]) => path === PROBE).length; + + it('blocks the third identical call and lets the model read the reason as the tool result', async () => { + seed(); + seedProbe(); + const viewSpy = vi.spyOn(fs, 'view'); + const ends: ToolEnd[] = []; + const requests = script([ + [todos()], + ...repeatedViews(3), + [create('notes.txt', 'Changed approach')], + 'Finished', + ]); + const result = await generateViaAgent(input, { fs, onEvent: collectToolEnds(ends) }); + expect(result.message).toBe('Finished'); + const views = ends.filter((end) => end.toolName === 'str_replace_based_edit_tool'); + expect(views.slice(0, 2).map((end) => end.isError)).toEqual([false, false]); + expect(views[2]).toMatchObject({ isError: true }); + expect(views[2]?.text).toContain( + '`str_replace_based_edit_tool` with the same arguments 3 times in a row', + ); + expect(views[2]?.text).toContain('Change your approach'); + expect(probeViews(viewSpy)).toBe(2); + const next = requests[4] ?? []; + expect(next.at(-1)).toMatchObject({ role: 'toolResult', isError: true }); + expect(JSON.stringify(next.at(-1))).toContain('Change your approach'); + expect(next.filter((message) => message.role === 'user')).toHaveLength(1); + expect(writes).toEqual(['notes.txt']); + }); + + it('ends the run at the fifth identical call without another model request, keeping files and queued messages', async () => { + seed(); + seedProbe(); + const controller = new AbortController(); + const receipts: ActiveRunMessageV1[] = []; + const active = new ActiveRunMessages( + 'design', + 'run', + (row) => receipts.push(row), + controller.signal, + ); + const viewSpy = vi.spyOn(fs, 'view'); + const ends: ToolEnd[] = []; + const onComplete = vi.fn(); + script([[todos()], [create('kept.txt', 'Kept')], ...repeatedViews(5), 'Must not complete']); + const running = generateViaAgent( + { ...input, signal: controller.signal }, + { + fs, + activeMessages: active, + onComplete, + onEvent: collectToolEnds(ends, (end) => { + if (!end.text.includes('STOP')) return; + for (const mode of ['steer', 'follow-up'] as const) { + active.submit({ + schemaVersion: 1, + designId: 'design', + generationId: 'run', + messageId: mode, + mode, + text: 'Queued before the stop', + }); + } + }), + }, + ); + await expect(running).rejects.toMatchObject({ + code: 'GENERATION_INCOMPLETE', + message: expect.stringContaining( + '`str_replace_based_edit_tool` with the same arguments 5 times in a row', + ), + }); + expect(native.stream).toHaveBeenCalledTimes(7); + expect(probeViews(viewSpy)).toBe(2); + expect(ends.filter((end) => end.isError).map((end) => end.text.includes('STOP'))).toEqual([ + false, + false, + true, + ]); + expect(readFileSync(join(root, 'kept.txt'), 'utf8')).toBe('Kept'); + expect(readFileSync(join(root, 'App.jsx'), 'utf8')).toBe(SOURCE); + expect(controller.signal.aborted).toBe(false); + expect(onComplete).toHaveBeenCalledOnce(); + const [pi] = native.agents; + await pi?.waitForIdle(); + expect(pi?.state.isStreaming).toBe(false); + expect(pi?.hasQueuedMessages()).toBe(false); + expect(pi?.state.messages.filter((message) => message.role === 'user')).toHaveLength(1); + expect(receipts.map((row) => `${row.messageId}:${row.status}`)).toEqual([ + 'steer:pending', + 'follow-up:pending', + 'steer:not-delivered', + 'follow-up:not-delivered', + ]); + }); + + it('does not run the calls that follow the halting call in the same response', async () => { + seed(); + seedProbe(); + script([ + [todos()], + ...repeatedViews(4), + [view(PROBE), create('later.txt', 'must not write')], + ]); + await expect(generateViaAgent(input, { fs })).rejects.toMatchObject({ + code: 'GENERATION_INCOMPLETE', + }); + expect(native.stream).toHaveBeenCalledTimes(6); + expect(writes).toEqual([]); + }); + + it('halts a cycle of calls that repeats unchanged, across the view and a failing edit', async () => { + seed(); + script([ + [todos()], + ...Array.from({ length: 5 }, () => [view(), missingEdit()]), + 'Must not complete', + ]); + await expect(generateViaAgent(input, { fs })).rejects.toMatchObject({ + code: 'GENERATION_INCOMPLETE', + message: expect.stringContaining( + 'the same sequence of calls (`str_replace_based_edit_tool` → `str_replace_based_edit_tool`) 5 times in a row', + ), + }); + expect(native.stream).toHaveBeenCalledTimes(6); + }); + + it('keeps allowing the same read between different edits', async () => { + seed(); + const ends: ToolEnd[] = []; + script([ + [todos()], + ...Array.from({ length: 6 }, (_, n) => [create(`part${n}.txt`, `Part ${n}`), view()]), + 'Finished', + ]); + const result = await generateViaAgent(input, { fs, onEvent: collectToolEnds(ends) }); + expect(result.message).toBe('Finished'); + expect(ends.some((end) => end.isError)).toBe(false); + expect(writes).toHaveLength(6); + }); + + it('leaves repeated done calls to the done repair limit', async () => { + seed(); + const verify = vi.fn(async () => [{ message: 'Broken runtime', source: 'runtime' }]); + script([[todos()], ...Array.from({ length: 5 }, () => [done()]), 'Must not complete']); + await expect(generateViaAgent(input, { fs, runtimeVerify: verify })).rejects.toMatchObject({ + code: 'GENERATION_INCOMPLETE', + message: expect.stringContaining('3 done() error rounds'), + }); + expect(verify).toHaveBeenCalledTimes(3); + expect(native.stream).toHaveBeenCalledTimes(4); + }); + + it('starts counting again after a queued user message is delivered', async () => { + seed(); + seedProbe(); + const controller = new AbortController(); + const receipts: ActiveRunMessageV1[] = []; + const active = new ActiveRunMessages( + 'design', + 'run', + (row) => receipts.push(row), + controller.signal, + ); + const ends: ToolEnd[] = []; + script([[todos()], ...repeatedViews(4), 'Finished']); + let views = 0; + const result = await generateViaAgent( + { ...input, signal: controller.signal }, + { + fs, + activeMessages: active, + onEvent: collectToolEnds(ends, (end) => { + if (end.toolName !== 'str_replace_based_edit_tool') return; + views += 1; + if (views === 2) { + active.submit({ + schemaVersion: 1, + designId: 'design', + generationId: 'run', + messageId: 'steer', + mode: 'steer', + text: 'Keep going', + }); + } + }), + }, + ); + expect(result.message).toBe('Finished'); + expect(ends.filter((end) => end.toolName === 'str_replace_based_edit_tool')).toHaveLength(4); + expect(ends.some((end) => end.isError)).toBe(false); + expect(receipts.at(-1)?.status).toBe('delivered'); + }); + }); }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index a6dab1e5..a50fa2cf 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -100,6 +100,7 @@ import { recordScaffold, } from './resource-state.js'; import { buildRunProtocolPreflight, type RunProtocolState } from './run-protocol.js'; +import { createToolLoopGuard } from './tool-loop-guard.js'; import { availableToolNames } from './tool-manifest.js'; import { makeAskTool } from './tools/ask.js'; import { makeDecomposeToUiKitTool } from './tools/decompose-to-ui-kit.js'; @@ -667,6 +668,41 @@ function formatDoneRepairLimitText(details: DoneDetails): string { ].join('\n'); } +interface ToolLoopHalt { + cycle: string[]; + repeats: number; +} + +function repeatedCallsPredicate({ cycle, repeats }: ToolLoopHalt): string { + const names = cycle.map((name) => `\`${name}\``).join(' → '); + return cycle.length === 1 + ? `called ${names} with the same arguments ${repeats} times in a row` + : `made the same sequence of calls (${names}) ${repeats} times in a row`; +} + +function formatToolLoopWarning(loop: ToolLoopHalt): string { + return [ + `You have ${repeatedCallsPredicate(loop)}.`, + 'Repeating it will not change the result.', + 'Change your approach: use different arguments or a different tool, or stop and tell the user what is blocking you.', + ].join(' '); +} + +function formatToolLoopHalt(loop: ToolLoopHalt): string { + return [ + `You have ${repeatedCallsPredicate(loop)}.`, + 'STOP. Do not call any tool again.', + 'The host is ending this run and keeping the workspace files. Do not claim the design is ready.', + ].join(' '); +} + +function formatToolLoopIncomplete(loop: ToolLoopHalt): string { + return [ + `The run was stopped because the model ${repeatedCallsPredicate(loop)}.`, + 'The workspace files have been kept, but the design may be incomplete. Retry with a different instruction, or continue the run.', + ].join('\n'); +} + function isReasoningContentRoundTripError(errorMessage: string | undefined): boolean { if (!errorMessage) return false; const message = errorMessage.toLowerCase(); @@ -988,6 +1024,9 @@ async function generateViaAgentInternal( let lastDoneDetails: DoneDetails | undefined; const doneProgress = { errorRounds: 0 }; const repairLimitReached = () => doneProgress.errorRounds >= MAX_DONE_ERROR_ROUNDS; + const toolLoopGuard = createToolLoopGuard(); + const toolLoop: { halt: ToolLoopHalt | null } = { halt: null }; + const runHalted = () => repairLimitReached() || toolLoop.halt !== null; const assertNotCancelled = (signal?: AbortSignal): void => { if (input.signal?.aborted || signal?.aborted) { throw new CodesignError('Generation cancelled', ERROR_CODES.PROVIDER_ABORTED); @@ -1316,14 +1355,28 @@ async function generateViaAgentInternal( m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult', ), transformContext: buildTransformContext(log, deps.onAggressivePrune), - beforeToolCall: async (_context, signal) => { + beforeToolCall: async (context, signal) => { if (input.signal?.aborted || signal?.aborted) { return { block: true, reason: 'Generation cancelled' }; } if (repairLimitReached()) { return { block: true, reason: 'Done repair limit reached; no further tools may run.' }; } - return undefined; + if (toolLoop.halt !== null) { + return { block: true, reason: 'Repeated tool call; no further tools may run.' }; + } + // `done` has its own cap, which reports the remaining verifier errors. + if (context.toolCall.name === 'done') return undefined; + const verdict = toolLoopGuard.check({ name: context.toolCall.name, args: context.args }); + if (verdict.action === 'ok') return undefined; + const loop = { cycle: verdict.cycle, repeats: verdict.repeats }; + if (verdict.action === 'warn') { + log.warn('[generate] step=tool_loop.warn', { ...ctx, ...loop }); + return { block: true, reason: formatToolLoopWarning(loop) }; + } + log.error('[generate] step=tool_loop.halt', { ...ctx, ...loop }); + toolLoop.halt = loop; + return { block: true, reason: formatToolLoopHalt(loop) }; }, getApiKey: input.getApiKey ? async () => { @@ -1361,6 +1414,7 @@ async function generateViaAgentInternal( runProtocolState.todosSet = false; lastDoneDetails = undefined; doneProgress.errorRounds = 0; + toolLoopGuard.reset(); }); deps.onEvent?.(event); if (event.type === 'turn_end') { @@ -1370,6 +1424,12 @@ async function generateViaAgentInternal( if (repairLimitReached()) { throw new CodesignError('Done repair limit reached', ERROR_CODES.GENERATION_INCOMPLETE); } + if (toolLoop.halt !== null) { + throw new CodesignError( + formatToolLoopIncomplete(toolLoop.halt), + ERROR_CODES.GENERATION_INCOMPLETE, + ); + } } }); deps.activeMessages?.bind(retryAgent); @@ -1481,7 +1541,7 @@ async function generateViaAgentInternal( while (true) { const checkMsg = findFinalAssistantMessage(agent.state.messages); if (!checkMsg || checkMsg.stopReason === 'stop') break; - if (input.signal?.aborted || repairLimitReached()) break; + if (input.signal?.aborted || runHalted()) break; const shouldRetryWithoutReasoning = !reasoningFallbackUsed && @@ -1565,8 +1625,7 @@ async function generateViaAgentInternal( if (!finalAssistant) { throw new CodesignError('Agent produced no assistant message', ERROR_CODES.PROVIDER_ERROR); } - const stoppedAfterDoneRepairLimit = repairLimitReached(); - if (finalAssistant.stopReason !== 'stop' && !stoppedAfterDoneRepairLimit) { + if (finalAssistant.stopReason !== 'stop' && !runHalted()) { // Prefer the original `getApiKey` throw (e.g. PROVIDER_AUTH_MISSING after // mid-run logout) over pi-agent-core's flattened plain-string failure, // so the renderer's error-code routing stays consistent with the path @@ -1600,6 +1659,13 @@ async function generateViaAgentInternal( deps.onComplete?.(agent.state.messages); + if (toolLoop.halt !== null) { + throw new CodesignError( + formatToolLoopIncomplete(toolLoop.halt), + ERROR_CODES.GENERATION_INCOMPLETE, + ); + } + if (lastDoneDetails?.status === 'has_errors') { const remaining = lastDoneDetails.errors .slice(0, 8) diff --git a/packages/core/src/tool-loop-guard.test.ts b/packages/core/src/tool-loop-guard.test.ts new file mode 100644 index 00000000..7d7cf712 --- /dev/null +++ b/packages/core/src/tool-loop-guard.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest'; +import { + createToolLoopGuard, + TOOL_LOOP_HALT_REPEATS, + TOOL_LOOP_MAX_CYCLE_LENGTH, + TOOL_LOOP_WARN_REPEATS, + type ToolLoopCall, + type ToolLoopVerdict, +} from './tool-loop-guard'; + +const view = (path = 'App.jsx'): ToolLoopCall => ({ + name: 'str_replace_based_edit_tool', + args: { command: 'view', path }, +}); +const edit = (n: number): ToolLoopCall => ({ + name: 'str_replace_based_edit_tool', + args: { command: 'str_replace', path: 'App.jsx', old_str: `a${n}`, new_str: `b${n}` }, +}); +const preview: ToolLoopCall = { name: 'preview', args: { path: 'App.jsx' } }; + +function feed(calls: ToolLoopCall[]): ToolLoopVerdict[] { + const guard = createToolLoopGuard(); + return calls.map((call) => guard.check(call)); +} + +function repeat(cycle: ToolLoopCall[], times: number): ToolLoopCall[] { + return Array.from({ length: times }, () => cycle).flat(); +} + +describe('createToolLoopGuard', () => { + it('allows fewer identical calls than the warn threshold', () => { + const verdicts = feed(repeat([view()], TOOL_LOOP_WARN_REPEATS - 1)); + expect(verdicts.every((verdict) => verdict.action === 'ok')).toBe(true); + }); + + it('warns on a streak from the warn threshold and halts at the halt threshold', () => { + const verdicts = feed(repeat([view()], TOOL_LOOP_HALT_REPEATS)); + expect(verdicts.map((verdict) => verdict.action)).toEqual([ + ...Array(TOOL_LOOP_WARN_REPEATS - 1).fill('ok'), + ...Array(TOOL_LOOP_HALT_REPEATS - TOOL_LOOP_WARN_REPEATS).fill('warn'), + 'halt', + ]); + expect(verdicts.at(-1)).toEqual({ + action: 'halt', + repeats: TOOL_LOOP_HALT_REPEATS, + cycle: ['str_replace_based_edit_tool'], + }); + }); + + it('keeps counting calls the host blocked after a warning', () => { + const guard = createToolLoopGuard(); + const verdicts = repeat([view()], TOOL_LOOP_HALT_REPEATS).map((call) => guard.check(call)); + const warned = verdicts.findIndex((verdict) => verdict.action === 'warn'); + expect(verdicts.slice(warned).map((verdict) => verdict.action)).toEqual([ + 'warn', + 'warn', + 'halt', + ]); + }); + + it('resets the streak when the arguments change', () => { + const verdicts = feed([ + ...repeat([view()], TOOL_LOOP_WARN_REPEATS), + view('Other.jsx'), + ...repeat([view()], TOOL_LOOP_WARN_REPEATS - 1), + ]); + expect(verdicts.slice(0, TOOL_LOOP_WARN_REPEATS).at(-1)?.action).toBe('warn'); + expect(verdicts.slice(TOOL_LOOP_WARN_REPEATS).every((verdict) => verdict.action === 'ok')).toBe( + true, + ); + }); + + it('treats the same arguments in another key order as the same call', () => { + const verdicts = feed([ + { name: 'preview', args: { path: 'App.jsx', viewport: { width: 1, height: 2 } } }, + { name: 'preview', args: { viewport: { height: 2, width: 1 }, path: 'App.jsx' } }, + { name: 'preview', args: { path: 'App.jsx', viewport: { width: 1, height: 2 } } }, + ]); + expect(verdicts.at(-1)?.action).toBe('warn'); + }); + + it('keeps array order and tool name significant', () => { + const steps = (order: string[]): ToolLoopCall => ({ name: 'preview', args: { order } }); + const verdicts = feed([ + steps(['a', 'b']), + steps(['b', 'a']), + steps(['a', 'b']), + { name: 'other', args: { order: ['a', 'b'] } }, + steps(['a', 'b']), + ]); + expect(verdicts.every((verdict) => verdict.action === 'ok')).toBe(true); + }); + + it.each([2, 3])('detects a cycle of %i calls repeating unchanged', (length) => { + const cycle = [view(), edit(1), preview].slice(0, length); + const verdicts = feed(repeat(cycle, TOOL_LOOP_HALT_REPEATS)); + const firstWarn = verdicts.findIndex((verdict) => verdict.action !== 'ok'); + expect(firstWarn).toBe(TOOL_LOOP_WARN_REPEATS * length - 1); + expect(verdicts[firstWarn]).toEqual({ + action: 'warn', + repeats: TOOL_LOOP_WARN_REPEATS, + cycle: cycle.map((call) => call.name), + }); + expect(verdicts.at(-1)).toMatchObject({ action: 'halt', repeats: TOOL_LOOP_HALT_REPEATS }); + expect(verdicts.findIndex((verdict) => verdict.action === 'halt')).toBe( + TOOL_LOOP_HALT_REPEATS * length - 1, + ); + }); + + it('does not look for cycles longer than the maximum', () => { + const cycle = Array.from({ length: TOOL_LOOP_MAX_CYCLE_LENGTH + 1 }, (_, n) => edit(n)); + const verdicts = feed(repeat(cycle, TOOL_LOOP_HALT_REPEATS)); + expect(verdicts.every((verdict) => verdict.action === 'ok')).toBe(true); + }); + + it('breaks a cycle when one call in it changes', () => { + const verdicts = feed([ + ...repeat([view(), edit(1)], TOOL_LOOP_WARN_REPEATS - 1), + view(), + edit(2), + view(), + edit(3), + ]); + expect(verdicts.every((verdict) => verdict.action === 'ok')).toBe(true); + }); + + it('allows a long design: many different edits with a repeated preview between them', () => { + const calls = Array.from({ length: 60 }, (_, n) => [edit(n), preview]).flat(); + expect(feed(calls).every((verdict) => verdict.action === 'ok')).toBe(true); + }); + + it('does not count a block of one repeated call as a cycle', () => { + const verdicts = feed(repeat([view()], TOOL_LOOP_WARN_REPEATS)); + expect(verdicts.at(-1)).toMatchObject({ + action: 'warn', + cycle: ['str_replace_based_edit_tool'], + }); + }); + + it('forgets earlier calls after reset', () => { + const guard = createToolLoopGuard(); + for (const call of repeat([view()], TOOL_LOOP_WARN_REPEATS)) guard.check(call); + guard.reset(); + expect(guard.check(view())).toEqual({ action: 'ok' }); + }); +}); diff --git a/packages/core/src/tool-loop-guard.ts b/packages/core/src/tool-loop-guard.ts new file mode 100644 index 00000000..c5305ee0 --- /dev/null +++ b/packages/core/src/tool-loop-guard.ts @@ -0,0 +1,109 @@ +/** + * Detects a run that keeps repeating the same tool call. + * + * Two signals, both by tool name plus validated arguments: + * - the same call several times in a row; + * - a short cycle of calls (up to MAX_CYCLE_LENGTH) that repeats unchanged. + * + * A call with different arguments breaks both patterns, so edits to different + * ranges or with different content never count. There is no total per tool: + * a large design legitimately makes dozens of edits. Every call counts, + * including calls the host blocked after a warning, so a model that ignores + * the warning still reaches the halt. + * + * The guard only looks at calls; the caller decides what a warning or a halt + * does. State belongs to one generation and lives as long as the guard does. + */ + +export const TOOL_LOOP_WARN_REPEATS = 3; +export const TOOL_LOOP_HALT_REPEATS = 5; +export const TOOL_LOOP_MAX_CYCLE_LENGTH = 3; + +export interface ToolLoopCall { + name: string; + args: unknown; +} + +export type ToolLoopVerdict = + | { action: 'ok' } + | { + action: 'warn' | 'halt'; + /** Times the pattern has repeated back to back, this call included. */ + repeats: number; + /** Tool names of the repeating pattern, in call order. */ + cycle: string[]; + }; + +export interface ToolLoopGuard { + check(call: ToolLoopCall): ToolLoopVerdict; + reset(): void; +} + +interface RecordedCall { + name: string; + key: string; +} + +const HISTORY_LIMIT = TOOL_LOOP_HALT_REPEATS * TOOL_LOOP_MAX_CYCLE_LENGTH; + +/** Order-independent serialization, so key order never hides a repeat. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (value !== null && typeof value === 'object') { + const record = value as Record; + const fields = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`); + return `{${fields.join(',')}}`; + } + return JSON.stringify(value); +} + +function sameKeys(history: RecordedCall[], start: number, other: number, length: number): boolean { + for (let i = 0; i < length; i++) { + if (history[start + i]?.key !== history[other + i]?.key) return false; + } + return true; +} + +/** How many times the last `length` calls repeat back to back, counting themselves. */ +function trailingRepeats(history: RecordedCall[], length: number): number { + if (history.length < length) return 0; + const block = history.length - length; + let repeats = 1; + while ( + history.length >= (repeats + 1) * length && + sameKeys(history, block, block - repeats * length, length) + ) { + repeats += 1; + } + return repeats; +} + +export function createToolLoopGuard(): ToolLoopGuard { + let history: RecordedCall[] = []; + return { + check(call) { + history.push({ name: call.name, key: `${call.name}\u0000${canonicalJson(call.args)}` }); + if (history.length > HISTORY_LIMIT) history = history.slice(-HISTORY_LIMIT); + let repeats = 0; + let length = 1; + for (let candidate = 1; candidate <= TOOL_LOOP_MAX_CYCLE_LENGTH; candidate++) { + const found = trailingRepeats(history, candidate); + if (found > repeats) { + repeats = found; + length = candidate; + } + } + if (repeats < TOOL_LOOP_WARN_REPEATS) return { action: 'ok' }; + return { + action: repeats >= TOOL_LOOP_HALT_REPEATS ? 'halt' : 'warn', + repeats, + cycle: history.slice(-length).map((recorded) => recorded.name), + }; + }, + reset() { + history = []; + }, + }; +}