fix(agent): reset loopWarned on recovery and rename maxRepeatedToolCalls to maxRepetitions

- Reset loopWarned flag when the agent stops repeating, so a future
  loop gets a fresh warning cycle instead of immediate termination
- Rename maxRepeatedToolCalls → maxRepetitions since the threshold
  applies to both tool call and text output repetition detection
This commit is contained in:
JackChen 2026-04-05 12:56:15 +08:00
parent 56b8cef158
commit 2ecb1f471a
4 changed files with 18 additions and 14 deletions

View File

@ -38,7 +38,7 @@ export class LoopDetector {
private readonly textOutputs: string[] = [] private readonly textOutputs: string[] = []
constructor(config: LoopDetectionConfig = {}) { constructor(config: LoopDetectionConfig = {}) {
this.maxRepeats = config.maxRepeatedToolCalls ?? 3 this.maxRepeats = config.maxRepetitions ?? 3
const requestedWindow = config.loopDetectionWindow ?? 4 const requestedWindow = config.loopDetectionWindow ?? 4
// Window must be >= threshold, otherwise detection can never trigger. // Window must be >= threshold, otherwise detection can never trigger.
this.windowSize = Math.max(requestedWindow, this.maxRepeats) this.windowSize = Math.max(requestedWindow, this.maxRepeats)

View File

@ -350,6 +350,10 @@ export class AgentRunner {
// Fall through to execute tools, then inject warning. // Fall through to execute tools, then inject warning.
} }
// 'continue' — do nothing, let the loop proceed normally. // 'continue' — do nothing, let the loop proceed normally.
} else {
// No loop detected this turn — agent has recovered, so reset
// the warning state. A future loop gets a fresh warning cycle.
loopWarned = false
} }
} }

View File

@ -241,10 +241,10 @@ export interface AgentConfig {
/** Configuration for agent loop detection. */ /** Configuration for agent loop detection. */
export interface LoopDetectionConfig { export interface LoopDetectionConfig {
/** /**
* Maximum consecutive times the same tool call (name + args) can repeat * Maximum consecutive times the same tool call (name + args) or text
* before detection triggers. Default: `3`. * output can repeat before detection triggers. Default: `3`.
*/ */
readonly maxRepeatedToolCalls?: number readonly maxRepetitions?: number
/** /**
* Number of recent turns to track for repetition analysis. Default: `4`. * Number of recent turns to track for repetition analysis. Default: `4`.
*/ */

View File

@ -117,7 +117,7 @@ describe('LoopDetector', () => {
}) })
it('respects custom threshold', () => { it('respects custom threshold', () => {
const detector = new LoopDetector({ maxRepeatedToolCalls: 2 }) const detector = new LoopDetector({ maxRepetitions: 2 })
expect(detector.recordToolCalls([{ name: 'a', input: {} }])).toBeNull() expect(detector.recordToolCalls([{ name: 'a', input: {} }])).toBeNull()
const info = detector.recordToolCalls([{ name: 'a', input: {} }]) const info = detector.recordToolCalls([{ name: 'a', input: {} }])
expect(info).not.toBeNull() expect(info).not.toBeNull()
@ -175,7 +175,7 @@ describe('LoopDetector', () => {
describe('window size', () => { describe('window size', () => {
it('clamps windowSize to at least maxRepeats', () => { it('clamps windowSize to at least maxRepeats', () => {
// Window of 2 with threshold 3 is auto-clamped to 3. // Window of 2 with threshold 3 is auto-clamped to 3.
const detector = new LoopDetector({ loopDetectionWindow: 2, maxRepeatedToolCalls: 3 }) const detector = new LoopDetector({ loopDetectionWindow: 2, maxRepetitions: 3 })
detector.recordToolCalls([{ name: 'a', input: {} }]) detector.recordToolCalls([{ name: 'a', input: {} }])
detector.recordToolCalls([{ name: 'a', input: {} }]) detector.recordToolCalls([{ name: 'a', input: {} }])
// Third call triggers because window was clamped to 3 // Third call triggers because window was clamped to 3
@ -185,7 +185,7 @@ describe('LoopDetector', () => {
}) })
it('works correctly when window >= threshold', () => { it('works correctly when window >= threshold', () => {
const detector = new LoopDetector({ loopDetectionWindow: 4, maxRepeatedToolCalls: 3 }) const detector = new LoopDetector({ loopDetectionWindow: 4, maxRepetitions: 3 })
detector.recordToolCalls([{ name: 'a', input: {} }]) detector.recordToolCalls([{ name: 'a', input: {} }])
detector.recordToolCalls([{ name: 'a', input: {} }]) detector.recordToolCalls([{ name: 'a', input: {} }])
const info = detector.recordToolCalls([{ name: 'a', input: {} }]) const info = detector.recordToolCalls([{ name: 'a', input: {} }])
@ -224,7 +224,7 @@ describe('AgentRunner loop detection', () => {
textResponse('done'), textResponse('done'),
] ]
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: 'terminate', onLoopDetected: 'terminate',
}) })
@ -240,7 +240,7 @@ describe('AgentRunner loop detection', () => {
textResponse('done'), textResponse('done'),
] ]
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: 'terminate', onLoopDetected: 'terminate',
}) })
@ -262,7 +262,7 @@ describe('AgentRunner loop detection', () => {
textResponse('done'), textResponse('done'),
] ]
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: 'terminate', onLoopDetected: 'terminate',
}) })
@ -283,7 +283,7 @@ describe('AgentRunner loop detection', () => {
textResponse('done'), textResponse('done'),
] ]
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: 'warn', onLoopDetected: 'warn',
}) })
@ -301,7 +301,7 @@ describe('AgentRunner loop detection', () => {
] ]
const callback = vi.fn().mockReturnValue('terminate') const callback = vi.fn().mockReturnValue('terminate')
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: callback, onLoopDetected: callback,
}) })
@ -320,7 +320,7 @@ describe('AgentRunner loop detection', () => {
] ]
const callback = vi.fn().mockReturnValue('inject') const callback = vi.fn().mockReturnValue('inject')
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: callback, onLoopDetected: callback,
}) })
@ -338,7 +338,7 @@ describe('AgentRunner loop detection', () => {
] ]
const callback = vi.fn().mockReturnValue('continue') const callback = vi.fn().mockReturnValue('continue')
const runner = buildRunner(responses, { const runner = buildRunner(responses, {
maxRepeatedToolCalls: 3, maxRepetitions: 3,
onLoopDetected: callback, onLoopDetected: callback,
}) })