From 665b5ca6c783f5883cc15647e52382b01e89c770 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 14 May 2026 23:13:01 +0000 Subject: [PATCH] fix issue with missing function responses --- packages/core/src/context/contextManager.ts | 13 ++- .../core/src/context/graph/mapper.test.ts | 110 ++++++++++++++++++ .../context/utils/adaptiveTokenCalculator.ts | 4 +- .../core/src/utils/historyHardening.test.ts | 78 +++++++++++++ 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/context/graph/mapper.test.ts diff --git a/packages/core/src/context/contextManager.ts b/packages/core/src/context/contextManager.ts index 82bc22aaa6..116de68cfc 100644 --- a/packages/core/src/context/contextManager.ts +++ b/packages/core/src/context/contextManager.ts @@ -386,10 +386,21 @@ export class ContextManager { this.tracer.logEvent('ContextManager', 'Finished rendering'); - const hardenedHistory = hardenHistory(renderedHistory, { + // 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, { sentinels: this.sidecar.sentinels, }); + if (pendingRequest) { + hardenedHistory.pop(); // Remove the pending request from the final output + } + const apiHistory = hardenedHistory.map((h) => h.content); if (header) { apiHistory.unshift(header); diff --git a/packages/core/src/context/graph/mapper.test.ts b/packages/core/src/context/graph/mapper.test.ts new file mode 100644 index 0000000000..fa2640dac1 --- /dev/null +++ b/packages/core/src/context/graph/mapper.test.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { ContextGraphMapper } from './mapper.js'; +import type { HistoryTurn } from '../../core/agentChatHistory.js'; +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 originalHistory: HistoryTurn[] = [ + { + id: 'system_prompt_id', + content: { + role: 'user', + parts: [{ text: '\nSystem Prompt here' }], + }, + }, + { + id: 'user_turn_1', + content: { + role: 'user', + parts: [{ text: 'Please read file A and file B at the same time.' }], + }, + }, + { + id: 'model_turn_1', + content: { + role: 'model', + parts: [ + { text: 'I will read both files concurrently.' }, + { + functionCall: { + id: 'call_A', + name: 'read_file', + args: { path: 'A.txt' }, + }, + thoughtSignature: 'synthetic_sig_xyz', + }, + { + functionCall: { + id: 'call_B', + name: 'read_file', + args: { path: 'B.txt' }, + }, + }, + ], + }, + }, + // Note: GeminiChat records these as separate sequential user turns initially + { + id: 'tool_resp_B_id', + content: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_B', + name: 'read_file', + response: { content: 'File B' }, + }, + }, + ], + }, + }, + { + id: 'tool_resp_A_id', + content: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_A', + name: 'read_file', + response: { content: 'File A' }, + }, + }, + ], + }, + }, + ]; + + // 2. We harden the original history first. The core agent loop feeds the hardener the pure history. + // We want our round-tripped history to match what the hardener WOULD have produced natively. + const hardenedOriginal = hardenHistory(originalHistory); + + // 3. Translate History -> Graph + const mapper = new ContextGraphMapper(); + // Simulate the HistoryObserver capturing the push + const nodes = mapper.applyEvent({ + type: 'SYNC_FULL', + payload: originalHistory, + }); + + // 4. Translate Graph -> History + const reconstructedHistory = mapper.fromGraph(nodes); + + // 5. Harden the reconstructed history (as the ContextManager does before sending to API) + const hardenedReconstructed = hardenHistory(reconstructedHistory); + + // 6. Assert Absolute Equality + // The round-trip through the Context Graph and Hardener must exactly equal + // the original history put through the Hardener. + expect(hardenedReconstructed).toEqual(hardenedOriginal); + }); +}); diff --git a/packages/core/src/context/utils/adaptiveTokenCalculator.ts b/packages/core/src/context/utils/adaptiveTokenCalculator.ts index 69be8a202f..8204116409 100644 --- a/packages/core/src/context/utils/adaptiveTokenCalculator.ts +++ b/packages/core/src/context/utils/adaptiveTokenCalculator.ts @@ -42,7 +42,9 @@ export class AdaptiveTokenCalculator implements AdvancedTokenCalculator { private handleGroundTruth(actualTokens: number, promptBaseUnits: number) { if (promptBaseUnits <= 0) return; - const overheadTokens = this.getOverheadTokens ? this.getOverheadTokens() : 0; + const overheadTokens = this.getOverheadTokens + ? this.getOverheadTokens() + : 0; // The Gemini API token count includes the static overhead (system instruction + tools) // and the dynamic chat history (which we measure as promptBaseUnits). diff --git a/packages/core/src/utils/historyHardening.test.ts b/packages/core/src/utils/historyHardening.test.ts index cb4949481e..6c691bd3fe 100644 --- a/packages/core/src/utils/historyHardening.test.ts +++ b/packages/core/src/utils/historyHardening.test.ts @@ -134,6 +134,84 @@ describe('hardenHistory', () => { expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_resp'])); }); + it('should successfully match parallel tool calls and responses even if responses are originally split across separate user turns', () => { + const history: HistoryTurn[] = [ + { id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } }, + { + id: '2', + content: { + role: 'model', + parts: [ + { + functionCall: { id: 'call_1', name: 'toolA', args: {} }, + thoughtSignature: 'sig', + }, + { functionCall: { id: 'call_2', name: 'toolB', args: {} } }, + ], + }, + }, + // Responses arrive as separate user turns + { + id: '3', + content: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'toolA', + response: { ok: true }, + }, + }, + ], + }, + }, + { + id: '4', + content: { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_2', + name: 'toolB', + response: { ok: true }, + }, + }, + ], + }, + }, + ]; + + // The hardener should coalesce Turn 3 and Turn 4 *before* it tries to pair them with Turn 2. + // Otherwise, it would look at Turn 3, see 'call_2' is missing, inject a sentinel for 'call_2', + // and then look at Turn 4 and consider 'call_2' to be orphaned. + const hardened = hardenHistory(history); + + // Total turns: User(1), Model(2), User(3+4 merged) + expect(hardened.length).toBe(3); + + const userResponseTurn = hardened[2]; + expect(userResponseTurn.content.role).toBe('user'); + expect(userResponseTurn.content.parts).toHaveLength(2); + + // Verify no sentinels were injected and original responses were preserved + expect(userResponseTurn.content.parts![0].functionResponse?.id).toBe( + 'call_1', + ); + expect(userResponseTurn.content.parts![1].functionResponse?.id).toBe( + 'call_2', + ); + + // Ensure no error properties exist + expect( + userResponseTurn.content.parts![0].functionResponse?.response, + ).toEqual({ ok: true }); + expect( + userResponseTurn.content.parts![1].functionResponse?.response, + ).toEqual({ ok: true }); + }); + it('should drop orphaned functionResponses', () => { const history: HistoryTurn[] = [ { id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },