Implement hashing based loop detection (#4831)
This commit is contained in:
parent
91f016d44a
commit
d76cedb68f
|
@ -23,6 +23,7 @@ vi.mock('../telemetry/loggers.js', () => ({
|
||||||
|
|
||||||
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
||||||
const CONTENT_LOOP_THRESHOLD = 10;
|
const CONTENT_LOOP_THRESHOLD = 10;
|
||||||
|
const CONTENT_CHUNK_SIZE = 50;
|
||||||
|
|
||||||
describe('LoopDetectionService', () => {
|
describe('LoopDetectionService', () => {
|
||||||
let service: LoopDetectionService;
|
let service: LoopDetectionService;
|
||||||
|
@ -79,7 +80,7 @@ describe('LoopDetectionService', () => {
|
||||||
service.addAndCheck(event);
|
service.addAndCheck(event);
|
||||||
}
|
}
|
||||||
expect(service.addAndCheck(event)).toBe(true);
|
expect(service.addAndCheck(event)).toBe(true);
|
||||||
expect(loggers.logLoopDetected).toHaveBeenCalledTimes(2);
|
expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not detect a loop for different tool calls', () => {
|
it('should not detect a loop for different tool calls', () => {
|
||||||
|
@ -123,179 +124,62 @@ describe('LoopDetectionService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Content Loop Detection', () => {
|
describe('Content Loop Detection', () => {
|
||||||
it(`should not detect a loop for fewer than CONTENT_LOOP_THRESHOLD identical content strings`, () => {
|
const generateRandomString = (length: number) => {
|
||||||
const event = createContentEvent('This is a test sentence.');
|
let result = '';
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
const characters =
|
||||||
expect(service.addAndCheck(event)).toBe(false);
|
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const charactersLength = characters.length;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += characters.charAt(
|
||||||
|
Math.floor(Math.random() * charactersLength),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should not detect a loop for random content', () => {
|
||||||
|
service.reset('');
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
const content = generateRandomString(10);
|
||||||
|
const isLoop = service.addAndCheck(createContentEvent(content));
|
||||||
|
expect(isLoop).toBe(false);
|
||||||
}
|
}
|
||||||
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`should detect a loop on the CONTENT_LOOP_THRESHOLD-th identical content string`, () => {
|
it('should detect a loop when a chunk of content repeats consecutively', () => {
|
||||||
const event = createContentEvent('This is a test sentence.');
|
service.reset('');
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
const repeatedContent = 'a'.repeat(CONTENT_CHUNK_SIZE);
|
||||||
service.addAndCheck(event);
|
|
||||||
|
let isLoop = false;
|
||||||
|
for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) {
|
||||||
|
for (const char of repeatedContent) {
|
||||||
|
isLoop = service.addAndCheck(createContentEvent(char));
|
||||||
}
|
}
|
||||||
expect(service.addAndCheck(event)).toBe(true);
|
}
|
||||||
|
expect(isLoop).toBe(true);
|
||||||
expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1);
|
expect(loggers.logLoopDetected).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not detect a loop for different content strings', () => {
|
it('should not detect a loop if repetitions are very far apart', () => {
|
||||||
const event1 = createContentEvent('Sentence A');
|
service.reset('');
|
||||||
const event2 = createContentEvent('Sentence B');
|
const repeatedContent = 'b'.repeat(CONTENT_CHUNK_SIZE);
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 2; i++) {
|
const fillerContent = generateRandomString(500);
|
||||||
expect(service.addAndCheck(event1)).toBe(false);
|
|
||||||
expect(service.addAndCheck(event2)).toBe(false);
|
let isLoop = false;
|
||||||
|
for (let i = 0; i < CONTENT_LOOP_THRESHOLD; i++) {
|
||||||
|
for (const char of repeatedContent) {
|
||||||
|
isLoop = service.addAndCheck(createContentEvent(char));
|
||||||
}
|
}
|
||||||
|
for (const char of fillerContent) {
|
||||||
|
isLoop = service.addAndCheck(createContentEvent(char));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(isLoop).toBe(false);
|
||||||
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Sentence Extraction and Punctuation', () => {
|
|
||||||
it('should not check for loops when content has no sentence-ending punctuation', () => {
|
|
||||||
const eventNoPunct = createContentEvent('This has no punctuation');
|
|
||||||
expect(service.addAndCheck(eventNoPunct)).toBe(false);
|
|
||||||
|
|
||||||
const eventWithPunct = createContentEvent('This has punctuation!');
|
|
||||||
expect(service.addAndCheck(eventWithPunct)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not treat function calls or method calls as sentence endings', () => {
|
|
||||||
// These should not trigger sentence detection, so repeating them many times should never cause a loop
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
|
|
||||||
expect(service.addAndCheck(createContentEvent('console.log()'))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
service.reset('');
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
|
|
||||||
expect(service.addAndCheck(createContentEvent('obj.method()'))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
service.reset('');
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('arr.filter().map()')),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
service.reset('');
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD + 2; i++) {
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(
|
|
||||||
createContentEvent('if (condition) { return true; }'),
|
|
||||||
),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should correctly identify actual sentence endings and trigger loop detection', () => {
|
|
||||||
// These should trigger sentence detection, so repeating them should eventually cause a loop
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('This is a sentence.')),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('This is a sentence.')),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
service.reset('');
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('Is this a question? ')),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('Is this a question? ')),
|
|
||||||
).toBe(true);
|
|
||||||
|
|
||||||
service.reset('');
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('What excitement!\n')),
|
|
||||||
).toBe(false);
|
|
||||||
}
|
|
||||||
expect(
|
|
||||||
service.addAndCheck(createContentEvent('What excitement!\n')),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle content with mixed punctuation', () => {
|
|
||||||
service.addAndCheck(createContentEvent('Question?'));
|
|
||||||
service.addAndCheck(createContentEvent('Exclamation!'));
|
|
||||||
service.addAndCheck(createContentEvent('Period.'));
|
|
||||||
|
|
||||||
// Repeat one of them multiple times
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
|
||||||
service.addAndCheck(createContentEvent('Period.'));
|
|
||||||
}
|
|
||||||
expect(service.addAndCheck(createContentEvent('Period.'))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty sentences after trimming', () => {
|
|
||||||
service.addAndCheck(createContentEvent(' .'));
|
|
||||||
expect(service.addAndCheck(createContentEvent('Normal sentence.'))).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should require at least two sentences for loop detection', () => {
|
|
||||||
const event = createContentEvent('Only one sentence.');
|
|
||||||
expect(service.addAndCheck(event)).toBe(false);
|
|
||||||
|
|
||||||
// Even repeating the same single sentence shouldn't trigger detection
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
expect(service.addAndCheck(event)).toBe(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Performance Optimizations', () => {
|
|
||||||
it('should cache sentence extraction and only re-extract when content grows significantly', () => {
|
|
||||||
// Add initial content
|
|
||||||
service.addAndCheck(createContentEvent('First sentence.'));
|
|
||||||
service.addAndCheck(createContentEvent('Second sentence.'));
|
|
||||||
|
|
||||||
// Add small amounts of content (shouldn't trigger re-extraction)
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
service.addAndCheck(createContentEvent('X'));
|
|
||||||
}
|
|
||||||
service.addAndCheck(createContentEvent('.'));
|
|
||||||
|
|
||||||
// Should still work correctly
|
|
||||||
expect(service.addAndCheck(createContentEvent('Test.'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should re-extract sentences when content grows by more than 100 characters', () => {
|
|
||||||
service.addAndCheck(createContentEvent('Initial sentence.'));
|
|
||||||
|
|
||||||
// Add enough content to trigger re-extraction
|
|
||||||
const longContent = 'X'.repeat(101);
|
|
||||||
service.addAndCheck(createContentEvent(longContent + '.'));
|
|
||||||
|
|
||||||
// Should work correctly after re-extraction
|
|
||||||
expect(service.addAndCheck(createContentEvent('Test.'))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use indexOf for efficient counting instead of regex', () => {
|
|
||||||
const repeatedSentence = 'This is a repeated sentence.';
|
|
||||||
|
|
||||||
// Build up content with the sentence repeated
|
|
||||||
for (let i = 0; i < CONTENT_LOOP_THRESHOLD - 1; i++) {
|
|
||||||
service.addAndCheck(createContentEvent(repeatedSentence));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The threshold should be reached
|
|
||||||
expect(service.addAndCheck(createContentEvent(repeatedSentence))).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle empty content', () => {
|
it('should handle empty content', () => {
|
||||||
const event = createContentEvent('');
|
const event = createContentEvent('');
|
||||||
|
|
|
@ -13,6 +13,8 @@ import { SchemaUnion, Type } from '@google/genai';
|
||||||
|
|
||||||
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
const TOOL_CALL_LOOP_THRESHOLD = 5;
|
||||||
const CONTENT_LOOP_THRESHOLD = 10;
|
const CONTENT_LOOP_THRESHOLD = 10;
|
||||||
|
const CONTENT_CHUNK_SIZE = 50;
|
||||||
|
const MAX_HISTORY_LENGTH = 1000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The number of recent conversation turns to include in the history when asking the LLM to check for a loop.
|
* The number of recent conversation turns to include in the history when asking the LLM to check for a loop.
|
||||||
|
@ -42,8 +44,6 @@ const MIN_LLM_CHECK_INTERVAL = 5;
|
||||||
*/
|
*/
|
||||||
const MAX_LLM_CHECK_INTERVAL = 15;
|
const MAX_LLM_CHECK_INTERVAL = 15;
|
||||||
|
|
||||||
const SENTENCE_ENDING_PUNCTUATION_REGEX = /[.!?]+(?=\s|$)/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for detecting and preventing infinite loops in AI responses.
|
* Service for detecting and preventing infinite loops in AI responses.
|
||||||
* Monitors tool call repetitions and content sentence repetitions.
|
* Monitors tool call repetitions and content sentence repetitions.
|
||||||
|
@ -57,9 +57,10 @@ export class LoopDetectionService {
|
||||||
private toolCallRepetitionCount: number = 0;
|
private toolCallRepetitionCount: number = 0;
|
||||||
|
|
||||||
// Content streaming tracking
|
// Content streaming tracking
|
||||||
private lastRepeatedSentence: string = '';
|
private streamContentHistory = '';
|
||||||
private sentenceRepetitionCount: number = 0;
|
private contentStats = new Map<string, number[]>();
|
||||||
private partialContent: string = '';
|
private lastContentIndex = 0;
|
||||||
|
private loopDetected = false;
|
||||||
|
|
||||||
// LLM loop track tracking
|
// LLM loop track tracking
|
||||||
private turnsInCurrentPrompt = 0;
|
private turnsInCurrentPrompt = 0;
|
||||||
|
@ -82,17 +83,24 @@ export class LoopDetectionService {
|
||||||
* @returns true if a loop is detected, false otherwise
|
* @returns true if a loop is detected, false otherwise
|
||||||
*/
|
*/
|
||||||
addAndCheck(event: ServerGeminiStreamEvent): boolean {
|
addAndCheck(event: ServerGeminiStreamEvent): boolean {
|
||||||
|
if (this.loopDetected) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case GeminiEventType.ToolCallRequest:
|
case GeminiEventType.ToolCallRequest:
|
||||||
// content chanting only happens in one single stream, reset if there
|
// content chanting only happens in one single stream, reset if there
|
||||||
// is a tool call in between
|
// is a tool call in between
|
||||||
this.resetSentenceCount();
|
this.resetContentTracking();
|
||||||
return this.checkToolCallLoop(event.value);
|
this.loopDetected = this.checkToolCallLoop(event.value);
|
||||||
|
break;
|
||||||
case GeminiEventType.Content:
|
case GeminiEventType.Content:
|
||||||
return this.checkContentLoop(event.value);
|
this.loopDetected = this.checkContentLoop(event.value);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return false;
|
break;
|
||||||
}
|
}
|
||||||
|
return this.loopDetected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -140,38 +148,74 @@ export class LoopDetectionService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects content loops by analyzing streaming text for repetitive patterns.
|
||||||
|
*
|
||||||
|
* The algorithm works by:
|
||||||
|
* 1. Appending new content to the streaming history
|
||||||
|
* 2. Truncating history if it exceeds the maximum length
|
||||||
|
* 3. Analyzing content chunks for repetitive patterns using hashing
|
||||||
|
* 4. Detecting loops when identical chunks appear frequently within a short distance
|
||||||
|
*/
|
||||||
private checkContentLoop(content: string): boolean {
|
private checkContentLoop(content: string): boolean {
|
||||||
this.partialContent += content;
|
this.streamContentHistory += content;
|
||||||
|
|
||||||
if (!SENTENCE_ENDING_PUNCTUATION_REGEX.test(this.partialContent)) {
|
this.truncateAndUpdate();
|
||||||
return false;
|
return this.analyzeContentChunksForLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeSentences =
|
/**
|
||||||
this.partialContent.match(/[^.!?]+[.!?]+(?=\s|$)/g) || [];
|
* Truncates the content history to prevent unbounded memory growth.
|
||||||
if (completeSentences.length === 0) {
|
* When truncating, adjusts all stored indices to maintain their relative positions.
|
||||||
return false;
|
*/
|
||||||
|
private truncateAndUpdate(): void {
|
||||||
|
if (this.streamContentHistory.length <= MAX_HISTORY_LENGTH) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastSentence = completeSentences[completeSentences.length - 1];
|
// Calculate how much content to remove from the beginning
|
||||||
const lastCompleteIndex = this.partialContent.lastIndexOf(lastSentence);
|
const truncationAmount =
|
||||||
const endOfLastSentence = lastCompleteIndex + lastSentence.length;
|
this.streamContentHistory.length - MAX_HISTORY_LENGTH;
|
||||||
this.partialContent = this.partialContent.slice(endOfLastSentence);
|
this.streamContentHistory =
|
||||||
|
this.streamContentHistory.slice(truncationAmount);
|
||||||
|
this.lastContentIndex = Math.max(
|
||||||
|
0,
|
||||||
|
this.lastContentIndex - truncationAmount,
|
||||||
|
);
|
||||||
|
|
||||||
for (const sentence of completeSentences) {
|
// Update all stored chunk indices to account for the truncation
|
||||||
const trimmedSentence = sentence.trim();
|
for (const [hash, oldIndices] of this.contentStats.entries()) {
|
||||||
if (trimmedSentence === '') {
|
const adjustedIndices = oldIndices
|
||||||
continue;
|
.map((index) => index - truncationAmount)
|
||||||
}
|
.filter((index) => index >= 0);
|
||||||
|
|
||||||
if (this.lastRepeatedSentence === trimmedSentence) {
|
if (adjustedIndices.length > 0) {
|
||||||
this.sentenceRepetitionCount++;
|
this.contentStats.set(hash, adjustedIndices);
|
||||||
} else {
|
} else {
|
||||||
this.lastRepeatedSentence = trimmedSentence;
|
this.contentStats.delete(hash);
|
||||||
this.sentenceRepetitionCount = 1;
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sentenceRepetitionCount >= CONTENT_LOOP_THRESHOLD) {
|
/**
|
||||||
|
* Analyzes content in fixed-size chunks to detect repetitive patterns.
|
||||||
|
*
|
||||||
|
* Uses a sliding window approach:
|
||||||
|
* 1. Extract chunks of fixed size (CONTENT_CHUNK_SIZE)
|
||||||
|
* 2. Hash each chunk for efficient comparison
|
||||||
|
* 3. Track positions where identical chunks appear
|
||||||
|
* 4. Detect loops when chunks repeat frequently within a short distance
|
||||||
|
*/
|
||||||
|
private analyzeContentChunksForLoop(): boolean {
|
||||||
|
while (this.hasMoreChunksToProcess()) {
|
||||||
|
// Extract current chunk of text
|
||||||
|
const currentChunk = this.streamContentHistory.substring(
|
||||||
|
this.lastContentIndex,
|
||||||
|
this.lastContentIndex + CONTENT_CHUNK_SIZE,
|
||||||
|
);
|
||||||
|
const chunkHash = createHash('sha256').update(currentChunk).digest('hex');
|
||||||
|
|
||||||
|
if (this.isLoopDetectedForChunk(currentChunk, chunkHash)) {
|
||||||
logLoopDetected(
|
logLoopDetected(
|
||||||
this.config,
|
this.config,
|
||||||
new LoopDetectedEvent(
|
new LoopDetectedEvent(
|
||||||
|
@ -181,10 +225,74 @@ export class LoopDetectionService {
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move to next position in the sliding window
|
||||||
|
this.lastContentIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasMoreChunksToProcess(): boolean {
|
||||||
|
return (
|
||||||
|
this.lastContentIndex + CONTENT_CHUNK_SIZE <=
|
||||||
|
this.streamContentHistory.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a content chunk indicates a loop pattern.
|
||||||
|
*
|
||||||
|
* Loop detection logic:
|
||||||
|
* 1. Check if we've seen this hash before (new chunks are stored for future comparison)
|
||||||
|
* 2. Verify actual content matches to prevent hash collisions
|
||||||
|
* 3. Track all positions where this chunk appears
|
||||||
|
* 4. A loop is detected when the same chunk appears CONTENT_LOOP_THRESHOLD times
|
||||||
|
* within a small average distance (≤ 1.5 * chunk size)
|
||||||
|
*/
|
||||||
|
private isLoopDetectedForChunk(chunk: string, hash: string): boolean {
|
||||||
|
const existingIndices = this.contentStats.get(hash);
|
||||||
|
|
||||||
|
if (!existingIndices) {
|
||||||
|
this.contentStats.set(hash, [this.lastContentIndex]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isActualContentMatch(chunk, existingIndices[0])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
existingIndices.push(this.lastContentIndex);
|
||||||
|
|
||||||
|
if (existingIndices.length < CONTENT_LOOP_THRESHOLD) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze the most recent occurrences to see if they're clustered closely together
|
||||||
|
const recentIndices = existingIndices.slice(-CONTENT_LOOP_THRESHOLD);
|
||||||
|
const totalDistance =
|
||||||
|
recentIndices[recentIndices.length - 1] - recentIndices[0];
|
||||||
|
const averageDistance = totalDistance / (CONTENT_LOOP_THRESHOLD - 1);
|
||||||
|
const maxAllowedDistance = CONTENT_CHUNK_SIZE * 1.5;
|
||||||
|
|
||||||
|
return averageDistance <= maxAllowedDistance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies that two chunks with the same hash actually contain identical content.
|
||||||
|
* This prevents false positives from hash collisions.
|
||||||
|
*/
|
||||||
|
private isActualContentMatch(
|
||||||
|
currentChunk: string,
|
||||||
|
originalIndex: number,
|
||||||
|
): boolean {
|
||||||
|
const originalChunk = this.streamContentHistory.substring(
|
||||||
|
originalIndex,
|
||||||
|
originalIndex + CONTENT_CHUNK_SIZE,
|
||||||
|
);
|
||||||
|
return originalChunk === currentChunk;
|
||||||
|
}
|
||||||
|
|
||||||
private async checkForLoopWithLLM(signal: AbortSignal) {
|
private async checkForLoopWithLLM(signal: AbortSignal) {
|
||||||
const recentHistory = this.config
|
const recentHistory = this.config
|
||||||
.getGeminiClient()
|
.getGeminiClient()
|
||||||
|
@ -261,8 +369,9 @@ Please analyze the conversation history to determine the possibility that the co
|
||||||
reset(promptId: string): void {
|
reset(promptId: string): void {
|
||||||
this.promptId = promptId;
|
this.promptId = promptId;
|
||||||
this.resetToolCallCount();
|
this.resetToolCallCount();
|
||||||
this.resetSentenceCount();
|
this.resetContentTracking();
|
||||||
this.resetLlmCheckTracking();
|
this.resetLlmCheckTracking();
|
||||||
|
this.loopDetected = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetToolCallCount(): void {
|
private resetToolCallCount(): void {
|
||||||
|
@ -270,10 +379,12 @@ Please analyze the conversation history to determine the possibility that the co
|
||||||
this.toolCallRepetitionCount = 0;
|
this.toolCallRepetitionCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetSentenceCount(): void {
|
private resetContentTracking(resetHistory = true): void {
|
||||||
this.lastRepeatedSentence = '';
|
if (resetHistory) {
|
||||||
this.sentenceRepetitionCount = 0;
|
this.streamContentHistory = '';
|
||||||
this.partialContent = '';
|
}
|
||||||
|
this.contentStats.clear();
|
||||||
|
this.lastContentIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetLlmCheckTracking(): void {
|
private resetLlmCheckTracking(): void {
|
||||||
|
|
Loading…
Reference in New Issue