From 387859977218365b08e43120a01fb3bce3f2e209 Mon Sep 17 00:00:00 2001 From: Adam Weidman Date: Mon, 23 Mar 2026 09:35:41 -0400 Subject: [PATCH] !feat(core): surface loop detection as a warning --- .../core/src/agent/event-translator.test.ts | 13 +++++++--- packages/core/src/agent/event-translator.ts | 26 +++++++++---------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/core/src/agent/event-translator.test.ts b/packages/core/src/agent/event-translator.test.ts index 6d70dae294..f40c6c27ad 100644 --- a/packages/core/src/agent/event-translator.test.ts +++ b/packages/core/src/agent/event-translator.test.ts @@ -395,15 +395,18 @@ describe('translateEvent', () => { }); describe('LoopDetected events', () => { - it('emits a custom loop_detected event', () => { + it('emits a non-fatal warning error event', () => { state.streamStartEmitted = true; const event: ServerGeminiStreamEvent = { type: GeminiEventType.LoopDetected, }; const result = translateEvent(event, state); expect(result).toHaveLength(1); - expect(result[0]?.type).toBe('custom'); - expect((result[0] as AgentEvent<'custom'>).kind).toBe('loop_detected'); + expect(result[0]?.type).toBe('error'); + const loopWarning = result[0] as AgentEvent<'error'>; + expect(loopWarning.fatal).toBe(false); + expect(loopWarning.message).toBe('Loop detected, stopping execution'); + expect(loopWarning._meta?.['code']).toBe('LOOP_DETECTED'); }); }); @@ -676,6 +679,10 @@ describe('mapError', () => { expect(result.status).toBe('RESOURCE_EXHAUSTED'); expect(result.message).toBe('Rate limit'); expect(result.fatal).toBe(true); + expect(result._meta?.['rawError']).toEqual({ + message: 'Rate limit', + status: 429, + }); }); it('maps Error instances', () => { diff --git a/packages/core/src/agent/event-translator.ts b/packages/core/src/agent/event-translator.ts index c601330786..7ff8c3bbe1 100644 --- a/packages/core/src/agent/event-translator.ts +++ b/packages/core/src/agent/event-translator.ts @@ -59,13 +59,12 @@ export function createTranslationState(streamId?: string): TranslationState { // Helpers // --------------------------------------------------------------------------- -function makeEvent( - type: AgentEventType, +function makeEvent( + type: T, state: TranslationState, - payload: Partial, + payload: Partial>, ): AgentEvent { const id = `${state.streamId}-${state.eventCounter++}`; - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- constructing AgentEvent from common fields + payload return { ...payload, id, @@ -169,12 +168,13 @@ export function translateEvent( case GeminiEventType.LoopDetected: ensureStreamStart(state, out); out.push( - makeEvent('custom', state, { - kind: 'loop_detected', + makeEvent('error', state, { + status: 'INTERNAL', + message: 'Loop detected, stopping execution', + fatal: false, + _meta: { code: 'LOOP_DETECTED' }, }), ); - // No agent_end — the stream continues. Consumer decides how to handle: - // non-interactive emits a warning, interactive shows a confirmation dialog. break; case GeminiEventType.ContextWindowWillOverflow: @@ -380,7 +380,8 @@ export function mapHttpToGrpcStatus( /** * Maps a StructuredError (or unknown error value) to an ErrorData payload. - * Preserves error metadata (name, code, stack) in _meta. + * Preserves selected error metadata in _meta and includes raw structured + * errors for lossless debugging. */ export function mapError( error: unknown, @@ -397,14 +398,13 @@ export function mapError( } } - const hasMeta = Object.keys(meta).length > 0; - if (isStructuredError(error)) { + const structuredMeta = { ...meta, rawError: error }; return { status: mapHttpToGrpcStatus(error.status), message: error.message, fatal: true, - ...(hasMeta ? { _meta: meta } : {}), + _meta: structuredMeta, }; } @@ -413,7 +413,7 @@ export function mapError( status: 'INTERNAL', message: error.message, fatal: true, - ...(hasMeta ? { _meta: meta } : {}), + ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), }; }