fix(core): mitigate infinite ReAct loops and prompt injection loops (#28429)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
amelidev
2026-07-17 15:03:46 -06:00
committed by GitHub
parent 69e0c2659e
commit acae7124bd
2 changed files with 120 additions and 11 deletions
@@ -175,6 +175,93 @@ describe('LoopDetectionService', () => {
}
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
});
it('should detect an alternating tool call loop (cycle of length 2)', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
let loopCount = 0;
for (let i = 0; i < 15; i++) {
const currentEvent = i % 2 === 0 ? eventA : eventB;
const result = service.addAndCheck(currentEvent);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 3', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
let loopCount = 0;
const sequence = [eventA, eventB, eventC];
for (let i = 0; i < 20; i++) {
const result = service.addAndCheck(sequence[i % 3]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should detect a cyclic tool call loop of length 5', () => {
const e1 = createToolCallRequestEvent('t', { id: 1 });
const e2 = createToolCallRequestEvent('t', { id: 2 });
const e3 = createToolCallRequestEvent('t', { id: 3 });
const e4 = createToolCallRequestEvent('t', { id: 4 });
const e5 = createToolCallRequestEvent('t', { id: 5 });
let loopCount = 0;
const sequence = [e1, e2, e3, e4, e5];
for (let i = 0; i < 30; i++) {
const result = service.addAndCheck(sequence[i % 5]);
if (result.count > 0) {
loopCount = result.count;
}
}
expect(loopCount).toBeGreaterThan(0);
});
it('should not detect loops for non-looping alternating sequences that vary', () => {
const eventA = createToolCallRequestEvent('read_file', {
file_path: 'loop_a.txt',
});
const eventB = createToolCallRequestEvent('read_file', {
file_path: 'loop_b.txt',
});
const eventC = createToolCallRequestEvent('read_file', {
file_path: 'loop_c.txt',
});
// Run some alternating calls, then break the pattern with eventC
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
expect(service.addAndCheck(eventC).count).toBe(0);
for (let i = 0; i < 4; i++) {
expect(service.addAndCheck(i % 2 === 0 ? eventA : eventB).count).toBe(
0,
);
}
});
});
describe('Content Loop Detection', () => {
@@ -136,8 +136,7 @@ export class LoopDetectionService {
private userPrompt = '';
// Tool call tracking
private lastToolCallKey: string | null = null;
private toolCallRepetitionCount: number = 0;
private toolCallHistory: string[] = [];
// Content streaming tracking
private streamContentHistory = '';
@@ -313,15 +312,39 @@ export class LoopDetectionService {
private checkToolCallLoop(toolCall: { name: string; args: object }): boolean {
const key = this.getToolCallKey(toolCall);
if (this.lastToolCallKey === key) {
this.toolCallRepetitionCount++;
} else {
this.lastToolCallKey = key;
this.toolCallRepetitionCount = 1;
this.toolCallHistory.push(key);
const maxRequiredLength = 5 * TOOL_CALL_LOOP_THRESHOLD;
if (this.toolCallHistory.length > maxRequiredLength) {
this.toolCallHistory = this.toolCallHistory.slice(-maxRequiredLength);
}
if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
return true;
const n = this.toolCallHistory.length;
const R = TOOL_CALL_LOOP_THRESHOLD; // 5
// Check for repeating patterns of cycle length k from 1 to 5
for (let k = 1; k <= 5; k++) {
const requiredLength = k * R;
if (n >= requiredLength) {
const cycle = this.toolCallHistory.slice(-k);
let isPatternMatch = true;
for (let i = 0; i < requiredLength; i++) {
const indexFromEnd = requiredLength - i;
const actualKey = this.toolCallHistory[n - indexFromEnd];
const expectedKey = cycle[i % k];
if (actualKey !== expectedKey) {
isPatternMatch = false;
break;
}
}
if (isPatternMatch) {
return true;
}
}
}
return false;
}
@@ -739,8 +762,7 @@ export class LoopDetectionService {
}
private resetToolCallCount(): void {
this.lastToolCallKey = null;
this.toolCallRepetitionCount = 0;
this.toolCallHistory = [];
}
private resetContentTracking(resetHistory = true): void {