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[] = []
constructor(config: LoopDetectionConfig = {}) {
this.maxRepeats = config.maxRepeatedToolCalls ?? 3
this.maxRepeats = config.maxRepetitions ?? 3
const requestedWindow = config.loopDetectionWindow ?? 4
// Window must be >= threshold, otherwise detection can never trigger.
this.windowSize = Math.max(requestedWindow, this.maxRepeats)

View File

@ -350,6 +350,10 @@ export class AgentRunner {
// Fall through to execute tools, then inject warning.
}
// '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. */
export interface LoopDetectionConfig {
/**
* Maximum consecutive times the same tool call (name + args) can repeat
* before detection triggers. Default: `3`.
* Maximum consecutive times the same tool call (name + args) or text
* 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`.
*/

View File

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