diff --git a/packages/core/src/context/ir/projector.ts b/packages/core/src/context/ir/projector.ts index 48e80f3ba7..27530d7584 100644 --- a/packages/core/src/context/ir/projector.ts +++ b/packages/core/src/context/ir/projector.ts @@ -27,7 +27,9 @@ export class IrProjector { protectedIds: Set ): Promise { if (!sidecar.budget) { - return this.projectAndDump(workingBuffer, env); + const contents = IrMapper.fromIr(workingBuffer); + tracer.logEvent('IrProjector', 'Projected Context to LLM (No Budget)', { projectedContext: contents }); + return contents; } const maxTokens = sidecar.budget.maxTokens; @@ -35,7 +37,9 @@ export class IrProjector { if (currentTokens <= maxTokens) { tracer.logEvent('IrProjector', `View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`); - return this.projectAndDump(workingBuffer, env); + const contents = IrMapper.fromIr(workingBuffer); + tracer.logEvent('IrProjector', 'Projected Context to LLM', { projectedContext: contents }); + return contents; } tracer.logEvent('IrProjector', `View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`); @@ -54,29 +58,8 @@ export class IrProjector { tracer.logEvent('IrProjector', `Finished projection. Final token count: ${finalTokens}.`); debugLogger.log(`Context Manager finished. Final actual token count: ${finalTokens}.`); - return this.projectAndDump(processedEpisodes, env); - } - - /** - * Converts the internal IR graph into a flat Content[] array for the LLM. - * If tracing is enabled via environment variables, dumps the payload to disk. - */ - private static async projectAndDump(episodes: Episode[], env: ContextEnvironment): Promise { - const contents = IrMapper.fromIr(episodes); - - if (process.env['GEMINI_DUMP_CONTEXT'] === 'true') { - try { - const fs = await import('node:fs/promises'); - const path = await import('node:path'); - const dumpPath = path.join(env.traceDir, '.gemini', 'projected_context.json'); - await fs.mkdir(path.dirname(dumpPath), { recursive: true }); - await fs.writeFile(dumpPath, JSON.stringify(contents, null, 2), 'utf-8'); - debugLogger.log(`[Observability] Context successfully dumped to ${dumpPath}`); - } catch (e) { - debugLogger.error(`Failed to dump context: ${e}`); - } - } - + const contents = IrMapper.fromIr(processedEpisodes); + tracer.logEvent('IrProjector', 'Projected Sanitized Context to LLM', { projectedContextSanitized: contents }); return contents; } } diff --git a/packages/core/src/context/sidecar/orchestrator.ts b/packages/core/src/context/sidecar/orchestrator.ts index e6a57b3504..b4778503b3 100644 --- a/packages/core/src/context/sidecar/orchestrator.ts +++ b/packages/core/src/context/sidecar/orchestrator.ts @@ -100,6 +100,7 @@ export class PipelineOrchestrator { } // Blocking execution + this.tracer.logEvent('Orchestrator', `Triggering synchronous pipeline: ${pipeline.name}`); let currentEpisodes = [...episodes]; for (let i = 0; i < pipeline.processors.length; i++) { const procDef = pipeline.processors[i]; @@ -107,6 +108,7 @@ export class PipelineOrchestrator { if (!processor) continue; try { + this.tracer.logEvent('Orchestrator', `Executing processor: ${procDef.processorId}`); currentEpisodes = await processor.process(currentEpisodes, state); } catch (error) { debugLogger.error(`Pipeline ${pipeline.name} failed synchronously at ${procDef.processorId}:`, error); @@ -131,6 +133,7 @@ export class PipelineOrchestrator { if (!processor) continue; try { + this.tracer.logEvent('Orchestrator', `Executing processor: ${procDef.processorId} (async)`); currentEpisodes = await processor.process(currentEpisodes, state); } catch (error) { debugLogger.error(`Pipeline ${pipeline.name} failed at ${procDef.processorId}:`, error); diff --git a/packages/core/src/context/tracer.test.ts b/packages/core/src/context/tracer.test.ts new file mode 100644 index 0000000000..c6ab819bc5 --- /dev/null +++ b/packages/core/src/context/tracer.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import { ContextTracer } from './tracer.js'; + +vi.mock('node:fs'); + +describe('ContextTracer', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('initializes, logs events, and auto-saves large assets when GEMINI_CONTEXT_TRACE is true', () => { + process.env['GEMINI_CONTEXT_TRACE'] = 'true'; + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); + const appendFileSyncSpy = vi.spyOn(fs, 'appendFileSync'); + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + + const tracer = new ContextTracer('/fake/target', 'test-session'); + + expect(mkdirSyncSpy).toHaveBeenCalled(); + + // Small logging: shouldn't trigger saveAsset + tracer.logEvent('TestComponent', 'TestAction', { key: 'value' }); + + expect(appendFileSyncSpy).toHaveBeenCalledTimes(2); // 1 for init, 1 for TestAction + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + const logCall = appendFileSyncSpy.mock.calls[1][1] as string; + + expect(logCall).toContain('[TestComponent] TestAction'); + expect(logCall).toContain('{"key":"value"}'); + + // Large logging: should trigger auto-asset save + const hugeString = 'a'.repeat(2000); + tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString }); + + expect(writeFileSyncSpy).toHaveBeenCalled(); // asset saved + + expect(appendFileSyncSpy).toHaveBeenCalledTimes(4); // init + TestAction + the inner saveAsset log + LargeAction log + const largeLogCall = appendFileSyncSpy.mock.calls[3][1] as string; + expect(largeLogCall).toContain('LargeAction'); + expect(largeLogCall).toContain('"$asset":'); // verifies it was extracted + }); + + it('silently ignores logging when GEMINI_CONTEXT_TRACE is false', () => { + process.env['GEMINI_CONTEXT_TRACE'] = 'false'; + const mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync'); + const appendFileSyncSpy = vi.spyOn(fs, 'appendFileSync'); + const writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync'); + + const tracer = new ContextTracer('/fake/target', 'test-session'); + expect(mkdirSyncSpy).not.toHaveBeenCalled(); + + tracer.logEvent('TestComponent', 'TestAction'); + expect(appendFileSyncSpy).not.toHaveBeenCalled(); + + const hugeString = 'a'.repeat(2000); + tracer.logEvent('TestComponent', 'LargeAction', { largeKey: hugeString }); + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/context/tracer.ts b/packages/core/src/context/tracer.ts index 00ffa1a9ca..7d67075660 100644 --- a/packages/core/src/context/tracer.ts +++ b/packages/core/src/context/tracer.ts @@ -14,6 +14,8 @@ export class ContextTracer { private assetsDir: string; private enabled: boolean; + private readonly MAX_INLINE_SIZE = 1000; + constructor(targetDir: string, sessionId: string) { this.enabled = process.env['GEMINI_CONTEXT_TRACE'] === 'true'; this.traceDir = path.join(targetDir, '.gemini', 'context_trace', sessionId); @@ -37,9 +39,24 @@ export class ContextTracer { ) { if (!this.enabled) return; try { + let processedDetails: Record | undefined; + + if (details) { + processedDetails = {}; + for (const [key, value] of Object.entries(details)) { + const strValue = typeof value === 'string' ? value : JSON.stringify(value); + if (strValue && strValue.length > this.MAX_INLINE_SIZE) { + const assetId = this.saveAsset(component, key, value); + processedDetails[key] = { $asset: assetId }; + } else { + processedDetails[key] = value; + } + } + } + const timestamp = new Date().toISOString(); - const detailsStr = details - ? ` | Details: ${JSON.stringify(details)}` + const detailsStr = processedDetails + ? ` | Details: ${JSON.stringify(processedDetails)}` : ''; const logLine = `[${timestamp}] [${component}] ${action}${detailsStr}\n`; fs.appendFileSync( @@ -52,7 +69,7 @@ export class ContextTracer { } } - saveAsset(component: string, assetName: string, data: unknown): string { + private saveAsset(component: string, assetName: string, data: unknown): string { if (!this.enabled) return 'asset-recording-disabled'; try { const assetId = `${Date.now()}-${randomUUID().slice(0, 6)}-${assetName}.json`;