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:
parent
56b8cef158
commit
2ecb1f471a
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue