From f7ff26ba6596762f4b5b9f57816a586905f09d7d Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 15 Sep 2025 14:12:39 -0700 Subject: [PATCH] feat(logging): Add clearcut logging for disabling loop detection (#8503) --- .../src/services/loopDetectionService.test.ts | 3 +++ .../core/src/services/loopDetectionService.ts | 15 +++++++++++++-- .../telemetry/clearcut-logger/clearcut-logger.ts | 10 ++++++++++ packages/core/src/telemetry/loggers.ts | 8 ++++++++ packages/core/src/telemetry/types.ts | 13 +++++++++++++ 5 files changed, 47 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/loopDetectionService.test.ts b/packages/core/src/services/loopDetectionService.test.ts index cc9f25afb9..aaf7f90829 100644 --- a/packages/core/src/services/loopDetectionService.test.ts +++ b/packages/core/src/services/loopDetectionService.test.ts @@ -20,6 +20,7 @@ import { LoopDetectionService } from './loopDetectionService.js'; vi.mock('../telemetry/loggers.js', () => ({ logLoopDetected: vi.fn(), + logLoopDetectionDisabled: vi.fn(), })); const TOOL_CALL_LOOP_THRESHOLD = 5; @@ -134,6 +135,7 @@ describe('LoopDetectionService', () => { it('should not detect a loop when disabled for session', () => { service.disableForSession(); + expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(1); const event = createToolCallRequestEvent('testTool', { param: 'value' }); for (let i = 0; i < TOOL_CALL_LOOP_THRESHOLD; i++) { expect(service.addAndCheck(event)).toBe(false); @@ -746,6 +748,7 @@ describe('LoopDetectionService LLM Checks', () => { it('should not trigger LLM check when disabled for session', async () => { service.disableForSession(); + expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(1); await advanceTurns(30); const result = await service.turnStarted(abortController.signal); expect(result).toBe(false); diff --git a/packages/core/src/services/loopDetectionService.ts b/packages/core/src/services/loopDetectionService.ts index 2de71a717d..312cf0ac7b 100644 --- a/packages/core/src/services/loopDetectionService.ts +++ b/packages/core/src/services/loopDetectionService.ts @@ -8,8 +8,15 @@ import type { Content } from '@google/genai'; import { createHash } from 'node:crypto'; import type { ServerGeminiStreamEvent } from '../core/turn.js'; import { GeminiEventType } from '../core/turn.js'; -import { logLoopDetected } from '../telemetry/loggers.js'; -import { LoopDetectedEvent, LoopType } from '../telemetry/types.js'; +import { + logLoopDetected, + logLoopDetectionDisabled, +} from '../telemetry/loggers.js'; +import { + LoopDetectedEvent, + LoopDetectionDisabledEvent, + LoopType, +} from '../telemetry/types.js'; import type { Config } from '../config/config.js'; import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js'; import { @@ -97,6 +104,10 @@ export class LoopDetectionService { */ disableForSession(): void { this.disabledForSession = true; + logLoopDetectionDisabled( + this.config, + new LoopDetectionDisabledEvent(this.promptId), + ); } private getToolCallKey(toolCall: { name: string; args: object }): string { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 7a9273853f..21a80ca76e 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -49,6 +49,7 @@ export enum EventNames { FLASH_FALLBACK = 'flash_fallback', RIPGREP_FALLBACK = 'ripgrep_fallback', LOOP_DETECTED = 'loop_detected', + LOOP_DETECTION_DISABLED = 'loop_detection_disabled', NEXT_SPEAKER_CHECK = 'next_speaker_check', SLASH_COMMAND = 'slash_command', MALFORMED_JSON_RESPONSE = 'malformed_json_response', @@ -657,6 +658,15 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logLoopDetectionDisabledEvent(): void { + const data: EventValue[] = []; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.LOOP_DETECTION_DISABLED, data), + ); + this.flushIfNeeded(); + } + logNextSpeakerCheck(event: NextSpeakerCheckEvent): void { const data: EventValue[] = [ { diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index aecd331d85..55e9148d2b 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -41,6 +41,7 @@ import type { FlashFallbackEvent, NextSpeakerCheckEvent, LoopDetectedEvent, + LoopDetectionDisabledEvent, SlashCommandEvent, ConversationFinishedEvent, KittySequenceOverflowEvent, @@ -441,6 +442,13 @@ export function logLoopDetected( logger.emit(logRecord); } +export function logLoopDetectionDisabled( + config: Config, + _event: LoopDetectionDisabledEvent, +): void { + ClearcutLogger.getInstance(config)?.logLoopDetectionDisabledEvent(); +} + export function logNextSpeakerCheck( config: Config, event: NextSpeakerCheckEvent, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index ee87558e1c..3b265de78b 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -313,6 +313,18 @@ export class LoopDetectedEvent implements BaseTelemetryEvent { } } +export class LoopDetectionDisabledEvent implements BaseTelemetryEvent { + 'event.name': 'loop_detection_disabled'; + 'event.timestamp': string; + prompt_id: string; + + constructor(prompt_id: string) { + this['event.name'] = 'loop_detection_disabled'; + this['event.timestamp'] = new Date().toISOString(); + this.prompt_id = prompt_id; + } +} + export class NextSpeakerCheckEvent implements BaseTelemetryEvent { 'event.name': 'next_speaker_check'; 'event.timestamp': string; @@ -524,6 +536,7 @@ export type TelemetryEvent = | ApiResponseEvent | FlashFallbackEvent | LoopDetectedEvent + | LoopDetectionDisabledEvent | NextSpeakerCheckEvent | KittySequenceOverflowEvent | MalformedJsonResponseEvent