diff --git a/integration-tests/context-fidelity.test.ts b/integration-tests/context-fidelity.test.ts index 5b5f83d03ba..591fbeb29ed 100644 --- a/integration-tests/context-fidelity.test.ts +++ b/integration-tests/context-fidelity.test.ts @@ -14,6 +14,18 @@ import type { FakeResponse, HistoryTurn } from '@google/gemini-cli-core'; describe('Context Management Fidelity E2E', () => { let rig: TestRig; + function generateRandomString(length: number): string { + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt( + Math.floor(Math.random() * characters.length), + ); + } + return result; + } + beforeEach(() => { rig = new TestRig(); }); @@ -52,7 +64,7 @@ describe('Context Management Fidelity E2E', () => { const countTokensResponse: FakeResponse = { method: 'countTokens', - response: { totalTokens: 50000 }, + response: { totalTokens: 1000 }, }; const streamResponse = (text: string): FakeResponse => ({ @@ -87,7 +99,6 @@ describe('Context Management Fidelity E2E', () => { }, }); - const massivePayload = 'X'.repeat(50000); const traceDir = path.join(rig.testDir!, 'traces'); fs.mkdirSync(traceDir, { recursive: true }); const traceLog = path.join(traceDir, 'trace.log'); @@ -105,24 +116,35 @@ describe('Context Management Fidelity E2E', () => { streamResponse('Ack 3'), streamResponse('Ack 4'), streamResponse('Ack 5'), + streamResponse('Ack 6'), + streamResponse('Ack 7'), + streamResponse('Ack 8'), + streamResponse('Ack 9'), + streamResponse('Ack 10'), + streamResponse('Ack 11'), + streamResponse('Ack 12'), ]; for (let i = 0; i < 50; i++) { runMocks.push(snapshotResponse); runMocks.push(countTokensResponse); } - // Turn 1: Initial massive payload to put pressure - await rig.run({ - args: [ - '--debug', - '--fake-responses-non-strict', - setupResponses('resp1.json', runMocks), - ], - stdin: 'Turn 1: ' + massivePayload, - env: commonEnv, - }); + // Turns 1-10: Build up history + for (let i = 1; i <= 10; i++) { + await rig.run({ + args: [ + '--debug', + i === 1 ? '' : '--resume', + i === 1 ? '' : 'latest', + '--fake-responses-non-strict', + setupResponses(`resp_init_${i}.json`, runMocks), + ].filter(Boolean), + stdin: `Turn ${i}: ` + generateRandomString(900), + env: commonEnv, + }); + } - // Turn 2: Another turn, resuming Turn 1 + // Turn 11: Penultimate turn await rig.run({ args: [ '--debug', @@ -131,11 +153,11 @@ describe('Context Management Fidelity E2E', () => { '--fake-responses-non-strict', setupResponses('resp2.json', runMocks), ], - stdin: 'Turn 2: ' + massivePayload, + stdin: 'Turn 11: ' + generateRandomString(900), env: commonEnv, }); - // Turn 3: Third turn to force GC, resuming Turn 2 + // Turn 12: Breach threshold and force GC await rig.run({ args: [ '--debug', @@ -144,7 +166,7 @@ describe('Context Management Fidelity E2E', () => { '--fake-responses-non-strict', setupResponses('resp3.json', runMocks), ], - stdin: 'Turn 3: ' + massivePayload, + stdin: 'Turn 12: ' + generateRandomString(900), env: commonEnv, }); @@ -214,12 +236,16 @@ describe('Context Management Fidelity E2E', () => { // Most importantly, synthetic IDs (like summaries) must be stable. const syntheticTurns = contextBeforeExit!.filter( - (t: HistoryTurn) => t.id && t.id.length === 32, - ); // deriveStableId produces 32-char hex + (t: HistoryTurn) => + t.content.parts?.some((p) => p.text?.includes('active_tasks')) || + (t.id && t.id.length === 32), + ); expect(syntheticTurns.length).toBeGreaterThan(0); const syntheticTurnsAfter = contextAfterResume!.filter( - (t: HistoryTurn) => t.id && t.id.length === 32, + (t: HistoryTurn) => + t.content.parts?.some((p) => p.text?.includes('active_tasks')) || + (t.id && t.id.length === 32), ); expect(syntheticTurnsAfter.length).toBeGreaterThanOrEqual( syntheticTurns.length, diff --git a/packages/core/src/context/config/profiles.ts b/packages/core/src/context/config/profiles.ts index b721c01ad04..b58eb820134 100644 --- a/packages/core/src/context/config/profiles.ts +++ b/packages/core/src/context/config/profiles.ts @@ -177,8 +177,8 @@ export const stressTestProfile: ContextProfile = { name: 'Stress Test', config: { budget: { - retainedTokens: 4000, - maxTokens: 10000, + retainedTokens: 1500, + maxTokens: 5000, }, processorOptions: { ToolMasking: { diff --git a/packages/core/src/context/contextManager.barrier.test.ts b/packages/core/src/context/contextManager.barrier.test.ts index 9f5aaa119ab..6f84bd05b15 100644 --- a/packages/core/src/context/contextManager.barrier.test.ts +++ b/packages/core/src/context/contextManager.barrier.test.ts @@ -11,6 +11,7 @@ import { createSyntheticHistory, createMockContextConfig, setupContextComponentTest, + deriveStableId, } from './testing/contextTestUtils.js'; describe('ContextManager Sync Pressure Barrier Tests', () => { @@ -32,10 +33,14 @@ describe('ContextManager Sync Pressure Barrier Tests', () => { ); // 2. Add System Prompt (Episode 0 - Protected) + const envId = deriveStableId(['environment-context']); chatHistory.set([ { - id: 'h1', - content: { role: 'user', parts: [{ text: 'System prompt' }] }, + id: envId, + content: { + role: 'user', + parts: [{ text: '\nSystem prompt' }], + }, }, { id: 'h2', @@ -74,8 +79,10 @@ describe('ContextManager Sync Pressure Barrier Tests', () => { expect(projection.length).toBeLessThan(rawHistoryLength); - // Verify Episode 0 (System) was pruned, so we now start with a sentinel due to role alternation + // Verify Episode 0 (System) was PRESERVED because it is pinned Turn 0. + expect(projection[0].id).toBe(envId); expect(projection[0].content.role).toBe('user'); + const projectionString = JSON.stringify(projection); expect(projectionString).toContain('User turn 17'); // Filter out synthetic Yield nodes (they are model responses without actual tool/text bodies) @@ -86,19 +93,13 @@ describe('ContextManager Sync Pressure Barrier Tests', () => { ); // Verify the latest turn is perfectly preserved at the back - // Note: The HistoryHardener appends a "Please continue." user turn if we end on model, - // so we look at the turns before the sentinel. - const lastSentinel = contentNodes[contentNodes.length - 1].content; - const lastModel = contentNodes[contentNodes.length - 2].content; - const lastUser = contentNodes[contentNodes.length - 3].content; + const lastModel = contentNodes[contentNodes.length - 1].content; + const lastUser = contentNodes[contentNodes.length - 2].content; - expect(lastSentinel.role).toBe('user'); - expect(lastSentinel.parts![0].text).toBe('Please continue.'); + expect(lastModel.role).toBe('model'); + expect(lastModel.parts![0].text).toBe('Final answer.'); expect(lastUser.role).toBe('user'); expect(lastUser.parts![0].text).toBe('Final question.'); - - expect(lastModel.role).toBe('model'); - expect(lastModel.parts![0].text).toBe('Final answer.'); }); }); diff --git a/packages/core/src/context/contextManager.test.ts b/packages/core/src/context/contextManager.test.ts new file mode 100644 index 00000000000..5d00ad54b87 --- /dev/null +++ b/packages/core/src/context/contextManager.test.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest'; +import { ContextManager } from './contextManager.js'; +import type { ContextProfile } from './config/profiles.js'; +import type { ContextEnvironment } from './pipeline/environment.js'; +import type { ContextTracer } from './tracer.js'; +import type { PipelineOrchestrator } from './pipeline/orchestrator.js'; +import type { + AgentChatHistory, + HistoryTurn, +} from '../core/agentChatHistory.js'; +import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js'; +import { createMockEnvironment } from './testing/contextTestUtils.js'; + +describe('ContextManager', () => { + let mockSidecar: ContextProfile; + let mockEnv: ContextEnvironment; + let mockTracer: ContextTracer; + let mockOrchestrator: PipelineOrchestrator; + let mockChatHistory: AgentChatHistory; + let mockAdvancedTokenCalculator: AdvancedTokenCalculator; + + beforeEach(() => { + vi.resetAllMocks(); + + mockSidecar = { + name: 'test-profile', + config: { budget: { retainedTokens: 1000, maxTokens: 2000 } }, + buildPipelines: vi.fn().mockReturnValue([]), + buildAsyncPipelines: vi.fn().mockReturnValue([]), + } as unknown as ContextProfile; + + mockEnv = createMockEnvironment(); + mockTracer = mockEnv.tracer; + + mockOrchestrator = { + setNodeProvider: vi.fn(), + waitForPipelines: vi.fn().mockResolvedValue(undefined), + executeTriggerSync: vi + .fn() + .mockImplementation(async (trigger, nodes) => nodes), + shutdown: vi.fn(), + } as unknown as PipelineOrchestrator; + + mockChatHistory = { + all: vi.fn().mockReturnValue([]), + last: vi.fn(), + getById: vi.fn(), + getTurnById: vi.fn(), + getTurnsByIds: vi.fn(), + getNeighboringTurns: vi.fn(), + getHistory: vi.fn().mockReturnValue([]), + get: vi.fn().mockReturnValue([]), + setHistory: vi.fn(), + getHistoryTurns: vi.fn().mockReturnValue([]), + getRawHistory: vi.fn().mockReturnValue([]), + addTurn: vi.fn(), + updateTurn: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + clear: vi.fn(), + subscribe: vi.fn(), + } as unknown as AgentChatHistory; + + mockAdvancedTokenCalculator = { + getRawBaseUnits: vi.fn().mockReturnValue(0), + getRawBaseUnitsForContent: vi.fn().mockReturnValue(0), + calculateTokensAndBaseUnits: vi + .fn() + .mockReturnValue({ tokens: 0, baseUnits: 0 }), + } as unknown as AdvancedTokenCalculator; + }); + + it('renderHistory should process pendingRequest via the new_message pipeline', async () => { + const contextManager = new ContextManager( + mockSidecar, + mockEnv, + mockTracer, + mockOrchestrator, + mockChatHistory, + mockAdvancedTokenCalculator, + ); + + const largeToolOutput = 'a'.repeat(10000); + const pendingRequest: HistoryTurn = { + id: 'pending-turn-1', + content: { + role: 'user', + parts: [ + { + functionResponse: { + name: 'run_shell_command', + response: { + output: largeToolOutput, + }, + }, + }, + ], + }, + }; + + await contextManager.renderHistory(pendingRequest); + + expect(mockOrchestrator.executeTriggerSync).toHaveBeenCalledExactlyOnceWith( + 'new_message', + expect.any(Array), + expect.any(Set), + ); + + // Check that the node passed to the orchestrator corresponds to our pendingRequest + const call = (mockOrchestrator.executeTriggerSync as unknown as Mock).mock + .calls[0]; + const passedNodes = call[1]; + const passedNodeIds = call[2]; + + expect(passedNodes).toHaveLength(1); + expect(passedNodes[0].type).toBe('TOOL_EXECUTION'); + expect(passedNodes[0].payload.functionResponse.response.output).toBe( + largeToolOutput, + ); + expect(passedNodeIds.has(passedNodes[0].id)).toBe(true); + }); + + it('renderHistory should exclude pendingRequest from the result (late binding)', async () => { + const contextManager = new ContextManager( + mockSidecar, + mockEnv, + mockTracer, + mockOrchestrator, + mockChatHistory, + mockAdvancedTokenCalculator, + ); + + const pendingRequest: HistoryTurn = { + id: 'pending-turn-1', + content: { role: 'user', parts: [{ text: 'Active prompt' }] }, + }; + + const { history, apiHistory } = + await contextManager.renderHistory(pendingRequest); + + // Should be empty because mockChatHistory has no historical turns + expect(history).toHaveLength(0); + expect(apiHistory).toHaveLength(0); + }); +}); diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index e8a59b79678..abc565089ec 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -15,24 +15,24 @@ import type { ContextTracer } from './tracer.js'; import type { ContextEnvironment } from './pipeline/environment.js'; import type { ContextProfile } from './config/profiles.js'; import type { PipelineOrchestrator } from './pipeline/orchestrator.js'; -import { HistoryObserver } from './historyObserver.js'; import { render } from './graph/render.js'; import { ContextWorkingBufferImpl } from './pipeline/contextWorkingBuffer.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { deriveStableId } from '../utils/cryptoUtils.js'; import { hardenHistory } from '../utils/historyHardening.js'; import { checkContextInvariants } from './utils/invariantChecker.js'; import type { AdvancedTokenCalculator } from './utils/contextTokenCalculator.js'; export class ContextManager { - // The master state containing the pristine graph and current active graph. + // Master state containing the pristine graph and current active graph. private buffer: ContextWorkingBufferImpl = ContextWorkingBufferImpl.initialize([]); private readonly eventBus: ContextEventBus; - - // Internal sub-components private readonly orchestrator: PipelineOrchestrator; - private readonly historyObserver: HistoryObserver; + + // Track what IDs have been evaluated for triggers to prevent redundant processing + private readonly evaluatedNodeIds = new Set(); // Hysteresis tracking to prevent utility call churn private lastTriggeredDeficit = 0; @@ -43,6 +43,7 @@ export class ContextManager { result: { history: HistoryTurn[]; apiHistory: Content[]; + pendingApiHistory: Content[]; didApplyManagement: boolean; baseUnits: number; processedNodes: readonly ConcreteNode[]; @@ -56,7 +57,7 @@ export class ContextManager { private readonly env: ContextEnvironment, private readonly tracer: ContextTracer, orchestrator: PipelineOrchestrator, - chatHistory: AgentChatHistory, + private readonly chatHistory: AgentChatHistory, private readonly advancedTokenCalculator: AdvancedTokenCalculator, private readonly headerProvider?: () => Promise, ) { @@ -66,23 +67,8 @@ export class ContextManager { // Provide the orchestrator with a way to fetch the latest nodes from the live buffer this.orchestrator.setNodeProvider(() => this.buffer.nodes); - this.historyObserver = new HistoryObserver( - chatHistory, - this.env.eventBus, - this.tracer, - this.env.graphMapper, - ); - - this.eventBus.onPristineHistoryUpdated((event) => { - // Sync the entire pristine history chronologically - this.buffer = this.buffer.syncPristineHistory(event.nodes); - - this.evaluateTriggers(event.newNodes); - }); this.eventBus.onProcessorResult((event) => { // Defensive: Verify all targets are still present in the buffer. - // If a synchronous render or a previous async task already removed them, - // this result is stale and should be dropped. const currentIds = new Set(this.buffer.nodes.map((n) => n.id)); const allTargetsPresent = event.targets.every((t) => currentIds.has(t.id), @@ -100,14 +86,7 @@ export class ContextManager { event.targets, event.returnedNodes, ); - // We explicitly DO NOT call evaluateTriggers here. - // The Context Manager is a one-way assembly line. It only evaluates triggers - // when fundamentally new organic context is added via PristineHistoryUpdated. - // Re-evaluating after a processor finishes creates infinite feedback loops if - // the processor fails to reduce the token count below the threshold. }); - - this.historyObserver.start(); } /** @@ -122,21 +101,21 @@ export class ContextManager { */ shutdown() { this.orchestrator.shutdown(); - this.historyObserver.stop(); } /** * Evaluates if the current working buffer exceeds configured budget thresholds, * firing consolidation events if necessary. */ - private evaluateTriggers(newNodes: Set) { + private async evaluateTriggers(newNodes: Set) { if (!this.sidecar.config.budget) return; if (newNodes.size > 0) { - this.eventBus.emitChunkReceived({ - nodes: this.buffer.nodes, - targetNodeIds: newNodes, - }); + await this.orchestrator.executeTriggerSync( + 'new_message', + this.buffer.nodes, + newNodes, + ); } const currentTokens = this.env.tokenCalculator.calculateConcreteListTokens( @@ -149,11 +128,6 @@ export class ContextManager { // Identify nodes that must NEVER be truncated const protectedIds = this.getProtectedNodeIds(this.buffer.nodes); - if (protectedIds.size > 0) { - debugLogger.log( - `[ContextManager] Pinning ${protectedIds.size} nodes (recent_turn or external_active_task) to prevent truncation.`, - ); - } // Walk backwards finding nodes that fall out of the retained budget for (let i = this.buffer.nodes.length - 1; i >= 0; i--) { @@ -163,11 +137,7 @@ export class ContextManager { node, ]); - // Loose Boundary Policy: If this node is the one that pushes us over the retained limit, - // we KEEP it to prevent aggressive undershooting. We only age out nodes that are - // strictly *older* than the boundary node. if (priorTokens > this.sidecar.config.budget.retainedTokens) { - // Only age out if not protected if (!protectedIds.has(node.id)) { agedOutNodes.add(node.id); } @@ -178,17 +148,12 @@ export class ContextManager { const targetDeficit = currentTokens - this.sidecar.config.budget.retainedTokens; - // If the deficit has shrunk (e.g. after a consolidation), update the baseline - // so we can track growth from this new, smaller deficit. if (targetDeficit < this.lastTriggeredDeficit) { this.lastTriggeredDeficit = targetDeficit; } - // Respect coalescing threshold for background work const threshold = this.sidecar.config.budget.coalescingThresholdTokens || 0; - - // Only trigger if deficit has grown significantly since last time const growthSinceLast = targetDeficit - this.lastTriggeredDeficit; if ( @@ -199,25 +164,21 @@ export class ContextManager { this.env.tokenCalculator.garbageCollectCache( new Set(this.buffer.nodes.map((n) => n.id)), ); - this.eventBus.emitConsolidationNeeded({ - nodes: this.buffer.nodes, - targetDeficit, - targetNodeIds: agedOutNodes, - }); + + // Trigger synchronous consolidation for budget deficit + await this.orchestrator.executeTriggerSync( + 'nodes_aged_out', + this.buffer.nodes, + agedOutNodes, + new Set(protectedIds.keys()), + ); } } else { - // Budget is healthy, reset hysteresis this.lastTriggeredDeficit = 0; } } } - /** - * Identifies 'pinned' nodes that should not be truncated. - * This includes: - * 1. The entire last turn (Recent context). - * 2. Active tool calls (calls without responses in the graph). - */ private getProtectedNodeIds( nodes: readonly ConcreteNode[], extraProtectedIds: Set = new Set(), @@ -225,17 +186,18 @@ export class ContextManager { const protectionMap = new Map(); if (nodes.length === 0) return protectionMap; - // 1. Identify all nodes belonging to the last turn (Recent context) const lastNode = nodes[nodes.length - 1]; const lastTurnId = lastNode.turnId; + const envTurnId = `turn_${deriveStableId(['environment-context'])}`; for (const node of nodes) { if (node.turnId === lastTurnId) { protectionMap.set(node.id, 'recent_turn'); + } else if (node.turnId === envTurnId) { + protectionMap.set(node.id, 'environment_context'); } } - // 2. Any externally requested protections for (const id of extraProtectedIds) { protectionMap.set(id, 'external_active_task'); } @@ -243,11 +205,6 @@ export class ContextManager { return protectionMap; } - /** - * Retrieves the raw, uncompressed Episodic Context Graph graph. - * Useful for internal tool rendering (like the trace viewer). - * Note: This is an expensive, deep clone operation. - */ getPristineGraph(): readonly ConcreteNode[] { const pristineSet = new Map(); for (const node of this.buffer.nodes) { @@ -256,58 +213,70 @@ export class ContextManager { pristineSet.set(root.id, root); } } - // We sort them by timestamp to ensure they are returned in chronological order return Array.from(pristineSet.values()).sort( (a, b) => a.timestamp - b.timestamp, ); } - /** - * Generates a virtual view of the pristine graph, substituting in variants - * up to the configured token budget. - * This is the view that will eventually be projected back to the LLM. - */ getNodes(): readonly ConcreteNode[] { return [...this.buffer.nodes]; } - getEnvironment(): ContextEnvironment { - return this.env; - } - /** - * Executes the final 'gc_backstop' pipeline if necessary, enforcing the token budget, - * and maps the Episodic Context Graph back into a raw Gemini Content[] array for transmission. - * This is the primary method called by the agent framework before sending a request. + * Generates a virtual view of the pristine graph, substituting in variants + * up to the configured token budget. */ async renderHistory( - pendingRequest?: HistoryTurn, + pendingRequest?: { id: string; content: Content }, activeTaskIds: Set = new Set(), abortSignal?: AbortSignal, ): Promise<{ history: HistoryTurn[]; apiHistory: Content[]; + pendingApiHistory: Content[]; didApplyManagement: boolean; baseUnits: number; processedNodes: readonly ConcreteNode[]; }> { this.tracer.logEvent('ContextManager', 'Starting rendering of LLM context'); - let previewNodes: ConcreteNode[] = []; + // 1. Explicit Sync with the durable history. + // This replaces the background HistoryObserver. + const currentHistory = this.chatHistory.get(); + const pristineNodes = this.env.graphMapper.sync(currentHistory); + + this.buffer = this.buffer.syncPristineHistory(pristineNodes); + + // Identify truly "new" nodes that haven't been evaluated for triggers yet. + const newPrimalNodes = new Set(); + for (const node of pristineNodes) { + if (!this.evaluatedNodeIds.has(node.id)) { + newPrimalNodes.add(node.id); + this.evaluatedNodeIds.add(node.id); + } + } + + // 2. Preview the pending request. + let previewNodes: readonly ConcreteNode[] = []; if (pendingRequest) { - previewNodes = this.env.graphMapper.applyEvent({ - type: 'PUSH', - payload: [pendingRequest], - }); + previewNodes = this.env.graphMapper.sync([pendingRequest]); + + const previewNodeIds = new Set(previewNodes.map((n) => n.id)); + + previewNodes = await this.orchestrator.executeTriggerSync( + 'new_message', + previewNodes, + previewNodeIds, + ); } + // 3. Trigger evaluation (Sync budget management). + await this.evaluateTriggers(newPrimalNodes); + // --- Hot Start Calibration --- - // If we are resuming a session with history, we don't want the adaptive token calculator - // to fly blind on its first GC pass. We do a one-time API calibration. const hotStartPromise = (async () => { if (!this.hasPerformedHotStart) { this.hasPerformedHotStart = true; - if (this.buffer.nodes.length > 0) { const nodesForHotStart = [...this.buffer.nodes, ...previewNodes]; await this.performHotStartCalibration(nodesForHotStart, abortSignal); @@ -315,14 +284,11 @@ export class ContextManager { } })(); - // 1. Synchronous Pressure Barrier: Wait for background management pipelines to finish. - // We run hot start calibration in parallel to hide the network latency. await Promise.all([this.orchestrator.waitForPipelines(), hotStartPromise]); let nodes = this.buffer.nodes; const previewNodeIds = new Set(); - // Apply the preview nodes to the final graph if (previewNodes.length > 0) { for (const n of previewNodes) { previewNodeIds.add(n.id); @@ -330,13 +296,10 @@ export class ContextManager { nodes = [...nodes, ...previewNodes]; } - // 2. Fetch Header and calculate tokens const header = this.headerProvider ? await this.headerProvider() : undefined; - // 3. Cache Check (Anomaly 3): If nodes haven't changed, return previous result. - // We combine the graph hash with a hash of the header to ensure total freshness. const graphHash = nodes.map((n) => n.id).join('|'); const headerHash = header ? JSON.stringify(header.parts) : 'no-header'; const totalHash = `${graphHash}::${headerHash}`; @@ -350,7 +313,6 @@ export class ContextManager { const protectionReasons = this.getProtectedNodeIds(nodes, activeTaskIds); - // Apply final GC Backstop pressure barrier synchronously before mapping const renderResult = await render( nodes, this.orchestrator, @@ -358,22 +320,22 @@ export class ContextManager { this.tracer, this.env, this.advancedTokenCalculator, - protectionReasons, - header, - previewNodeIds, + { + protectionReasons, + header, + lateBindPrompt: !!pendingRequest, + }, ); const { history: renderedHistory, + pendingHistory, didApplyManagement, baseUnits, processedNodes, } = renderResult; if (didApplyManagement) { - // Commit the GC backstop results back to the master buffer. - // We filter out preview nodes because they are ephemeral and will be - // added to history naturally by the client after the turn completes. this.buffer = this.buffer.applyProcessorResult( 'sync_backstop', this.buffer.nodes, @@ -381,54 +343,52 @@ export class ContextManager { ); } - // Structural validation in debug mode checkContextInvariants(this.buffer.nodes, 'RenderHistory'); this.tracer.logEvent('ContextManager', 'Finished rendering'); - // We must temporarily append the pendingRequest (if any) before hardening. - // Otherwise, the hardener will see dangling functionCalls and inject sentinels - // even though the pendingRequest provides the required functionResponses. - const fullHistoryToHarden = pendingRequest - ? [...renderedHistory, pendingRequest] - : renderedHistory; - - const hardenedHistory = hardenHistory(fullHistoryToHarden, { + const allHistory = [...renderedHistory, ...pendingHistory]; + const hardenedAllHistory = hardenHistory(allHistory, { sentinels: this.sidecar.sentinels, }); - if (pendingRequest) { - const last = hardenedHistory[hardenedHistory.length - 1]; - if (last && last.content.parts) { - const numPartsToRemove = pendingRequest.content.parts?.length || 0; - if ( - numPartsToRemove > 0 && - last.content.parts.length > numPartsToRemove - ) { - last.content.parts.splice(-numPartsToRemove); - } else { - hardenedHistory.pop(); - } - } else { - hardenedHistory.pop(); + const firstPendingId = pendingHistory[0]?.id; + let splitIndex = renderedHistory.length; + if (firstPendingId) { + const foundIndex = hardenedAllHistory.findIndex( + (h) => h.id === firstPendingId, + ); + if (foundIndex !== -1) { + splitIndex = foundIndex; } } - const apiHistory = hardenedHistory.map((h) => h.content); + const apiHistory = hardenedAllHistory + .slice(0, splitIndex) + .map((h) => h.content); + + const pendingApiHistory = hardenedAllHistory + .slice(splitIndex) + .map((h) => h.content); + if (header) { apiHistory.unshift(header); } const result = { - history: hardenedHistory, + history: renderedHistory, apiHistory, + pendingApiHistory, didApplyManagement, baseUnits, processedNodes, }; - // Update cache - this.lastRenderCache = { nodesHash: totalHash, result }; + this.lastRenderCache = { + nodesHash: totalHash, + result, + }; + return result; } @@ -436,47 +396,28 @@ export class ContextManager { nodes: readonly ConcreteNode[], abortSignal?: AbortSignal, ) { + const history = this.env.graphMapper.fromGraph(nodes); + const contents = history.map((h) => h.content); + try { - this.tracer.logEvent( - 'ContextManager', - 'Performing Hot Start Token Calibration', - ); + const { totalTokens } = await this.env.llmClient.countTokens({ + modelConfigKey: { model: 'context-calibrator' }, + contents, + abortSignal, + }); - const contents = this.env.graphMapper.fromGraph(nodes); - const rawContents = contents.map((h) => h.content); - const header = this.headerProvider - ? await this.headerProvider() - : undefined; - const combinedHistory = header ? [header, ...rawContents] : rawContents; - - const baseUnits = - this.advancedTokenCalculator.getRawBaseUnits(nodes) + - (header - ? this.advancedTokenCalculator.getRawBaseUnitsForContent(header) - : 0); - - // We only make the network call if we have actual contents to send, - // avoiding 400 Bad Request errors from the API. - if (combinedHistory.length > 0) { - const result = await this.env.llmClient.countTokens({ - contents: combinedHistory, - abortSignal, + if (totalTokens !== undefined) { + this.env.eventBus.emitTokenGroundTruth({ + actualTokens: totalTokens, + promptBaseUnits: this.advancedTokenCalculator.getRawBaseUnits(nodes), }); - if (result.totalTokens > 0) { - this.env.eventBus.emitTokenGroundTruth({ - actualTokens: result.totalTokens, - promptBaseUnits: baseUnits, - }); - } } - } catch (error) { - // Hot start calibration is purely an optimization. If the network fails or auth is weird, - // we silently swallow and fallback to the un-calibrated 1.0 ratio heuristic. - this.tracer.logEvent( - 'ContextManager', - 'Hot Start Token Calibration Failed (Ignored)', - { error }, - ); + } catch (e) { + debugLogger.warn('[ContextManager] Hot start calibration failed', e); } } + + getEnvironment(): ContextEnvironment { + return this.env; + } } diff --git a/packages/core/src/context/graph/mapper.test.ts b/packages/core/src/context/graph/mapper.test.ts index fa2640dac16..ffbcd148e96 100644 --- a/packages/core/src/context/graph/mapper.test.ts +++ b/packages/core/src/context/graph/mapper.test.ts @@ -12,9 +12,10 @@ import { hardenHistory } from '../../utils/historyHardening.js'; describe('ContextGraphMapper (Round-Trip Fidelity)', () => { it('should flawlessly round-trip a complex history containing parallel tool calls and responses', () => { // 1. Define a complex, worst-case scenario history + const envId = 'd04923d38bb0f6017037e74183378ef4'; const originalHistory: HistoryTurn[] = [ { - id: 'system_prompt_id', + id: envId, content: { role: 'user', parts: [{ text: '\nSystem Prompt here' }], @@ -90,11 +91,8 @@ describe('ContextGraphMapper (Round-Trip Fidelity)', () => { // 3. Translate History -> Graph const mapper = new ContextGraphMapper(); - // Simulate the HistoryObserver capturing the push - const nodes = mapper.applyEvent({ - type: 'SYNC_FULL', - payload: originalHistory, - }); + // Simulate the sync + const nodes = mapper.sync(originalHistory); // 4. Translate Graph -> History const reconstructedHistory = mapper.fromGraph(nodes); diff --git a/packages/core/src/context/graph/mapper.ts b/packages/core/src/context/graph/mapper.ts index ffd9a4cac03..bffddd20b7a 100644 --- a/packages/core/src/context/graph/mapper.ts +++ b/packages/core/src/context/graph/mapper.ts @@ -5,7 +5,7 @@ */ import type { ConcreteNode } from './types.js'; import { ContextGraphBuilder } from './toGraph.js'; -import type { HistoryEvent, HistoryTurn } from '../../core/agentChatHistory.js'; +import type { HistoryTurn } from '../../core/agentChatHistory.js'; import { fromGraph } from './fromGraph.js'; import { NodeIdService } from './nodeIdService.js'; @@ -17,8 +17,8 @@ export class ContextGraphMapper { this.builder = new ContextGraphBuilder(this.idService); } - applyEvent(event: HistoryEvent): ConcreteNode[] { - return this.builder.processHistory(event.payload); + sync(turns: readonly HistoryTurn[]): ConcreteNode[] { + return this.builder.processHistory(turns); } fromGraph(nodes: readonly ConcreteNode[]): HistoryTurn[] { diff --git a/packages/core/src/context/graph/render.test.ts b/packages/core/src/context/graph/render.test.ts index 1f862ff7686..1dc99f09cc4 100644 --- a/packages/core/src/context/graph/render.test.ts +++ b/packages/core/src/context/graph/render.test.ts @@ -16,7 +16,7 @@ import type { PipelineOrchestrator } from '../pipeline/orchestrator.js'; import type { Part } from '@google/genai'; describe('render', () => { - it('should filter out previewNodeIds', async () => { + it('should render all provided nodes', async () => { const mockNodes: ConcreteNode[] = [ { id: '1', @@ -34,7 +34,6 @@ describe('render', () => { payload: {} as Part, } as unknown as ConcreteNode, ]; - const previewNodeIds = new Set(['preview-1']); const orchestrator = {} as PipelineOrchestrator; const sidecar = { config: {} } as ContextProfile; // No budget @@ -44,6 +43,7 @@ describe('render', () => { baseUnits: 100, }), getRawBaseUnits: vi.fn().mockReturnValue(100), + calculateConcreteListTokens: vi.fn().mockReturnValue(100), getRawBaseUnitsForContent: vi.fn().mockReturnValue(0), }; @@ -69,12 +69,17 @@ describe('render', () => { tracer, env, mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, - new Map(), - undefined, - previewNodeIds, + { + protectionReasons: new Map(), + header: undefined, + }, ); - expect(result.history).toEqual([{ text: '1' }, { text: '2' }]); + expect(result.history).toEqual([ + { text: '1' }, + { text: '2' }, + { text: 'preview-1' }, + ]); expect(result.baseUnits).toBe(100); }); @@ -134,6 +139,10 @@ describe('render', () => { if (nodes.length === 1) return tokenMap[nodes[0].id]; return currentTokens; }), + calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { + if (nodes.length === 1) return tokenMap[nodes[0].id]; + return currentTokens; + }), }; const env = { @@ -165,9 +174,10 @@ describe('render', () => { tracer, env, mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, - new Map(), - undefined, - new Set(), + { + protectionReasons: new Map(), + header: undefined, + }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -228,6 +238,10 @@ describe('render', () => { if (nodes.length === 1) return tokenMap[nodes[0].id]; return currentTokens; }), + calculateConcreteListTokens: vi.fn((nodes: readonly ConcreteNode[]) => { + if (nodes.length === 1) return tokenMap[nodes[0].id]; + return currentTokens; + }), }; const env = { @@ -259,9 +273,10 @@ describe('render', () => { tracer, env, mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, - new Map(), - undefined, - new Set(), + { + protectionReasons: new Map(), + header: undefined, + }, ); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -270,4 +285,66 @@ describe('render', () => { expect(surviving).toEqual(['B', 'C']); // A is dropped expect(result.baseUnits).toBe(160000); }); + + it('should exclude the last turn when lateBindPrompt is true', async () => { + const mockNodes: ConcreteNode[] = [ + { + id: '1', + type: NodeType.USER_PROMPT, + turnId: 'turn-1', + payload: {} as Part, + } as unknown as ConcreteNode, + { + id: '2', + type: NodeType.AGENT_THOUGHT, + turnId: 'turn-2', + payload: {} as Part, + } as unknown as ConcreteNode, + ]; + + const orchestrator = { + executeTriggerSync: vi.fn(async (trigger, nodes) => nodes), + } as unknown as PipelineOrchestrator; + const sidecar = { config: {} } as ContextProfile; // No budget + const mockAdvancedTokenCalculator = { + calculateTokensAndBaseUnits: vi.fn().mockReturnValue({ + tokens: 100, + baseUnits: 100, + }), + getRawBaseUnits: vi.fn().mockReturnValue(50), + calculateConcreteListTokens: vi.fn().mockReturnValue(100), + getRawBaseUnitsForContent: vi.fn().mockReturnValue(0), + }; + + const env = { + tokenCalculator: { + calculateConcreteListTokens: vi.fn().mockReturnValue(100), + calculateTokenBreakdown: vi.fn().mockReturnValue({}), + }, + graphMapper: { + fromGraph: vi.fn((nodes: readonly ConcreteNode[]) => + nodes.map((n) => ({ text: n.id })), + ), + }, + } as unknown as ContextEnvironment; + const tracer = { + logEvent: vi.fn(), + } as unknown as ContextTracer; + + const result = await render( + mockNodes, + orchestrator, + sidecar, + tracer, + env, + mockAdvancedTokenCalculator as unknown as AdvancedTokenCalculator, + { + lateBindPrompt: true, + }, + ); + + expect(result.history).toEqual([{ text: '1' }]); // Turn 2 (node 2) is excluded + expect(result.pendingHistory).toEqual([{ text: '2' }]); // Turn 2 is included here + expect(result.baseUnits).toBe(50); + }); }); diff --git a/packages/core/src/context/graph/render.ts b/packages/core/src/context/graph/render.ts index 58bf9f07d01..b4e2dc0bdb9 100644 --- a/packages/core/src/context/graph/render.ts +++ b/packages/core/src/context/graph/render.ts @@ -6,6 +6,7 @@ import type { Content } from '@google/genai'; import type { ConcreteNode } from './types.js'; +import { debugLogger } from '../../utils/debugLogger.js'; import type { ContextTracer } from '../tracer.js'; import type { ContextProfile } from '../config/profiles.js'; import type { PipelineOrchestrator } from '../pipeline/orchestrator.js'; @@ -14,6 +15,17 @@ import { performCalibration } from '../utils/tokenCalibration.js'; import type { AdvancedTokenCalculator } from '../utils/contextTokenCalculator.js'; import type { HistoryTurn } from '../../core/agentChatHistory.js'; +export interface RenderOptions { + protectionReasons?: Map; + header?: Content; + /** + * If true, the most recent turn in the graph will not be considered for + * consolidation (snapshots) or included in the returned history. + * This is used for "late-binding" the prompt. + */ + lateBindPrompt?: boolean; +} + /** * Maps the Episodic Context Graph back into a list of HistoryTurns for transmission. * It applies synchronous context management (GC backstop) if the budget is exceeded. @@ -25,15 +37,15 @@ export async function render( tracer: ContextTracer, env: ContextEnvironment, advancedTokenCalculator: AdvancedTokenCalculator, - protectionReasons: Map = new Map(), - header?: Content, - previewNodeIds: ReadonlySet = new Set(), + options: RenderOptions = {}, ): Promise<{ history: HistoryTurn[]; + pendingHistory: HistoryTurn[]; didApplyManagement: boolean; baseUnits: number; processedNodes: readonly ConcreteNode[]; }> { + const { protectionReasons = new Map(), header, lateBindPrompt } = options; let headerTokens = 0; let headerBaseUnits = 0; if (header) { @@ -43,19 +55,36 @@ export async function render( headerBaseUnits = costs.baseUnits; } + const lastTurnId = nodes[nodes.length - 1]?.turnId; + if (!sidecar.config.budget) { - const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id)); - const contents = env.graphMapper.fromGraph(visibleNodes); + const allVisibleNodes = nodes; + + const managedNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId !== lastTurnId) + : allVisibleNodes; + + const pendingNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId === lastTurnId) + : []; + + const history = env.graphMapper.fromGraph(managedNodes); + const pendingHistory = env.graphMapper.fromGraph(pendingNodes); + tracer.logEvent('Render', 'Render Context to LLM (No Budget)', { - renderedContext: contents, + renderedContext: history, + pendingContext: pendingHistory, }); - // In all cases, retrieve raw base units from the token calculator interface const baseUnits = - advancedTokenCalculator.getRawBaseUnits(nodes) + headerBaseUnits; + advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) + + headerBaseUnits; return { - history: contents, + history, + pendingHistory, didApplyManagement: false, baseUnits, processedNodes: nodes, @@ -64,7 +93,7 @@ export async function render( const maxTokens = sidecar.config.budget.maxTokens; - const { tokens: graphTokens, baseUnits: graphBaseUnits } = + const { tokens: graphTokens } = advancedTokenCalculator.calculateTokensAndBaseUnits(nodes); const currentTokens = graphTokens + headerTokens; @@ -94,20 +123,39 @@ export async function render( 'Render', `View is within maxTokens (${currentTokens} <= ${maxTokens}). Returning view.`, ); - const visibleNodes = nodes.filter((n) => !previewNodeIds.has(n.id)); - const contents = env.graphMapper.fromGraph(visibleNodes); + + const allVisibleNodes = nodes; + + const managedNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId !== lastTurnId) + : allVisibleNodes; + + const pendingNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId === lastTurnId) + : []; + + const history = env.graphMapper.fromGraph(managedNodes); + const pendingHistory = env.graphMapper.fromGraph(pendingNodes); + tracer.logEvent('Render', 'Render Context for LLM', { - renderedContext: contents, + renderedContext: history, + pendingContext: pendingHistory, }); - performCalibration( - env, - visibleNodes, - contents.map((h) => h.content), - ); + + performCalibration(env, allVisibleNodes, [ + ...history.map((h) => h.content), + ...pendingHistory.map((h) => h.content), + ]); + return { - history: contents, + history, + pendingHistory, didApplyManagement: false, - baseUnits: graphBaseUnits + headerBaseUnits, + baseUnits: + advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) + + headerBaseUnits, processedNodes: nodes, }; } @@ -117,23 +165,31 @@ export async function render( `View exceeds maxTokens (${currentTokens} > ${maxTokens}). Hitting Synchronous Pressure Barrier.`, { targetDelta }, ); + debugLogger.log( + `Context Manager Synchronous Barrier triggered: View at ${currentTokens} tokens (limit: ${maxTokens}).`, + ); - // Calculate exactly which nodes aged out of the retainedTokens budget to form our target delta const agedOutNodes = new Set(); let rollingTokens = 0; - // Start from newest and count backwards for (let i = nodes.length - 1; i >= 0; i--) { const node = nodes[i]; const priorTokens = rollingTokens; const nodeTokens = env.tokenCalculator.calculateConcreteListTokens([node]); rollingTokens += nodeTokens; - // Loose Boundary Policy: Keep the node that crosses the boundary if (priorTokens > sidecar.config.budget.retainedTokens) { agedOutNodes.add(node.id); } } + if (lateBindPrompt && lastTurnId) { + for (const node of nodes) { + if (node.turnId === lastTurnId) { + agedOutNodes.delete(node.id); + } + } + } + const processedNodes = await orchestrator.executeTriggerSync( 'gc_backstop', nodes, @@ -141,7 +197,6 @@ export async function render( protectedIds, ); - // Apply skipList logic to abstract over summarized nodes const skipList = new Set(); for (const node of processedNodes) { if (node.abstractsIds) { @@ -149,24 +204,43 @@ export async function render( } } - const visibleNodes = processedNodes.filter( - (n) => !skipList.has(n.id) && !previewNodeIds.has(n.id), - ); + const allVisibleNodes = processedNodes.filter((n) => !skipList.has(n.id)); + + const managedNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId !== lastTurnId) + : allVisibleNodes; + + const pendingNodes = + lateBindPrompt && lastTurnId + ? allVisibleNodes.filter((n) => n.turnId === lastTurnId) + : []; - const contents = env.graphMapper.fromGraph(visibleNodes); + const history = env.graphMapper.fromGraph(managedNodes); + const pendingHistory = env.graphMapper.fromGraph(pendingNodes); + + const finalTokens = + advancedTokenCalculator.calculateConcreteListTokens(allVisibleNodes); tracer.logEvent('Render', 'Render Sanitized Context for LLM', { - renderedContextSanitized: contents, + renderedContextSanitized: history, + pendingContextSanitized: pendingHistory, }); - performCalibration( - env, - visibleNodes, - contents.map((h) => h.content), + debugLogger.log( + `Context Manager finished. Final actual token count: ${finalTokens}.`, ); + + performCalibration(env, allVisibleNodes, [ + ...history.map((h) => h.content), + ...pendingHistory.map((h) => h.content), + ]); + return { - history: contents, + history, + pendingHistory, didApplyManagement: true, baseUnits: - advancedTokenCalculator.getRawBaseUnits(visibleNodes) + headerBaseUnits, + advancedTokenCalculator.getRawBaseUnits(allVisibleNodes) + + headerBaseUnits, processedNodes, }; } diff --git a/packages/core/src/context/graph/toGraph.ts b/packages/core/src/context/graph/toGraph.ts index 0214c7021b7..14ace62aa56 100644 --- a/packages/core/src/context/graph/toGraph.ts +++ b/packages/core/src/context/graph/toGraph.ts @@ -11,6 +11,8 @@ import { debugLogger } from '../../utils/debugLogger.js'; import type { NodeIdService } from './nodeIdService.js'; import type { HistoryTurn } from '../../core/agentChatHistory.js'; import { isSnapshotState } from '../utils/snapshotGenerator.js'; +import { deriveStableId } from '../../utils/cryptoUtils.js'; +import { ensureStableToolIds } from '../../utils/sessionUtils.js'; // Global WeakMap to cache hashes for Part objects. // This optimizes getStableId by avoiding redundant stringify/hash operations @@ -41,9 +43,9 @@ function isFileDataPart( ); } -function isFunctionCallPart( - part: Part, -): part is Part & { functionCall: { id: string; name: string } } { +function isFunctionCallPart(part: Part): part is Part & { + functionCall: { id?: string; name: string; args: Record }; +} { return ( typeof part.functionCall === 'object' && part.functionCall !== null && @@ -51,9 +53,13 @@ function isFunctionCallPart( ); } -function isFunctionResponsePart( - part: Part, -): part is Part & { functionResponse: { id: string; name: string } } { +function isFunctionResponsePart(part: Part): part is Part & { + functionResponse: { + id?: string; + name: string; + response: Record; + }; +} { return ( typeof part.functionResponse === 'object' && part.functionResponse !== null && @@ -121,19 +127,27 @@ export function getStableId( .digest('hex'); id = `file_${contentHash}_${turnSalt}_${partIdx}`; } else if (isFunctionCallPart(part)) { - contentHash = createHash('sha256') - .update( - `call:${part.functionCall.name}:${JSON.stringify(part.functionCall.args)}`, - ) - .digest('hex'); - id = `call_h_${contentHash}_${turnSalt}_${partIdx}`; + if (part.functionCall.id) { + id = `call_${part.functionCall.id}`; + } else { + contentHash = createHash('sha256') + .update( + `call:${part.functionCall.name}:${JSON.stringify(part.functionCall.args)}`, + ) + .digest('hex'); + id = `call_h_${contentHash}_${turnSalt}_${partIdx}`; + } } else if (isFunctionResponsePart(part)) { - contentHash = createHash('sha256') - .update( - `resp:${part.functionResponse.name}:${JSON.stringify(part.functionResponse.response)}`, - ) - .digest('hex'); - id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`; + if (part.functionResponse.id) { + id = `resp_${part.functionResponse.id}`; + } else { + contentHash = createHash('sha256') + .update( + `resp:${part.functionResponse.name}:${JSON.stringify(part.functionResponse.response)}`, + ) + .digest('hex'); + id = `resp_h_${contentHash}_${turnSalt}_${partIdx}`; + } } else if (isExecutableCodePart(part)) { contentHash = createHash('sha256') .update( @@ -174,6 +188,8 @@ export class ContextGraphBuilder { constructor(private readonly idService: NodeIdService) {} processHistory(history: readonly HistoryTurn[]): ConcreteNode[] { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ensureStableToolIds(history as HistoryTurn[]); const nodes: ConcreteNode[] = []; for (let turnIdx = 0; turnIdx < history.length; turnIdx++) { @@ -181,42 +197,32 @@ export class ContextGraphBuilder { const msg = turn.content; if (!msg.parts) continue; - // Defensive: Skip legacy environment header regardless of where it appears. - // We now manage this as an orthogonal late-addition header. - if (msg.role === 'user' && msg.parts.length === 1) { - const text = msg.parts[0].text; - if ( - text?.startsWith('') && - text?.includes('This is the Gemini CLI') - ) { - debugLogger.log( - '[ContextGraphBuilder] Skipping legacy environment header turn from graph.', - ); - continue; - } - } - const turnSalt = turn.id; - const turnId = `turn_${turnSalt}`; + const turnId = turnSalt.startsWith('turn_') + ? turnSalt + : `turn_${turnSalt}`; if (msg.role === 'user') { for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) { const part = msg.parts[partIdx]; - const apiId = - isFunctionResponsePart(part) && - typeof part.functionResponse.id === 'string' - ? part.functionResponse.id - : isFunctionCallPart(part) && - typeof part.functionCall.id === 'string' - ? part.functionCall.id - : undefined; + + // Skip legacy session context headers if they appear later in history (after Turn 0). + // We identify Turn 0 by its deterministic ID. + const envTurnId = deriveStableId(['environment-context']); + if ( + isTextPart(part) && + part.text.trim().startsWith('') && + turnSalt !== envTurnId + ) { + debugLogger.log( + '[ContextGraphBuilder] Skipping legacy environment header turn from graph.', + ); + continue; + } const isSnapshot = isTextPart(part) && isSnapshotState(part.text); - // Use stable API ID if available, otherwise anchor to the turn and index. - const id = apiId - ? `${apiId}_${turnSalt}_${partIdx}` - : `${turnSalt}_${partIdx}`; + const id = getStableId(part, this.idService, turnSalt, partIdx); const node: ConcreteNode = { id, @@ -231,19 +237,12 @@ export class ContextGraphBuilder { turnId, }; nodes.push(node); - this.idService.set(part, id); } } else if (msg.role === 'model') { for (let partIdx = 0; partIdx < msg.parts.length; partIdx++) { const part = msg.parts[partIdx]; - const apiId = - isFunctionCallPart(part) && typeof part.functionCall.id === 'string' - ? part.functionCall.id - : undefined; - const id = apiId - ? `${apiId}_${turnSalt}_${partIdx}` - : `${turnSalt}_${partIdx}`; + const id = getStableId(part, this.idService, turnSalt, partIdx); const node: ConcreteNode = { id, @@ -256,7 +255,6 @@ export class ContextGraphBuilder { turnId, }; nodes.push(node); - this.idService.set(part, id); } } } diff --git a/packages/core/src/context/historyObserver.ts b/packages/core/src/context/historyObserver.ts deleted file mode 100644 index 0443d2250a8..00000000000 --- a/packages/core/src/context/historyObserver.ts +++ /dev/null @@ -1,89 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - AgentChatHistory, - HistoryEvent, -} from '../core/agentChatHistory.js'; -import type { ContextGraphMapper } from './graph/mapper.js'; -import type { ContextEventBus } from './eventBus.js'; -import type { ContextTracer } from './tracer.js'; - -/** - * Connects the raw AgentChatHistory to the ContextManager. - * It maps raw messages into Episodic Intermediate Representation (Context Graph) - * and evaluates background triggers whenever history changes. - */ -export class HistoryObserver { - private unsubscribeHistory?: () => void; - - private readonly seenNodeIds = new Set(); - - constructor( - private readonly chatHistory: AgentChatHistory, - private readonly eventBus: ContextEventBus, - private readonly tracer: ContextTracer, - private readonly graphMapper: ContextGraphMapper, - ) {} - - private processEvent = (event: HistoryEvent) => { - if (event.type === 'CLEAR') { - this.seenNodeIds.clear(); - } - - if (event.type === 'SILENT_SYNC') { - return; - } - - // Always process the FULL history to provide a complete view to the ContextManager. - // The ContextManager relies on the 'nodes' array to be the TOTAL set of valid pristine nodes. - const fullHistory = this.chatHistory.get(); - const nodes = this.graphMapper.applyEvent({ - ...event, - payload: fullHistory, - }); - - const newNodes = new Set(); - for (const node of nodes) { - if (!this.seenNodeIds.has(node.id)) { - newNodes.add(node.id); - this.seenNodeIds.add(node.id); - } - } - - this.tracer.logEvent( - 'HistoryObserver', - `Rebuilt pristine graph from ${event.type} event`, - { nodesSize: nodes.length, newNodesCount: newNodes.size }, - ); - - this.eventBus.emitPristineHistoryUpdated({ - nodes, - newNodes, - }); - }; - - start() { - if (this.unsubscribeHistory) { - this.unsubscribeHistory(); - } - - this.unsubscribeHistory = this.chatHistory.subscribe(this.processEvent); - - // Process any existing history immediately upon start - const existing = this.chatHistory.get(); - if (existing && existing.length > 0) { - this.processEvent({ type: 'SYNC_FULL', payload: existing }); - } - } - - stop() { - if (this.unsubscribeHistory) { - this.unsubscribeHistory(); - this.unsubscribeHistory = undefined; - } - } -} diff --git a/packages/core/src/context/initializer.ts b/packages/core/src/context/initializer.ts index ac6208a78ef..dc19c127e1c 100644 --- a/packages/core/src/context/initializer.ts +++ b/packages/core/src/context/initializer.ts @@ -22,7 +22,6 @@ import { NodeDistillationProcessorOptionsSchema } from './processors/nodeDistill import { StateSnapshotProcessorOptionsSchema } from './processors/stateSnapshotProcessor.js'; import { StateSnapshotAsyncProcessorOptionsSchema } from './processors/stateSnapshotAsyncProcessor.js'; import { RollingSummaryProcessorOptionsSchema } from './processors/rollingSummaryProcessor.js'; -import { getEnvironmentContext } from '../utils/environmentContext.js'; import { AdaptiveTokenCalculator } from './utils/adaptiveTokenCalculator.js'; import { estimateContextBreakdown } from '../core/loggingContentGenerator.js'; import { NodeBehaviorRegistry } from './graph/behaviorRegistry.js'; @@ -136,7 +135,6 @@ export async function initializeContextManager( sidecarProfile.buildPipelines(env), sidecarProfile.buildAsyncPipelines(env), env, - eventBus, tracer, ); @@ -147,9 +145,5 @@ export async function initializeContextManager( orchestrator, chat.agentHistory, calculator, - async () => { - const parts = await getEnvironmentContext(config); - return { role: 'user', parts }; - }, ); } diff --git a/packages/core/src/context/pipeline/orchestrator.test.ts b/packages/core/src/context/pipeline/orchestrator.test.ts index 61b27c06df6..49c682d751e 100644 --- a/packages/core/src/context/pipeline/orchestrator.test.ts +++ b/packages/core/src/context/pipeline/orchestrator.test.ts @@ -18,7 +18,6 @@ import type { ProcessArgs, } from '../pipeline.js'; import type { PipelineDef, AsyncPipelineDef } from '../config/types.js'; -import type { ContextEventBus } from '../eventBus.js'; import type { ConcreteNode, UserPrompt } from '../graph/types.js'; // A realistic mock processor that modifies the text of the first target node @@ -77,11 +76,10 @@ function createMockAsyncProcessor( describe('PipelineOrchestrator (Component)', () => { let env: ContextEnvironment; - let eventBus: ContextEventBus; + let orchestrator: PipelineOrchestrator; beforeEach(() => { env = createMockEnvironment(); - eventBus = env.eventBus; }); afterEach(() => { @@ -92,13 +90,13 @@ describe('PipelineOrchestrator (Component)', () => { pipelines: PipelineDef[], asyncPipelines: AsyncPipelineDef[] = [], ) => { - const orchestrator = new PipelineOrchestrator( + orchestrator = new PipelineOrchestrator( pipelines, asyncPipelines, env, - eventBus, env.tracer, ); + return orchestrator; }; @@ -207,13 +205,14 @@ describe('PipelineOrchestrator (Component)', () => { const node1 = createDummyNode('ep1', NodeType.USER_PROMPT, 10); const node2 = createDummyNode('ep1', NodeType.AGENT_THOUGHT, 20); - eventBus.emitChunkReceived({ - nodes: [node1, node2], - targetNodeIds: new Set([node2.id]), - }); + await orchestrator.executeTriggerSync( + 'nodes_added', + [node1, node2], + new Set([node2.id]), + ); // Yield event loop - await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 10)); expect(executeSpy).toHaveBeenCalledTimes(1); const callArgs = executeSpy.mock.calls[0][0]; diff --git a/packages/core/src/context/pipeline/orchestrator.ts b/packages/core/src/context/pipeline/orchestrator.ts index 8b8dbe706f4..806f72be9a9 100644 --- a/packages/core/src/context/pipeline/orchestrator.ts +++ b/packages/core/src/context/pipeline/orchestrator.ts @@ -10,11 +10,7 @@ import type { PipelineDef, PipelineTrigger, } from '../config/types.js'; -import type { - ContextEnvironment, - ContextEventBus, - ContextTracer, -} from './environment.js'; +import type { ContextEnvironment, ContextTracer } from './environment.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { InboxSnapshotImpl } from './inbox.js'; import { ContextWorkingBufferImpl } from './contextWorkingBuffer.js'; @@ -30,10 +26,9 @@ export class PipelineOrchestrator { private readonly pipelines: PipelineDef[], private readonly asyncPipelines: AsyncPipelineDef[], private readonly env: ContextEnvironment, - private readonly eventBus: ContextEventBus, private readonly tracer: ContextTracer, ) { - this.setupTriggers(); + // Background timers not fully implemented in V1 yet } /** @@ -70,181 +65,6 @@ export class PipelineOrchestrator { ); } - private setupTriggers() { - const bindTriggers =

( - pipelines: P[], - executeFn: ( - pipeline: P, - nodes: readonly ConcreteNode[], - targets: ReadonlySet, - protectedIds: ReadonlySet, - ) => Promise, - ) => { - for (const pipeline of pipelines) { - for (const trigger of pipeline.triggers) { - if (typeof trigger === 'object' && trigger.type === 'timer') { - const timer = setInterval(() => { - // Background timers not fully implemented in V1 yet - }, trigger.intervalMs); - this.activeTimers.push(timer); - } else if ( - trigger === 'retained_exceeded' || - trigger === 'nodes_aged_out' - ) { - this.eventBus.onConsolidationNeeded((event) => { - void executeFn( - pipeline, - event.nodes, - event.targetNodeIds, - new Set(), - ); - }); - } else if (trigger === 'new_message' || trigger === 'nodes_added') { - this.eventBus.onChunkReceived((event) => { - void executeFn( - pipeline, - event.nodes, - event.targetNodeIds, - new Set(), - ); - }); - } - } - } - }; - - const handleSyncExecution = async ( - pipeline: PipelineDef, - nodes: readonly ConcreteNode[], - targets: ReadonlySet, - protectedIds: ReadonlySet, - ) => { - if (this.pipelineScheduled.has(pipeline.name)) { - debugLogger.log( - `[Orchestrator] Pipeline ${pipeline.name} already scheduled (sync), dropping.`, - ); - return; - } - this.pipelineScheduled.add(pipeline.name); - - const existing = - this.pipelineMutex.get(pipeline.name) || Promise.resolve(); - - const nextPromise = (async () => { - try { - await existing; - this.pipelineScheduled.delete(pipeline.name); - - const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes; - const latestTargets = latestNodes.filter((n) => targets.has(n.id)); - - debugLogger.log( - `[Orchestrator] Executing sync pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`, - ); - - if (latestTargets.length === 0) { - debugLogger.log( - `[Orchestrator] No latest targets for sync pipeline ${pipeline.name}, returning.`, - ); - return; - } - - await this.executePipelineAsync( - pipeline, - latestNodes, - new Set(targets), - new Set(protectedIds), - ); - } catch (e) { - debugLogger.error(`Sync pipeline chain ${pipeline.name} failed:`, e); - } - })(); - - this.pipelineMutex.set(pipeline.name, nextPromise); - const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - this.pendingPipelines.set(pipelineId, nextPromise); - void nextPromise.finally(() => { - this.pendingPipelines.delete(pipelineId); - if (this.pipelineMutex.get(pipeline.name) === nextPromise) { - this.pipelineMutex.delete(pipeline.name); - } - }); - }; - - const handleAsyncExecution = async ( - pipeline: AsyncPipelineDef, - nodes: readonly ConcreteNode[], - targets: ReadonlySet, - ) => { - if (this.pipelineScheduled.has(pipeline.name)) { - debugLogger.log( - `[Orchestrator] Pipeline ${pipeline.name} already scheduled (async), dropping.`, - ); - return; - } - this.pipelineScheduled.add(pipeline.name); - - const existing = - this.pipelineMutex.get(pipeline.name) || Promise.resolve(); - - const nextPromise = (async () => { - try { - await existing; - this.pipelineScheduled.delete(pipeline.name); - - const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes; - const latestTargets = latestNodes.filter((n) => targets.has(n.id)); - - debugLogger.log( - `[Orchestrator] Executing async pipeline ${pipeline.name} with ${latestTargets.length} latest targets.`, - ); - - const inboxSnapshot = new InboxSnapshotImpl( - this.env.inbox.getMessages() || [], - ); - - for (const processor of pipeline.processors) { - debugLogger.log( - `[Orchestrator] Running async processor ${processor.id}`, - ); - await processor.process({ - targets: latestTargets, - inbox: inboxSnapshot, - buffer: ContextWorkingBufferImpl.initialize(latestNodes), - }); - } - this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds()); - } catch (e) { - debugLogger.error(`Async pipeline chain ${pipeline.name} failed:`, e); - } - })(); - - this.pipelineMutex.set(pipeline.name, nextPromise); - const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - this.pendingPipelines.set(pipelineId, nextPromise); - void nextPromise.finally(() => { - this.pendingPipelines.delete(pipelineId); - if (this.pipelineMutex.get(pipeline.name) === nextPromise) { - this.pipelineMutex.delete(pipeline.name); - } - }); - }; - - bindTriggers(this.pipelines, (pipeline, nodes, targets, protectedIds) => - handleSyncExecution(pipeline, nodes, targets, protectedIds), - ); - - bindTriggers(this.asyncPipelines, (pipeline, nodes, targets) => - handleAsyncExecution(pipeline, nodes, targets), - ); - } - - shutdown() { - for (const timer of this.activeTimers) { - clearInterval(timer); - } - } - async executeTriggerSync( trigger: PipelineTrigger, nodes: readonly ConcreteNode[], @@ -256,6 +76,8 @@ export class PipelineOrchestrator { totalNodes: nodes.length, targetNodes: triggerTargets.size, }); + + // First, run any sync pipelines matching this trigger let currentBuffer = ContextWorkingBufferImpl.initialize(nodes); const triggerPipelines = this.pipelines.filter((p) => p.triggers.includes(trigger), @@ -320,93 +142,86 @@ export class PipelineOrchestrator { } } + // After sync pipelines finish, trigger any matching async pipelines in the background + void this.executeTriggerAsync(trigger, currentBuffer.nodes, triggerTargets); + // Success! Drain consumed messages this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds()); return currentBuffer.nodes; } - private async executePipelineAsync( - pipeline: PipelineDef, + private async executeTriggerAsync( + trigger: PipelineTrigger, nodes: readonly ConcreteNode[], - triggerTargets: Set, - protectedTurnIds: ReadonlySet = new Set(), + triggerTargets: ReadonlySet, ) { - this.tracer.logEvent( - 'Orchestrator', - `Triggering async pipeline: ${pipeline.name}`, - { - triggerTargets: triggerTargets.size, - totalNodes: nodes.length, - }, + const asyncPipelines = this.asyncPipelines.filter((p) => + p.triggers.includes(trigger), ); - if (!nodes || nodes.length === 0) return; - let currentBuffer = ContextWorkingBufferImpl.initialize(nodes); - const inboxSnapshot = new InboxSnapshotImpl( - this.env.inbox.getMessages() || [], - ); + for (const pipeline of asyncPipelines) { + void this.handleAsyncExecution(pipeline, nodes, triggerTargets); + } + } - for (const processor of pipeline.processors) { + private async handleAsyncExecution( + pipeline: AsyncPipelineDef, + nodes: readonly ConcreteNode[], + targets: ReadonlySet, + ) { + if (this.pipelineScheduled.has(pipeline.name)) { + return; + } + this.pipelineScheduled.add(pipeline.name); + + const existing = this.pipelineMutex.get(pipeline.name) || Promise.resolve(); + + const nextPromise = (async () => { try { - this.tracer.logEvent( - 'Orchestrator', - `Executing processor: ${processor.id} (async)`, - { nodeCountBefore: currentBuffer.nodes.length }, - ); + await existing; + this.pipelineScheduled.delete(pipeline.name); - const allowedTargets = currentBuffer.nodes.filter((n) => - this.isNodeAllowed(n, triggerTargets, protectedTurnIds), - ); + const latestNodes = this.nodeProvider ? this.nodeProvider() : nodes; + const latestTargets = latestNodes.filter((n) => targets.has(n.id)); - const returnedNodes = await processor.process({ - buffer: currentBuffer, - targets: allowedTargets, - inbox: inboxSnapshot, - }); + if (latestTargets.length === 0) return; - currentBuffer = currentBuffer.applyProcessorResult( - processor.id, - allowedTargets, - returnedNodes, + debugLogger.log( + `[Orchestrator] Executing async pipeline ${pipeline.name}`, ); - const addedNodes = returnedNodes.filter( - (n) => !allowedTargets.some((at) => at.id === n.id), + const inboxSnapshot = new InboxSnapshotImpl( + this.env.inbox.getMessages() || [], ); - const removedNodes = allowedTargets.filter( - (at) => !returnedNodes.some((n) => n.id === at.id), - ); - - this.tracer.logEvent('Orchestrator', 'Transformation Lineage (Async)', { - processorId: processor.id, - inputNodeCount: allowedTargets.length, - outputNodeCount: returnedNodes.length, - removedNodeIds: removedNodes.map((n) => n.id), - addedNodes: addedNodes.map((n) => ({ - id: n.id, - replacesId: n.replacesId, - abstractsIds: n.abstractsIds, - approxTokens: this.env.tokenCalculator.calculateConcreteListTokens([ - n, - ]), - })), - }); - this.eventBus.emitProcessorResult({ - processorId: processor.id, - targets: allowedTargets, - returnedNodes, - }); - } catch (error) { - debugLogger.error( - `Pipeline ${pipeline.name} failed async at ${processor.id}:`, - error, - ); - return; + for (const processor of pipeline.processors) { + await processor.process({ + targets: latestTargets, + inbox: inboxSnapshot, + buffer: ContextWorkingBufferImpl.initialize(latestNodes), + }); + } + this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds()); + } catch (e) { + debugLogger.error(`Async pipeline chain ${pipeline.name} failed:`, e); } - } + })(); + + this.pipelineMutex.set(pipeline.name, nextPromise); + const pipelineId = `${pipeline.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + this.pendingPipelines.set(pipelineId, nextPromise); + void nextPromise.finally(() => { + this.pendingPipelines.delete(pipelineId); + if (this.pipelineMutex.get(pipeline.name) === nextPromise) { + this.pipelineMutex.delete(pipeline.name); + } + }); + } - this.env.inbox.drainConsumed(inboxSnapshot.getConsumedIds()); + shutdown() { + for (const timer of this.activeTimers) { + clearInterval(timer); + } } } diff --git a/packages/core/src/context/processors/stateSnapshotAsyncProcessor.ts b/packages/core/src/context/processors/stateSnapshotAsyncProcessor.ts index 9188acb8b5e..55f8c66b748 100644 --- a/packages/core/src/context/processors/stateSnapshotAsyncProcessor.ts +++ b/packages/core/src/context/processors/stateSnapshotAsyncProcessor.ts @@ -117,6 +117,15 @@ export function createStateSnapshotAsyncProcessor( maxStateTokens: options.maxStateTokens, }, ); + + env.tracer.logEvent( + 'StateSnapshotAsyncProcessor', + 'Snapshot Synthesized', + { + snapshotText, + }, + ); + const newConsumedIds = [ ...previousConsumedIds, ...targets.map((t) => t.id), diff --git a/packages/core/src/context/processors/stateSnapshotProcessor.ts b/packages/core/src/context/processors/stateSnapshotProcessor.ts index b57f4f79609..94e4c242db0 100644 --- a/packages/core/src/context/processors/stateSnapshotProcessor.ts +++ b/packages/core/src/context/processors/stateSnapshotProcessor.ts @@ -90,6 +90,13 @@ export function createStateSnapshotProcessor( const isValid = consumedIds.every((id) => targetIds.has(id)); if (isValid) { + env.tracer.logEvent( + 'StateSnapshotProcessor', + 'Snapshot Spliced from Inbox', + { + snapshotText: newText, + }, + ); debugLogger.log( `[StateSnapshotProcessor] Successfully spliced PROPOSED_SNAPSHOT from Inbox into Graph. Consumed ${consumedIds.length} nodes.`, ); @@ -186,6 +193,11 @@ export function createStateSnapshotProcessor( maxStateTokens: options.maxStateTokens, }, ); + + env.tracer.logEvent('StateSnapshotProcessor', 'Snapshot Synthesized', { + snapshotText, + }); + const consumedIds = nodesToSummarize.map((n) => n.id); if (baselineIdToConsume && !consumedIds.includes(baselineIdToConsume)) { consumedIds.push(baselineIdToConsume); diff --git a/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap index afd08df64d0..9fd2c143f49 100644 --- a/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap +++ b/packages/core/src/context/system-tests/__snapshots__/lifecycle.golden.test.ts.snap @@ -2,57 +2,13 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge Tool Output & Images 1`] = ` { - "baseUnits": 765, + "baseUnits": 3155, "finalProjection": [ { "content": { "parts": [ { - "text": "System Instructions", - }, - ], - "role": "user", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Ack.", - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Hello!", - }, - ], - "role": "user", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Hi, how can I help?", - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Read the logs.", + "text": "{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}", }, ], "role": "user", @@ -63,46 +19,9 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To "content": { "parts": [ { - "functionCall": { - "args": { - "cmd": "cat server.log", - }, - "name": "run_shell_command", - }, - "thoughtSignature": "skip_thought_signature_validator", - }, - { - "functionCall": { - "args": {}, - "id": "undefined", - "name": "run_shell_command", - }, - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "functionResponse": { - "name": "run_shell_command", - "response": { - "output": " -[Tool observation string (0.02MB, 1 lines) masked to preserve context window. Full string saved to: ] -", - }, - }, - }, - { - "functionResponse": { - "id": "undefined", - "name": "run_shell_command", - "response": { - "error": "The tool execution result was lost due to context management truncation.", - }, + "inlineData": { + "data": "fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_fake_base64_data_", + "mimeType": "image/png", }, }, ], @@ -110,31 +29,6 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To }, "id": "", }, - { - "content": { - "parts": [ - { - "text": "The logs are very long.", - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Look at this architecture diagram:", - }, - { - "text": "[Multi-Modal Blob (image/png, 0.01MB) degraded to text to preserve context window. Saved to: ]", - }, - ], - "role": "user", - }, - "id": "", - }, { "content": { "parts": [ @@ -168,42 +62,31 @@ exports[`System Lifecycle Golden Tests > Scenario 1: Organic Growth with Huge To }, "id": "", }, - { - "content": { - "parts": [ - { - "text": "Please continue.", - }, - ], - "role": "user", - }, - "id": "", - }, ], "tokenTrajectory": [ { "tokensAfterBackground": 33, - "tokensBeforeBackground": 33, + "tokensBeforeBackground": 9, "turnIndex": 0, }, { "tokensAfterBackground": 68, - "tokensBeforeBackground": 68, + "tokensBeforeBackground": 57, "turnIndex": 1, }, { - "tokensAfterBackground": 493, - "tokensBeforeBackground": 20232, + "tokensAfterBackground": 20158, + "tokensBeforeBackground": 96, "turnIndex": 2, }, { - "tokensAfterBackground": 728, - "tokensBeforeBackground": 3550, + "tokensAfterBackground": 3118, + "tokensBeforeBackground": 20176, "turnIndex": 3, }, { - "tokensAfterBackground": 765, - "tokensBeforeBackground": 765, + "tokensAfterBackground": 3155, + "tokensBeforeBackground": 3134, "turnIndex": 4, }, ], @@ -258,27 +141,16 @@ exports[`System Lifecycle Golden Tests > Scenario 2: Under Budget (No Modificati }, "id": "", }, - { - "content": { - "parts": [ - { - "text": "Please continue.", - }, - ], - "role": "user", - }, - "id": "", - }, ], "tokenTrajectory": [ { "tokensAfterBackground": 33, - "tokensBeforeBackground": 33, + "tokensBeforeBackground": 9, "turnIndex": 0, }, { "tokensAfterBackground": 68, - "tokensBeforeBackground": 68, + "tokensBeforeBackground": 57, "turnIndex": 1, }, ], @@ -287,57 +159,13 @@ exports[`System Lifecycle Golden Tests > Scenario 2: Under Budget (No Modificati exports[`System Lifecycle Golden Tests > Scenario 3: Node Distillation of Large Historical Messages 1`] = ` { - "baseUnits": 5370, + "baseUnits": 5100, "finalProjection": [ { "content": { "parts": [ { - "text": "Mock response from: utility_compressor, for: {"text":"A...AAAAAAAA"}", - }, - ], - "role": "user", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Mock response from: utility_compressor, for: {"text":"B...BBBBBBBB"}", - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Mock response from: utility_compressor, for: {"text":"C...CCCCCCCC"}", - }, - ], - "role": "user", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Mock response from: utility_compressor, for: {"text":"D...DDDDDDDD"}", - }, - ], - "role": "model", - }, - "id": "", - }, - { - "content": { - "parts": [ - { - "text": "Mock response from: utility_compressor, for: {"text":"E...EEEEEEEE"}", + "text": "{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}", }, ], "role": "user", @@ -355,32 +183,21 @@ exports[`System Lifecycle Golden Tests > Scenario 3: Node Distillation of Large }, "id": "", }, - { - "content": { - "parts": [ - { - "text": "Please continue.", - }, - ], - "role": "user", - }, - "id": "", - }, ], "tokenTrajectory": [ { - "tokensAfterBackground": 5078, - "tokensBeforeBackground": 10010, + "tokensAfterBackground": 10010, + "tokensBeforeBackground": 5005, "turnIndex": 0, }, { - "tokensAfterBackground": 5224, - "tokensBeforeBackground": 15088, + "tokensAfterBackground": 5100, + "tokensBeforeBackground": 5100, "turnIndex": 1, }, { - "tokensAfterBackground": 5370, - "tokensBeforeBackground": 15234, + "tokensAfterBackground": 5100, + "tokensBeforeBackground": 5100, "turnIndex": 2, }, ], @@ -397,9 +214,6 @@ exports[`System Lifecycle Golden Tests > Scenario 4: Async-Driven Background GC { "text": "{"active_tasks":[],"discovered_facts":[],"constraints_and_preferences":[],"recent_arc":[]}", }, - { - "text": "Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 ..................................................", - }, ], "role": "user", }, @@ -409,10 +223,10 @@ exports[`System Lifecycle Golden Tests > Scenario 4: Async-Driven Background GC "content": { "parts": [ { - "text": "Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 ..................................................", + "text": "Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 Msg 8 ..................................................", }, ], - "role": "model", + "role": "user", }, "id": "", }, @@ -420,10 +234,10 @@ exports[`System Lifecycle Golden Tests > Scenario 4: Async-Driven Background GC "content": { "parts": [ { - "text": "Please continue.", + "text": "Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 Msg 9 ..................................................", }, ], - "role": "user", + "role": "model", }, "id": "", }, @@ -431,27 +245,27 @@ exports[`System Lifecycle Golden Tests > Scenario 4: Async-Driven Background GC "tokenTrajectory": [ { "tokensAfterBackground": 410, - "tokensBeforeBackground": 410, + "tokensBeforeBackground": 205, "turnIndex": 0, }, { - "tokensAfterBackground": 820, - "tokensBeforeBackground": 820, + "tokensAfterBackground": 505, + "tokensBeforeBackground": 615, "turnIndex": 1, }, { - "tokensAfterBackground": 1230, - "tokensBeforeBackground": 1230, + "tokensAfterBackground": 505, + "tokensBeforeBackground": 505, "turnIndex": 2, }, { - "tokensAfterBackground": 1640, - "tokensBeforeBackground": 1640, + "tokensAfterBackground": 505, + "tokensBeforeBackground": 505, "turnIndex": 3, }, { - "tokensAfterBackground": 2050, - "tokensBeforeBackground": 2050, + "tokensAfterBackground": 505, + "tokensBeforeBackground": 505, "turnIndex": 4, }, ], diff --git a/packages/core/src/context/system-tests/lifecycle.golden.test.ts b/packages/core/src/context/system-tests/lifecycle.golden.test.ts index 970138ca5bf..3bee35951f7 100644 --- a/packages/core/src/context/system-tests/lifecycle.golden.test.ts +++ b/packages/core/src/context/system-tests/lifecycle.golden.test.ts @@ -17,16 +17,18 @@ expect.addSnapshotSerializer({ (/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.test( val, ) || - /^[0-9a-f]{32}$/i.test(val) || + /\b[0-9a-f]{32}\b/i.test(val) || + /\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/.test(val) || /[\\/]tmp[\\/]sim/.test(val)), print: (val) => { if (typeof val !== 'string') return `"${val}"`; let scrubbed = val .replace( - /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '', ) .replace(/\b[0-9a-f]{32}\b/gi, '') + .replace(/\bsynth_[a-zA-Z0-9_]+_[0-9a-f]{32}\b/g, 'synth__') .replace(/[\\/]tmp[\\/]sim[^\s"'\]]*/g, ''); // Also scrub timestamps in filenames like blob_1234567890_... diff --git a/packages/core/src/context/system-tests/simulationHarness.ts b/packages/core/src/context/system-tests/simulationHarness.ts index de137bb5e16..f177b6ec7be 100644 --- a/packages/core/src/context/system-tests/simulationHarness.ts +++ b/packages/core/src/context/system-tests/simulationHarness.ts @@ -83,7 +83,6 @@ export class SimulationHarness { config.buildPipelines(this.env), config.buildAsyncPipelines(this.env), this.env, - this.eventBus, this.tracer, ); this.contextManager = new ContextManager( @@ -97,24 +96,38 @@ export class SimulationHarness { } async simulateTurn(messages: Content[]) { - // 1. Append the new messages + // In the new turn-based flow, we simulate the 'next' prompt or turn + // by calling renderHistory on the pending content. + + // For the purpose of the simulation, we'll treat the first message as the 'pending' one + // if it hasn't been added to history yet. + const pendingContent = messages[messages.length - 1]; + + // 1. Render to trigger sync and management + const { processedNodes } = await this.contextManager.renderHistory({ + id: randomUUID(), + content: pendingContent, + }); + + const tokensBefore = + this.env.tokenCalculator.calculateConcreteListTokens(processedNodes); + + // 2. Append the new messages to durable history const currentHistory = this.chatHistory.get(); const turns = messages.map((m) => ({ id: randomUUID(), content: m })); this.chatHistory.set([...currentHistory, ...turns]); - // 2. Measure tokens immediately after append - const tokensBefore = this.env.tokenCalculator.calculateConcreteListTokens( - this.contextManager.getNodes(), - ); - - // 3. Yield to event loop and wait for async pipelines to finish + // 3. Wait for any async pipelines triggered by the sync await this.contextManager.waitForPipelines(); - await new Promise((resolve) => setTimeout(resolve, 100)); // Extra beat for event bus propagation - // 4. Measure tokens after background processors - const tokensAfter = this.env.tokenCalculator.calculateConcreteListTokens( - this.contextManager.getNodes(), - ); + // 4. Measure tokens after background processors (requires another render or sync check) + // In the new model, we'd need to re-render to see the effect of async processors + // that might have finished. + const { processedNodes: nodesAfter } = + await this.contextManager.renderHistory(); + + const tokensAfter = + this.env.tokenCalculator.calculateConcreteListTokens(nodesAfter); this.tokenTrajectory.push({ turnIndex: this.currentTurnIndex++, diff --git a/packages/core/src/context/testing/contextTestUtils.ts b/packages/core/src/context/testing/contextTestUtils.ts index 0ac9bae341c..fed08e2a823 100644 --- a/packages/core/src/context/testing/contextTestUtils.ts +++ b/packages/core/src/context/testing/contextTestUtils.ts @@ -8,6 +8,7 @@ import { vi } from 'vitest'; import { AgentChatHistory } from '../../core/agentChatHistory.js'; import { ContextManager } from '../contextManager.js'; import { randomUUID } from 'node:crypto'; +export { deriveStableId } from '../../utils/cryptoUtils.js'; import { ContextTracer } from '../tracer.js'; import { ContextEnvironmentImpl } from '../pipeline/environmentImpl.js'; import { ContextEventBus } from '../eventBus.js'; @@ -317,7 +318,6 @@ export function setupContextComponentTest( sidecar.buildPipelines(env), sidecar.buildAsyncPipelines(env), env, - eventBus, tracer, ); diff --git a/packages/core/src/core/agentChatHistory.ts b/packages/core/src/core/agentChatHistory.ts index 438a903b462..0bcd1576aac 100644 --- a/packages/core/src/core/agentChatHistory.ts +++ b/packages/core/src/core/agentChatHistory.ts @@ -16,61 +16,34 @@ export interface HistoryTurn { readonly content: Content; } -export type HistoryEventType = 'PUSH' | 'SYNC_FULL' | 'CLEAR' | 'SILENT_SYNC'; - -export interface HistoryEvent { - type: HistoryEventType; - payload: readonly HistoryTurn[]; -} - -export type HistoryListener = (event: HistoryEvent) => void; - /** * The 'Strong Owner' of chat history turns. * It ensures that every turn in the session is associated with a durable ID. */ export class AgentChatHistory { private history: HistoryTurn[] = []; - private listeners: Set = new Set(); constructor(initialTurns: HistoryTurn[] = []) { this.history = [...initialTurns]; } - subscribe(listener: HistoryListener): () => void { - this.listeners.add(listener); - // Emit initial state to new subscriber - listener({ type: 'SYNC_FULL', payload: this.history }); - return () => this.listeners.delete(listener); - } - - private notify(type: HistoryEventType, payload: readonly HistoryTurn[]) { - const event: HistoryEvent = { type, payload }; - for (const listener of this.listeners) { - listener(event); - } - } - /** * Adds a new turn to the history. * Every turn must have a durable ID, usually provided by the ChatRecordingService. */ push(turn: HistoryTurn) { this.history.push(turn); - this.notify('PUSH', [turn]); } /** * Overwrites the entire history with a new list of turns. */ - set(turns: readonly HistoryTurn[], options: { silent?: boolean } = {}) { + set(turns: readonly HistoryTurn[]) { this.history = [...turns]; - this.notify(options.silent ? 'SILENT_SYNC' : 'SYNC_FULL', this.history); } clear() { this.history = []; - this.notify('CLEAR', []); } get(): readonly HistoryTurn[] { diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index de9da9530e9..0606f18a00c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -1001,7 +1001,7 @@ ${JSON.stringify( { model: 'default-routed-model', isChatModel: true }, initialRequest, expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); @@ -1872,7 +1872,7 @@ ${JSON.stringify( { model: 'routed-model', isChatModel: true }, [{ text: 'Hi' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); @@ -1890,7 +1890,7 @@ ${JSON.stringify( { model: 'routed-model', isChatModel: true }, [{ text: 'Hi' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); // Second turn @@ -1908,7 +1908,7 @@ ${JSON.stringify( { model: 'routed-model', isChatModel: true }, [{ text: 'Continue' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); @@ -1926,7 +1926,7 @@ ${JSON.stringify( { model: 'routed-model', isChatModel: true }, [{ text: 'Hi' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); // New prompt @@ -1948,7 +1948,7 @@ ${JSON.stringify( { model: 'new-routed-model', isChatModel: true }, [{ text: 'A new topic' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); @@ -1976,7 +1976,7 @@ ${JSON.stringify( { model: 'original-model', isChatModel: true }, [{ text: 'Hi' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); mockRouterService.route.mockResolvedValue({ @@ -1999,7 +1999,7 @@ ${JSON.stringify( { model: 'fallback-model', isChatModel: true }, [{ text: 'Continue' }], expect.any(AbortSignal), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); }); @@ -2428,7 +2428,7 @@ ${JSON.stringify( expect.objectContaining({ model: 'model-a' }), expect.anything(), expect.anything(), - undefined, + expect.objectContaining({ displayContent: undefined }), ); }); @@ -3469,7 +3469,7 @@ ${JSON.stringify( expect.anything(), [{ text: 'Please explain' }], expect.anything(), - undefined, + expect.objectContaining({ displayContent: undefined }), ); // First call should have stopHookActive=false, retry should have stopHookActive=true diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 69a68c0313c..824e480e828 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -53,7 +53,7 @@ import type { DefaultHookOutput, AfterAgentHookOutput, } from '../hooks/types.js'; -import { NextSpeakerCheckEvent, type LlmRole } from '../telemetry/types.js'; +import { NextSpeakerCheckEvent, LlmRole } from '../telemetry/types.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import type { IdeContext, File } from '../ide/types.js'; import { handleFallback } from '../fallback/handler.js'; @@ -389,9 +389,7 @@ export class GeminiClient { const toolDeclarations = toolRegistry.getFunctionDeclarations(); const tools: Tool[] = [{ functionDeclarations: toolDeclarations }]; - const history = this.config.getContextManagementConfig().enabled - ? (extraHistory ?? []) - : await getInitialChatHistory(this.config, extraHistory); + const history = await getInitialChatHistory(this.config, extraHistory); try { const systemMemory = this.config.getSystemInstructionMemory(); @@ -640,21 +638,19 @@ export class GeminiClient { const modelForLimitCheck = this._getActiveModelForCurrentTurn(); let currentBaseUnits = 0; + let apiHistoryOverride: Content[] | undefined = undefined; if (this.config.getContextManagementConfig().enabled) { if (this.contextManager) { const rawPendingRequest = createUserContent(request); const pendingRequest = { - id: - this.getChatRecordingService()?.recordSyntheticMessage( - 'user', - rawPendingRequest.parts || [], - ) || randomUUID(), + id: randomUUID(), content: rawPendingRequest, }; const { history: newHistory, - didApplyManagement, + apiHistory, + pendingApiHistory, baseUnits, } = await this.contextManager.renderHistory( pendingRequest, @@ -664,12 +660,22 @@ export class GeminiClient { currentBaseUnits = baseUnits; - if (didApplyManagement) { - // If the manager pruned history, we update the chat before continuing. - // Note: we don't include the pendingRequest in this setHistory, - // because Turn.run will add it normally. - this.getChat().setHistory(newHistory, { silent: true }); - } + // Use the PROCESSED pending content if available (e.g. if cleaned or distilled) + const finalPendingContent = + pendingApiHistory.length > 0 + ? pendingApiHistory[0] + : rawPendingRequest; + + // Late-bind the prompt: Append the active request to the managed history + // only for the purpose of the upcoming API call. + apiHistoryOverride = [...apiHistory, finalPendingContent]; + + this.getChat().setHistory(newHistory); + + // Use the original request for display/recording, + // but the processed one for the API and durable history. + displayContent = rawPendingRequest.parts || []; + request = finalPendingContent.parts || []; } else { const newHistory = await this.agentHistoryProvider.manageHistory( this.getHistory(), @@ -794,12 +800,11 @@ export class GeminiClient { // Update tools with the final modelId to ensure model-dependent descriptions are used. await this.setTools(modelToUse); - const resultStream = turn.run( - modelConfigKey, - request, - signal, + const resultStream = turn.run(modelConfigKey, request, signal, { displayContent, - ); + role: LlmRole.MAIN, + apiHistoryOverride, + }); let isError = false; let loopDetectedAbort = false; diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index a663289fefa..fecc7f34d68 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2239,7 +2239,13 @@ describe('GeminiChat', () => { role: 'model', parts: [ { text: 'thinking...' }, - { functionCall: { name: 'test', args: {} } }, + { + functionCall: { + name: 'test', + args: {}, + id: expect.stringMatching(/^synth_test_/), + }, + }, ], }, ]); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 30b33d3c2e3..30837aca1cb 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -51,8 +51,11 @@ import { } from '../telemetry/types.js'; import { handleFallback } from '../fallback/handler.js'; import { isFunctionResponse } from '../utils/messageInspectors.js'; -import { scrubHistory } from '../utils/historyHardening.js'; -import { partListUnionToString } from './geminiRequest.js'; +import { scrubHistory, scrubContents } from '../utils/historyHardening.js'; +import { + partListUnionToString, + ensureStableToolIds, +} from '../utils/sessionUtils.js'; import { BINARY_INJECTION_KEY } from '../utils/generateContentResponseUtilities.js'; import type { ModelConfigKey } from '../services/modelConfigService.js'; import { estimateTokenCountSync } from '../utils/tokenCalculation.js'; @@ -62,7 +65,6 @@ import { } from '../availability/policyHelpers.js'; import { coreEvents } from '../utils/events.js'; import type { AgentLoopContext } from '../config/agent-loop-context.js'; -import { debugLogger } from '../utils/debugLogger.js'; export enum StreamEventType { /** A regular content chunk from the API. */ @@ -312,6 +314,8 @@ export class GeminiChat { } this.agentHistory = new AgentChatHistory(initialHistory); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ensureStableToolIds(this.agentHistory.get() as HistoryTurn[]); this.chatRecordingService = new ChatRecordingService(context); this.lastPromptTokenCount = estimateTokenCountSync( this.agentHistory.flatMap((c) => c.content.parts || []), @@ -325,7 +329,7 @@ export class GeminiChat { async initialize( resumedSessionData?: ResumedSessionData, kind: 'main' | 'subagent' = 'main', - ) { + ): Promise { await this.chatRecordingService.initialize(resumedSessionData, kind); // Sync initial history with the recorder to ensure all turns (even bootstrapped ones) // are durable and coordinated. @@ -375,6 +379,7 @@ export class GeminiChat { signal: AbortSignal, role: LlmRole, displayContent?: PartListUnion, + apiHistoryOverride?: Content[], ): Promise> { await this.sendPromise; @@ -388,6 +393,9 @@ export class GeminiChat { const { model } = this.context.config.modelConfigService.getResolvedConfig(modelConfigKey); + const isContextManagementEnabled = + this.context.config.isContextManagementEnabled(); + // Record user input - capture complete message with all parts (text, files, images, etc.) // but skip recording function responses (tool call results) as they should be stored in tool call records if (!isFunctionResponse(userContent)) { @@ -405,13 +413,34 @@ export class GeminiChat { } } - const id = this.chatRecordingService.recordMessage({ - model, - type: 'user', - content: userMessageParts, - displayContent: finalDisplayContent, - }); - this.agentHistory.push({ id, content: userContent }); + if (!isContextManagementEnabled) { + const id = this.chatRecordingService.recordMessage({ + model, + type: 'user', + content: userMessageParts, + displayContent: finalDisplayContent, + }); + this.agentHistory.push({ id, content: userContent }); + } else { + // With Context Management, the client has already recorded the user message + // and called setHistory to ensure the graph is in sync. + // We just verify it's there. + const history = this.agentHistory.get(); + const lastTurn = history[history.length - 1]; + if ( + !lastTurn || + partListUnionToString(lastTurn.content.parts || []) !== + userMessageContent + ) { + const id = this.chatRecordingService.recordMessage({ + model, + type: 'user', + content: userMessageParts, + displayContent: finalDisplayContent, + }); + this.agentHistory.push({ id, content: userContent }); + } + } } else { // Record tool response as a message to ensure durable ID and linear history for resume. const id = this.chatRecordingService.recordSyntheticMessage( @@ -419,49 +448,63 @@ export class GeminiChat { userContent.parts || [], ); - // Binary injections: If the tool output contains binary data, we expand the history. - const binaryParts = this.extractBinaryInjections(userContent.parts); - if (binaryParts) { - // Turn 1: The original tool response (now cleaned) - this.agentHistory.push({ id, content: userContent }); - - // Turn 2: Synthetic Model Acknowledgment - const modelId = this.chatRecordingService.recordSyntheticMessage( - 'gemini', - [ - { - text: 'Binary content received. Proceeding with analysis.', - thought: true, - thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE, - }, - ], - ); - this.agentHistory.push({ - id: modelId, - content: { - role: 'model', - parts: [ + if (!isContextManagementEnabled) { + // Binary injections: If the tool output contains binary data, we expand the history. + const binaryParts = this.extractBinaryInjections(userContent.parts); + if (binaryParts) { + // Turn 1: The original tool response (now cleaned) + this.agentHistory.push({ id, content: userContent }); + + // Turn 2: Synthetic Model Acknowledgment + const modelId = this.chatRecordingService.recordSyntheticMessage( + 'gemini', + [ { text: 'Binary content received. Proceeding with analysis.', thought: true, thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE, }, ], - }, - }); + ); + this.agentHistory.push({ + id: modelId, + content: { + role: 'model', + parts: [ + { + text: 'Binary content received. Proceeding with analysis.', + thought: true, + thoughtSignature: SYNTHETIC_THOUGHT_SIGNATURE, + }, + ], + }, + }); - // Turn 3: The actual binary data (becomes the current request message) - const binaryId = this.chatRecordingService.recordSyntheticMessage( - 'info', - binaryParts, - ); - userContent = { - role: 'user', - parts: binaryParts, - }; - this.agentHistory.push({ id: binaryId, content: userContent }); + // Turn 3: The actual binary data (becomes the current request message) + const binaryId = this.chatRecordingService.recordSyntheticMessage( + 'info', + binaryParts, + ); + userContent = { + role: 'user', + parts: binaryParts, + }; + this.agentHistory.push({ id: binaryId, content: userContent }); + } else { + this.agentHistory.push({ id, content: userContent }); + } } else { - this.agentHistory.push({ id, content: userContent }); + // With Context Management, we just push it to the history if not already there. + // (The client should have handled this, but we're defensive). + const history = this.agentHistory.get(); + const lastTurn = history[history.length - 1]; + if ( + !lastTurn || + partListUnionToString(lastTurn.content.parts || []) !== + partListUnionToString(userContent.parts || []) + ) { + this.agentHistory.push({ id, content: userContent }); + } } } @@ -493,6 +536,7 @@ export class GeminiChat { prompt_id, signal, role, + apiHistoryOverride, ); isConnectionPhase = false; for await (const chunk of stream) { @@ -635,6 +679,7 @@ export class GeminiChat { prompt_id: string, abortSignal: AbortSignal, role: LlmRole, + apiHistoryOverride?: Content[], ): Promise> { // Last mile scrubbing to remove internal tracking properties (e.g. callIndex) // before sending to the Gemini API. This whitelists only standard Gemini fields. @@ -644,10 +689,12 @@ export class GeminiChat { const scrubbedContents = scrubbedHistory.map((h) => h.content); - const contentsForPreviewModel = - this.ensureActiveLoopHasThoughtSignatures(scrubbedContents); + const requestContents = apiHistoryOverride + ? scrubContents(apiHistoryOverride) + : scrubbedContents; - const requestContents = scrubbedContents; + const contentsForPreviewModel = + this.ensureActiveLoopHasThoughtSignatures(requestContents); // Track final request parameters for AfterModel hooks const { @@ -934,12 +981,11 @@ export class GeminiChat { ); this.agentHistory.push({ id, content }); } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ensureStableToolIds(this.agentHistory.get() as HistoryTurn[]); } - setHistory( - history: ReadonlyArray, - options: { silent?: boolean } = {}, - ): void { + setHistory(history: ReadonlyArray): void { const wrappedHistory: HistoryTurn[] = history.map((item) => { if ('id' in item && 'content' in item) { return item; @@ -950,7 +996,8 @@ export class GeminiChat { ); return { id, content: item }; }); - this.agentHistory.set(wrappedHistory, options); + ensureStableToolIds(wrappedHistory); + this.agentHistory.set(wrappedHistory); this.lastPromptTokenCount = estimateTokenCountSync( this.agentHistory.flatMap((c) => c.content.parts || []), ); @@ -1104,9 +1151,6 @@ export class GeminiChat { if (!id) { id = `synth_${this.context.promptId}_${Date.now()}_${this.callCounter++}`; callIndexToId.set(globalIndex, id); - debugLogger.log( - `[GeminiChat] Assigned synthetic ID: ${id} to tool at index ${globalIndex}: ${fnCall.name}`, - ); } fnCall.id = id; } @@ -1203,9 +1247,6 @@ export class GeminiChat { let currentCallSourceIndex = -1; if (this.context.config.isContextManagementEnabled()) { - debugLogger.log( - `[GeminiChat] Starting consolidation for ${modelResponseParts.length} raw parts and ${finalFunctionCalls.length} assembled function calls.`, - ); for (const part of modelResponseParts) { if (part.functionCall) { const partIndex = isIndexedPart(part) ? part.callIndex : undefined; diff --git a/packages/core/src/core/turn.test.ts b/packages/core/src/core/turn.test.ts index be949454766..cd769abb0dd 100644 --- a/packages/core/src/core/turn.test.ts +++ b/packages/core/src/core/turn.test.ts @@ -18,7 +18,6 @@ import { StreamEventType, type GeminiChat, } from './geminiChat.js'; -import { LlmRole } from '../telemetry/types.js'; const mockSendMessageStream = vi.fn(); const mockGetHistory = vi.fn(); @@ -123,7 +122,8 @@ describe('Turn', () => { reqParts, 'prompt-id-1', expect.any(AbortSignal), - LlmRole.MAIN, + 'main', + undefined, undefined, ); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 97626c79ffe..74771c4478e 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -6,6 +6,7 @@ import { createUserContent, + type Content, type PartListUnion, type GenerateContentResponse, type FunctionCall, @@ -257,9 +258,13 @@ export class Turn { modelConfigKey: ModelConfigKey, req: PartListUnion, signal: AbortSignal, - displayContent?: PartListUnion, - role: LlmRole = LlmRole.MAIN, + options: { + displayContent?: PartListUnion; + role?: LlmRole; + apiHistoryOverride?: Content[]; + } = {}, ): AsyncGenerator { + const { displayContent, role = LlmRole.MAIN, apiHistoryOverride } = options; try { // Note: This assumes `sendMessageStream` yields events like // { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse } @@ -270,6 +275,7 @@ export class Turn { signal, role, displayContent, + apiHistoryOverride, ); for await (const streamEvent of responseStream) { diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index ca76a0e499a..9d9416284d1 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -220,14 +220,92 @@ export async function loadConversationRecord( ); memoryScratchpadIsStale = false; } + if ( + hasProperty(record.$set, 'messages') && + Array.isArray(record.$set.messages) + ) { + // Checkpoint: clear and rebuild from the provided messages array + messagesMap.clear(); + if (options?.metadataOnly) { + messageIds.length = 0; + messageKinds.clear(); + } + for (const msg of record.$set.messages) { + if (isMessageRecord(msg)) { + const id = msg.id; + const isUser = msg.type === 'user'; + const isUserOrAssistant = + msg.type === 'user' || msg.type === 'gemini'; + + if (options?.metadataOnly) { + messageIds.push(id); + messageKinds.set(id, { isUser, isUserOrAssistant }); + } else { + messagesMap.set(id, msg); + } + + if ( + !firstUserMessageStr && + isUser && + msg.content && + (Array.isArray(msg.content) || + typeof msg.content === 'string') + ) { + if (Array.isArray(msg.content)) { + firstUserMessageStr = msg.content + .map((p: unknown) => (isTextPart(p) ? p.text : '')) + .join(''); + } else { + firstUserMessageStr = msg.content; + } + } + } + } + } // Metadata update metadata = { ...metadata, ...record.$set, }; } else if (isPartialMetadataRecord(record)) { - // Initial metadata line + // Initial metadata line (or entire legacy record if on one line) metadata = { ...metadata, ...record }; + if ( + hasProperty(record, 'messages') && + Array.isArray(record.messages) + ) { + for (const msg of record.messages) { + if (isMessageRecord(msg)) { + const id = msg.id; + const isUser = msg.type === 'user'; + const isUserOrAssistant = + msg.type === 'user' || msg.type === 'gemini'; + + if (options?.metadataOnly) { + messageIds.push(id); + messageKinds.set(id, { isUser, isUserOrAssistant }); + } else { + messagesMap.set(id, msg); + } + + if ( + !firstUserMessageStr && + isUser && + msg.content && + (Array.isArray(msg.content) || + typeof msg.content === 'string') + ) { + if (Array.isArray(msg.content)) { + firstUserMessageStr = msg.content + .map((p: unknown) => (isTextPart(p) ? p.text : '')) + .join(''); + } else { + firstUserMessageStr = msg.content; + } + } + } + } + } } } catch { // ignore parse errors on individual lines @@ -238,15 +316,9 @@ export async function loadConversationRecord( return await parseLegacyRecordFallback(filePath, options); } - const metadataMessages = Array.isArray(metadata.messages) - ? metadata.messages - : []; - const loadedMessages = - metadataMessages.length > 0 - ? metadataMessages - : Array.from(messagesMap.values()); + const loadedMessages = Array.from(messagesMap.values()); const metadataFirstUserMessage = - metadataMessages.find((message) => message.type === 'user') ?? null; + loadedMessages.find((message) => message.type === 'user') ?? null; let fallbackFirstUserMessage = firstUserMessageStr; if (!fallbackFirstUserMessage && metadataFirstUserMessage) { const rawContent = metadataFirstUserMessage.content; @@ -276,22 +348,14 @@ export async function loadConversationRecord( kind: metadata.kind, messages: options?.metadataOnly ? [] : loadedMessages, messageCount: options?.metadataOnly - ? metadataMessages.length || messageIds.length + ? loadedMessages.length || messageIds.length : loadedMessages.length, - userMessageCount: - options?.metadataOnly && metadataMessages.length > 0 - ? metadataMessages.filter((m) => m.type === 'user').length - : userMessageCount, + userMessageCount, memoryScratchpadIsStale: isTrackingMemoryScratchpadFreshness ? memoryScratchpadIsStale : undefined, firstUserMessage: fallbackFirstUserMessage, - hasUserOrAssistantMessage: - options?.metadataOnly && metadataMessages.length > 0 - ? metadataMessages.some( - (m) => m.type === 'user' || m.type === 'gemini', - ) - : hasUserOrAssistant, + hasUserOrAssistantMessage: hasUserOrAssistant, }; } catch (error) { debugLogger.error('Error loading conversation record from JSONL:', error); diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index 6344a085695..2e1f742be96 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -8,6 +8,7 @@ import type { Part, Content } from '@google/genai'; import type { Config } from '../config/config.js'; import { getFolderStructure } from './getFolderStructure.js'; import type { HistoryTurn } from '../core/agentChatHistory.js'; +import { deriveStableId } from './cryptoUtils.js'; export const INITIAL_HISTORY_LENGTH = 1; @@ -84,13 +85,26 @@ export async function getInitialChatHistory( config: Config, extraHistory?: ReadonlyArray, ): Promise> { + const envId = deriveStableId(['environment-context']); + + if (extraHistory && extraHistory.length > 0) { + const first = extraHistory[0]; + const firstId = 'id' in first ? first.id : undefined; + if (firstId === envId) { + return [...extraHistory]; + } + } + const envParts = await getEnvironmentContext(config); const envContextString = envParts.map((part) => part.text || '').join('\n\n'); return [ { - role: 'user', - parts: [{ text: envContextString }], + id: deriveStableId(['environment-context']), + content: { + role: 'user', + parts: [{ text: envContextString }], + }, }, ...(extraHistory ?? []), ]; diff --git a/packages/core/src/utils/historyHardening.ts b/packages/core/src/utils/historyHardening.ts index 8a2dc547b1f..e469b08e834 100644 --- a/packages/core/src/utils/historyHardening.ts +++ b/packages/core/src/utils/historyHardening.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type Part } from '@google/genai'; +import { type Part, type Content } from '@google/genai'; import { debugLogger } from './debugLogger.js'; import { type HistoryTurn } from '../core/agentChatHistory.js'; import { deriveStableId } from './cryptoUtils.js'; @@ -124,7 +124,7 @@ function pairToolsAndEnforceSignatures( const missing: Array<{ id: string; name: string }> = []; for (const call of callParts) { - const id = call.functionCall!.id || 'undefined'; + const id = call.functionCall!.id; const name = call.functionCall!.name || 'unknown'; const hasResponse = @@ -139,7 +139,7 @@ function pairToolsAndEnforceSignatures( debugLogger.log( `[HistoryHardener] Call id='${id}' (name='${name}') has no matching response in next turn.`, ); - missing.push({ id, name }); + missing.push({ id: id || '', name }); } } @@ -164,7 +164,7 @@ function pairToolsAndEnforceSignatures( targetUserTurn.content.parts.push({ functionResponse: { name: m.name, - id: m.id, + id: m.id || undefined, response: { error: sentinels.lostToolResponse, }, @@ -346,10 +346,17 @@ function enforceRoleConstraints( export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] { return history.map((turn) => ({ id: turn.id, - content: { - role: turn.content.role, - parts: (turn.content.parts || []).map((p) => scrubPart(p)), - }, + content: scrubContents([turn.content])[0], + })); +} + +/** + * Deep-scrubs an array of Content objects to remove non-standard properties. + */ +export function scrubContents(contents: Content[]): Content[] { + return contents.map((content) => ({ + role: content.role, + parts: (content.parts || []).map((p) => scrubPart(p)), })); } @@ -361,7 +368,7 @@ function isThoughtPart(part: Part): part is ThoughtPart { return 'thoughtSignature' in part; } -function scrubPart(part: Part): Part { +export function scrubPart(part: Part): Part { const scrubbed: Record = {}; if ('text' in part && typeof part.text === 'string') { diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index 822612cb28f..cef35650c99 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -6,8 +6,77 @@ import { type Part, type PartListUnion } from '@google/genai'; import { type ConversationRecord } from '../services/chatRecordingService.js'; +export { partListUnionToString } from '../core/geminiRequest.js'; import { partListUnionToString } from '../core/geminiRequest.js'; import { type HistoryTurn } from '../core/agentChatHistory.js'; +import { deriveStableId } from './cryptoUtils.js'; + +/** + * Ensures that all function calls and responses in a chat history have stable IDs. + * If IDs are missing (e.g. legacy data or manually constructed tests), it synthesizes + * them and MUTATES the underlying Part objects. + * + * It uses a deterministic pairing heuristic for adjacent turns to link calls and responses. + */ +export function ensureStableToolIds(history: HistoryTurn[]): void { + for (let i = 0; i < history.length; i++) { + const turn = history[i]; + const parts = turn.content.parts || []; + + for (let partIdx = 0; partIdx < parts.length; partIdx++) { + const part = parts[partIdx]; + + if (part.functionCall && !part.functionCall.id) { + const name = part.functionCall.name; + // Search ahead for a matching response in the next turn (common pattern) + const nextTurn = history[i + 1]; + let pairedId: string | undefined; + + if (nextTurn?.content.role === 'user') { + const matchingResp = nextTurn.content.parts?.find( + (p) => + p.functionResponse && + p.functionResponse.name === name && + !p.functionResponse.id, + ); + if (matchingResp) { + pairedId = `synth_${name}_${deriveStableId([turn.id, i.toString(), partIdx.toString()])}`; + part.functionCall.id = pairedId; + matchingResp.functionResponse!.id = pairedId; + } + } + + if (!part.functionCall.id) { + // If no pairing found, generate a solo synthetic ID + part.functionCall.id = `synth_${name}_${deriveStableId([turn.id, i.toString(), partIdx.toString()])}`; + } + } + + if (part.functionResponse && !part.functionResponse.id) { + // Orphaned response handling (search backward) + const name = part.functionResponse.name; + const prevTurn = history[i - 1]; + if (prevTurn?.content.role === 'model') { + const matchingCall = prevTurn.content.parts?.find( + (p) => + p.functionCall && + p.functionCall.name === name && + !p.functionCall.id, + ); + if (matchingCall) { + const pairedId = `synth_${name}_${deriveStableId([prevTurn.id, (i - 1).toString(), partIdx.toString()])}`; + matchingCall.functionCall!.id = pairedId; + part.functionResponse.id = pairedId; + } + } + + if (!part.functionResponse.id) { + part.functionResponse.id = `synth_orph_${name}_${deriveStableId([turn.id, i.toString(), partIdx.toString()])}`; + } + } + } + } +} /** * Converts a PartListUnion into a normalized array of Part objects. @@ -57,37 +126,46 @@ export function convertSessionToClientHistory( } else if (msg.type === 'gemini') { const modelParts: Part[] = []; - // Add thoughts if present - if (msg.thoughts && msg.thoughts.length > 0) { - for (const thought of msg.thoughts) { - const thoughtText = thought.subject - ? `**${thought.subject}** ${thought.description}` - : thought.description; - modelParts.push({ - text: thoughtText, - thought: true, - } as Part); - } - } - - const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0; + const contentParts = msg.content ? ensurePartArray(msg.content) : []; + const hasCallsInContent = contentParts.some((p) => !!p.functionCall); + const hasThoughtsInContent = contentParts.some((p) => p.thought); - if (hasToolCalls) { - // Preserve original parts to maintain multimodal integrity - if (msg.content) { - modelParts.push(...ensurePartArray(msg.content)); + if (hasCallsInContent || hasThoughtsInContent) { + // Modern session: content is the source of truth for all parts + modelParts.push(...contentParts); + } else { + // Legacy session: rebuild from components + // 1. Add thoughts from metadata if present + if (msg.thoughts && msg.thoughts.length > 0) { + for (const thought of msg.thoughts) { + const thoughtText = thought.subject + ? `**${thought.subject}** ${thought.description}` + : thought.description; + modelParts.push({ + text: thoughtText, + thought: true, + } as Part); + } } - for (const toolCall of msg.toolCalls!) { - modelParts.push({ - functionCall: { - name: toolCall.name, - args: toolCall.args, - ...(toolCall.id && { id: toolCall.id }), - }, - }); + // 2. Add content (usually just text in legacy) + modelParts.push(...contentParts); + + // 3. Add tool calls from metadata + if (msg.toolCalls && msg.toolCalls.length > 0) { + for (const toolCall of msg.toolCalls) { + modelParts.push({ + functionCall: { + id: toolCall.id, + name: toolCall.name, + args: toolCall.args, + }, + }); + } } + } + if (modelParts.length > 0) { clientHistory.push({ id: msg.id, content: { @@ -96,54 +174,43 @@ export function convertSessionToClientHistory( }, }); - const functionResponseParts: Part[] = []; - for (const toolCall of msg.toolCalls!) { - if (toolCall.result) { - let responseData: Part; - - if (typeof toolCall.result === 'string') { - responseData = { - functionResponse: { - id: toolCall.id, - name: toolCall.name, - response: { - output: toolCall.result, + // 4. Generate tool response turns + if (msg.toolCalls && msg.toolCalls.length > 0) { + const functionResponseParts: Part[] = []; + for (const toolCall of msg.toolCalls) { + if (toolCall.result) { + let responseData: Part; + + if (typeof toolCall.result === 'string') { + responseData = { + functionResponse: { + id: toolCall.id, + name: toolCall.name, + response: { + output: toolCall.result, + }, }, - }, - }; - } else if (Array.isArray(toolCall.result)) { - functionResponseParts.push(...ensurePartArray(toolCall.result)); - continue; - } else { - responseData = toolCall.result; + }; + } else if (Array.isArray(toolCall.result)) { + functionResponseParts.push(...ensurePartArray(toolCall.result)); + continue; + } else { + responseData = toolCall.result; + } + + functionResponseParts.push(responseData); } - - functionResponseParts.push(responseData); } - } - if (functionResponseParts.length > 0) { - clientHistory.push({ - id: `${msg.id}_response`, - content: { - role: 'user', - parts: functionResponseParts, - }, - }); - } - } else { - if (msg.content) { - modelParts.push(...ensurePartArray(msg.content)); - } - - if (modelParts.length > 0) { - clientHistory.push({ - id: msg.id, - content: { - role: 'model', - parts: modelParts, - }, - }); + if (functionResponseParts.length > 0) { + clientHistory.push({ + id: `${msg.id}_response`, + content: { + role: 'user', + parts: functionResponseParts, + }, + }); + } } } }