From f5d5f6092009da05154d471f90ea3009bd1fd338 Mon Sep 17 00:00:00 2001 From: Aarchi Kumari Date: Sat, 21 Feb 2026 20:33:07 +0000 Subject: [PATCH 1/2] fix(core): sanitize function_response.parts before compression --- .../services/chatCompressionService.test.ts | 63 +++++++++++++++++++ .../src/services/chatCompressionService.ts | 27 +++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index 4ddd38e25cb..3aa26a09b19 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -835,4 +835,67 @@ describe('ChatCompressionService', () => { ); }); }); + + it('should strip function_response.parts (Gemini 3 multimodal data) before sending to summarizer', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'screenshot_tool', + response: { output: 'Screenshot taken.' }, + // Gemini 3 nests inlineData inside function_response.parts at runtime + parts: [ + { inlineData: { mimeType: 'image/png', data: 'base64data' } }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }, + ], + }, + { role: 'model', parts: [{ text: 'resp2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'resp4' }] }, + ]; + + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + + await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + // Verify that both generateContent calls had the nested .parts stripped + const generateContentMock = vi.mocked( + mockConfig.getBaseLlmClient().generateContent, + ); + expect(generateContentMock).toHaveBeenCalledTimes(2); + + for (const call of generateContentMock.mock.calls) { + const contents = call[0].contents; + for (const content of contents) { + for (const part of content.parts ?? []) { + if (part.functionResponse) { + // The nested .parts field should have been removed + expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (part.functionResponse as any).parts, + ).toBeUndefined(); + // But the rest of functionResponse should be preserved + expect(part.functionResponse.name).toBe('screenshot_tool'); + expect(part.functionResponse.response).toEqual({ + output: 'Screenshot taken.', + }); + } + } + } + } + }); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 5303a1a82a8..c55e531feb4 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -228,6 +228,29 @@ async function truncateHistoryToBudget( return truncatedHistory; } +/** + * Strips the Gemini-3-specific `function_response.parts` (nested multimodal + * data such as inlineData) from history entries. The compression summarizer + * model may not support this feature, so we sanitize before sending. + */ +function sanitizeHistoryForSummarizer(history: Content[]): Content[] { + return history.map((content) => ({ + ...content, + parts: content.parts?.map((part) => { + if (part.functionResponse) { + + const { parts: _nestedParts, ...cleanFR } = + part.functionResponse as // The `parts` field is added at runtime for Gemini 3 models and is + // not part of the official FunctionResponse type. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Record; + return { functionResponse: cleanFR } as typeof part; + } + return part; + }), + })); +} + export class ChatCompressionService { async compress( chat: GeminiChat, @@ -353,7 +376,7 @@ export class ChatCompressionService { const summaryResponse = await config.getBaseLlmClient().generateContent({ modelConfigKey: { model: modelStringToModelConfigAlias(model) }, contents: [ - ...historyForSummarizer, + ...sanitizeHistoryForSummarizer(historyForSummarizer), { role: 'user', parts: [ @@ -378,7 +401,7 @@ export class ChatCompressionService { .generateContent({ modelConfigKey: { model: modelStringToModelConfigAlias(model) }, contents: [ - ...historyForSummarizer, + ...sanitizeHistoryForSummarizer(historyForSummarizer), { role: 'model', parts: [{ text: summary }], From b3e5a2939f677b43c87029196f3a350f3534050b Mon Sep 17 00:00:00 2001 From: Aarchi Kumari Date: Sun, 22 Feb 2026 06:13:32 +0000 Subject: [PATCH 2/2] fix(core): improve history sanitization for summarizer function --- .../core/src/services/chatCompressionService.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index c55e531feb4..ee7c9607642 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -236,15 +236,13 @@ async function truncateHistoryToBudget( function sanitizeHistoryForSummarizer(history: Content[]): Content[] { return history.map((content) => ({ ...content, - parts: content.parts?.map((part) => { + parts: (content.parts ?? []).map((part) => { if (part.functionResponse) { - const { parts: _nestedParts, ...cleanFR } = - part.functionResponse as // The `parts` field is added at runtime for Gemini 3 models and is - // not part of the official FunctionResponse type. + part.functionResponse as // not part of the official FunctionResponse type. // The `parts` field is added at runtime for Gemini 3 models and is // eslint-disable-next-line @typescript-eslint/no-explicit-any Record; - return { functionResponse: cleanFR } as typeof part; + return { ...part, functionResponse: cleanFR }; } return part; }), @@ -373,10 +371,12 @@ export class ChatCompressionService { ? 'A previous exists in the history. You MUST integrate all still-relevant information from that snapshot into the new one, updating it with the more recent events. Do not lose established constraints or critical knowledge.' : 'Generate a new based on the provided history.'; + const sanitizedHistory = sanitizeHistoryForSummarizer(historyForSummarizer); + const summaryResponse = await config.getBaseLlmClient().generateContent({ modelConfigKey: { model: modelStringToModelConfigAlias(model) }, contents: [ - ...sanitizeHistoryForSummarizer(historyForSummarizer), + ...sanitizedHistory, { role: 'user', parts: [ @@ -401,7 +401,7 @@ export class ChatCompressionService { .generateContent({ modelConfigKey: { model: modelStringToModelConfigAlias(model) }, contents: [ - ...sanitizeHistoryForSummarizer(historyForSummarizer), + ...sanitizedHistory, { role: 'model', parts: [{ text: summary }],