/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { createHash } from 'node:crypto';
import { GeminiEventType } from '../core/turn.js';
import { logLoopDetected } from '../telemetry/loggers.js';
import { LoopDetectedEvent, LoopType } from '../telemetry/types.js';
const TOOL_CALL_LOOP_THRESHOLD = 10;
const CONTENT_LOOP_THRESHOLD = 15;
const CONTENT_CHUNK_SIZE = 50;
const MAX_HISTORY_LENGTH = 1000;
/**
 * Service for detecting and preventing infinite loops in AI responses.
 * Monitors tool call repetitions and content sentence repetitions.
 */
export class LoopDetectionService {
    config;
    promptId = '';
    // Tool call tracking
    lastToolCallKey = null;
    toolCallRepetitionCount = 0;
    // Content streaming tracking
    streamContentHistory = '';
    contentStats = new Map();
    lastContentIndex = 0;
    loopDetected = false;
    inCodeBlock = false;
    // Turn tracking for potential future rule-based checks
    turnsInCurrentPrompt = 0;
    constructor(config) {
        this.config = config;
    }
    getToolCallKey(toolCall) {
        const argsString = JSON.stringify(toolCall.args);
        const keyString = `${toolCall.name}:${argsString}`;
        return createHash('sha256').update(keyString).digest('hex');
    }
    /**
     * Processes a stream event and checks for loop conditions.
     * @param event - The stream event to process
     * @returns true if a loop is detected, false otherwise
     */
    addAndCheck(event) {
        if (this.loopDetected) {
            return true;
        }
        switch (event.type) {
            case GeminiEventType.ToolCallRequest:
                // content chanting only happens in one single stream, reset if there
                // is a tool call in between
                this.resetContentTracking();
                this.loopDetected = this.checkToolCallLoop(event.value);
                break;
            case GeminiEventType.Content:
                this.loopDetected = this.checkContentLoop(event.value);
                break;
            default:
                break;
        }
        return this.loopDetected;
    }
    /**
     * Signals the start of a new turn in the conversation.
     *
     * This method increments the turn counter and checks if the maximum
     * number of turns per prompt has been exceeded.
     *
     * @param _signal - An AbortSignal (currently unused)
     * @returns true if max turns exceeded, false otherwise
     */
    async turnStarted(_signal) {
        this.turnsInCurrentPrompt++;
        // Check if max turns per prompt is configured and exceeded
        const maxTurnsPerPrompt = this.config.getEphemeralSetting('maxTurnsPerPrompt') ?? 200;
        if (maxTurnsPerPrompt > 0 &&
            this.turnsInCurrentPrompt >= maxTurnsPerPrompt) {
            logLoopDetected(this.config, new LoopDetectedEvent(LoopType.MAX_TURNS_EXCEEDED, this.promptId));
            return true;
        }
        return false;
    }
    checkToolCallLoop(toolCall) {
        const key = this.getToolCallKey(toolCall);
        if (this.lastToolCallKey === key) {
            this.toolCallRepetitionCount++;
        }
        else {
            this.lastToolCallKey = key;
            this.toolCallRepetitionCount = 1;
        }
        if (this.toolCallRepetitionCount >= TOOL_CALL_LOOP_THRESHOLD) {
            logLoopDetected(this.config, new LoopDetectedEvent(LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS, this.promptId));
            return true;
        }
        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
     * 5. Disabling loop detection within code blocks to prevent false positives,
     *    as repetitive code structures are common and not necessarily loops.
     */
    checkContentLoop(content) {
        // Different content elements can often contain repetitive syntax that is not indicative of a loop.
        // To avoid false positives, we detect when we encounter different content types and
        // reset tracking to avoid analyzing content that spans across different element boundaries.
        const numFences = (content.match(/```/g) ?? []).length;
        const hasTable = /(^|\n)\s*(\|.*\||[|+-]{3,})/.test(content);
        const hasListItem = /(^|\n)\s*[*-+]\s/.test(content) || /(^|\n)\s*\d+\.\s/.test(content);
        const hasHeading = /(^|\n)#+\s/.test(content);
        const hasBlockquote = /(^|\n)>\s/.test(content);
        const isDivider = /^[+-_=*\u2500-\u257F]+$/.test(content);
        if (numFences ||
            hasTable ||
            hasListItem ||
            hasHeading ||
            hasBlockquote ||
            isDivider) {
            // Reset tracking when different content elements are detected to avoid analyzing content
            // that spans across different element boundaries.
            this.resetContentTracking();
        }
        const wasInCodeBlock = this.inCodeBlock;
        this.inCodeBlock =
            numFences % 2 === 0 ? this.inCodeBlock : !this.inCodeBlock;
        if (wasInCodeBlock || this.inCodeBlock || isDivider) {
            return false;
        }
        this.streamContentHistory += content;
        this.truncateAndUpdate();
        return this.analyzeContentChunksForLoop();
    }
    /**
     * Truncates the content history to prevent unbounded memory growth.
     * When truncating, adjusts all stored indices to maintain their relative positions.
     */
    truncateAndUpdate() {
        if (this.streamContentHistory.length <= MAX_HISTORY_LENGTH) {
            return;
        }
        // Calculate how much content to remove from the beginning
        const truncationAmount = this.streamContentHistory.length - MAX_HISTORY_LENGTH;
        this.streamContentHistory =
            this.streamContentHistory.slice(truncationAmount);
        this.lastContentIndex = Math.max(0, this.lastContentIndex - truncationAmount);
        // Update all stored chunk indices to account for the truncation
        for (const [hash, oldIndices] of this.contentStats.entries()) {
            const adjustedIndices = oldIndices
                .map((index) => index - truncationAmount)
                .filter((index) => index >= 0);
            if (adjustedIndices.length > 0) {
                this.contentStats.set(hash, adjustedIndices);
            }
            else {
                this.contentStats.delete(hash);
            }
        }
    }
    /**
     * 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
     */
    analyzeContentChunksForLoop() {
        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(this.config, new LoopDetectedEvent(LoopType.CHANTING_IDENTICAL_SENTENCES, this.promptId));
                return true;
            }
            // Move to next position in the sliding window
            this.lastContentIndex++;
        }
        return false;
    }
    hasMoreChunksToProcess() {
        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)
     */
    isLoopDetectedForChunk(chunk, hash) {
        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.
     */
    isActualContentMatch(currentChunk, originalIndex) {
        const originalChunk = this.streamContentHistory.substring(originalIndex, originalIndex + CONTENT_CHUNK_SIZE);
        return originalChunk === currentChunk;
    }
    /**
     * Resets all loop detection state.
     */
    reset(promptId) {
        this.promptId = promptId;
        this.resetToolCallCount();
        this.resetContentTracking();
        this.resetTurnTracking();
        this.loopDetected = false;
    }
    resetToolCallCount() {
        this.lastToolCallKey = null;
        this.toolCallRepetitionCount = 0;
    }
    resetContentTracking(resetHistory = true) {
        if (resetHistory) {
            this.streamContentHistory = '';
        }
        this.contentStats.clear();
        this.lastContentIndex = 0;
    }
    resetTurnTracking() {
        this.turnsInCurrentPrompt = 0;
    }
}
//# sourceMappingURL=loopDetectionService.js.map