From 4d24c9244fafa940457fe2ed5463dc237a986d4e Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:27:44 +0800 Subject: [PATCH 1/2] feat(core): add a detector for repeated tool calls createToolLoopGuard() sees each tool call (name plus validated arguments) and answers ok, warn or halt. It looks for the same call several times in a row and for a short cycle of calls (up to three) that repeats unchanged. A call with different arguments breaks both patterns, and there is no total per tool. Thresholds are named constants: warn at 3 repeats, halt at 5. Every call counts, including calls the host blocked after a warning. Refs #487 --- packages/core/src/tool-loop-guard.test.ts | 146 ++++++++++++++++++++++ packages/core/src/tool-loop-guard.ts | 109 ++++++++++++++++ 2 files changed, 255 insertions(+) create mode 100644 packages/core/src/tool-loop-guard.test.ts create mode 100644 packages/core/src/tool-loop-guard.ts 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 = []; + }, + }; +} From 4176ffb231e0078149ebd64882743281d348b729 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 14 Oct 2026 17:27:45 +0800 Subject: [PATCH 2/2] feat(core): stop a run that keeps repeating the same tool call Wire the detector into the beforeToolCall hook that agent.ts already uses for cancellation and the done repair limit. warn blocks the call; pi returns the reason as the error tool result, so no message is injected. halt records the stop, blocks every later call, and reuses the done repair limit's end of run: the turn_end listener throws GENERATION_INCOMPLETE before pi drains its steering and follow-up queues, and the outer boundary skips recovery and rethrows the error with the workspace files kept. Queued messages stay recoverable through ActiveRunMessages.close(). done is left to its own repair limit. The tests run a real pi Agent with a scripted model stream and cover warn, halt, a halt inside one response, a cross-call cycle, a long design that must not trip, done, and the reset on a delivered user message. Closes #487 --- .changeset/stop-repeated-tool-calls.md | 5 + .../core/src/agent.native-lifecycle.test.ts | 230 +++++++++++++++++- packages/core/src/agent.ts | 76 +++++- 3 files changed, 305 insertions(+), 6 deletions(-) create mode 100644 .changeset/stop-repeated-tool-calls.md 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)