From ddb7b6589758c5e2248702b2f2b29f2bfbb84b71 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Mar 2026 01:18:41 +0000 Subject: [PATCH] feat(core): Support auto-distillation for tool output. --- .../autoDistillation.test.tsx.snap | 72 +++++++ .../autoDistillation.test.tsx | 64 ++++++ packages/cli/src/test-utils/AppRig.tsx | 86 ++++++++ .../fixtures/auto-distillation.responses | 3 + packages/core/src/core/contentGenerator.ts | 2 + .../core/src/core/fakeContentGenerator.ts | 7 + .../core/src/core/loggingContentGenerator.ts | 4 + .../src/core/recordingContentGenerator.ts | 4 + packages/core/src/index.ts | 1 + .../core/src/scheduler/tool-executor.test.ts | 37 +++- packages/core/src/scheduler/tool-executor.ts | 101 +-------- .../src/services/toolDistillationService.ts | 203 ++++++++++++++++++ packages/core/src/tools/web-fetch.ts | 40 +--- 13 files changed, 496 insertions(+), 128 deletions(-) create mode 100644 packages/cli/src/integration-tests/__snapshots__/autoDistillation.test.tsx.snap create mode 100644 packages/cli/src/integration-tests/autoDistillation.test.tsx create mode 100644 packages/cli/src/test-utils/fixtures/auto-distillation.responses create mode 100644 packages/core/src/services/toolDistillationService.ts diff --git a/packages/cli/src/integration-tests/__snapshots__/autoDistillation.test.tsx.snap b/packages/cli/src/integration-tests/__snapshots__/autoDistillation.test.tsx.snap new file mode 100644 index 0000000000..4667a2e306 --- /dev/null +++ b/packages/cli/src/integration-tests/__snapshots__/autoDistillation.test.tsx.snap @@ -0,0 +1,72 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Auto-distillation Integration > should truncate and summarize massive tool outputs, and we should golden the chat history 1`] = ` +[ + { + "parts": [ + { + "text": "", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "Fetch the massive file.", + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "I will now fetch the data.", + }, + { + "functionCall": { + "args": { + "command": "cat large.txt", + }, + "id": "", + "name": "run_shell_command", + }, + }, + ], + "role": "model", + }, + { + "parts": [ + { + "functionResponse": { + "id": "", + "name": "run_shell_command", + "response": { + "output": "Output too large. Showing first 10 and last 40 characters. For full output see: /.gemini/tmp//tool-outputs/session-/run_shell_command__.txt +Output: ca + +... [40 characters omitted] ... + +Exit Code: 1 +Process Group PGID: + +--- Structural Map of Truncated Content --- +- Line 1: Header +- Lines 2-5000: User data +- Line 5001: Footer", + }, + }, + }, + ], + "role": "user", + }, + { + "parts": [ + { + "text": "I got the summarized output. Task complete.", + }, + ], + "role": "model", + }, +] +`; diff --git a/packages/cli/src/integration-tests/autoDistillation.test.tsx b/packages/cli/src/integration-tests/autoDistillation.test.tsx new file mode 100644 index 0000000000..7d793fb201 --- /dev/null +++ b/packages/cli/src/integration-tests/autoDistillation.test.tsx @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { AppRig } from '../test-utils/AppRig.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PolicyDecision } from '@google/gemini-cli-core'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe('Auto-distillation Integration', () => { + let rig: AppRig | undefined; + + afterEach(async () => { + if (rig) { + await rig.unmount(); + } + vi.restoreAllMocks(); + }); + + it('should truncate and summarize massive tool outputs, and we should golden the chat history', async () => { + const fakeResponsesPath = path.join( + __dirname, + '../test-utils/fixtures/auto-distillation.responses', + ); + rig = new AppRig({ + fakeResponsesPath, + }); + + await rig.initialize(); + + const config = rig.getConfig(); + // 50 chars threshold. > 75 chars triggers summarization + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(50); + + rig.setToolPolicy('run_shell_command', PolicyDecision.ASK_USER); + + rig.setMockCommands([ + { + command: /cat large.txt/, + result: { + output: 'A'.repeat(100), + exitCode: 0, + }, + }, + ]); + + rig.render(); + await rig.waitForIdle(); + + await rig.sendMessage('Fetch the massive file.'); + + await rig.waitForOutput('Shell'); + await rig.resolveTool('Shell'); + + await rig.waitForOutput('Task complete.'); + + expect(rig.getCuratedHistory()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index a9aea95376..8d0faeb9a9 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -30,6 +30,7 @@ import { IdeClient, debugLogger, CoreToolCallStatus, + ConsecaSafetyChecker, } from '@google/gemini-cli-core'; import { type MockShellCommand, @@ -47,6 +48,7 @@ import type { TrackedCompletedToolCall, TrackedToolCall, } from '../ui/hooks/useToolScheduler.js'; +import type { Content, GenerateContentParameters } from '@google/genai'; // Global state observer for React-based signals const sessionStateMap = new Map(); @@ -153,6 +155,7 @@ export class AppRig { private settings: LoadedSettings | undefined; private testDir: string; private sessionId: string; + private appRigId: string; private pendingConfirmations = new Map(); private breakpointTools = new Set(); @@ -168,6 +171,7 @@ export class AppRig { this.testDir = fs.mkdtempSync( path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`), ); + this.appRigId = path.basename(this.testDir).toLowerCase(); this.sessionId = `test-session-${uniqueId}`; activeRigs.set(this.sessionId, this); } @@ -738,6 +742,10 @@ export class AppRig { // Forcefully clear IdeClient singleton promise // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion (IdeClient as any).instancePromise = null; + + // Reset Conseca singleton to avoid leaking config/state across tests + ConsecaSafetyChecker.resetInstance(); + vi.clearAllMocks(); this.config = undefined; @@ -754,4 +762,82 @@ export class AppRig { } } } + + getSentRequests() { + if (!this.config) throw new Error('AppRig not initialized'); + return this.config.getContentGenerator().getSentRequests?.() || []; + } + + /** + * Helper to get the curated history (contents) sent in the most recent model request. + * This method scrubs unstable data like temp paths and IDs for deterministic goldens. + */ + getLastSentRequestContents() { + const requests = this.getSentRequests(); + if (requests.length === 0) return []; + const contents = requests[requests.length - 1].contents || []; + return this.scrubUnstableData(contents); + } + + /** + * Gets the final curated history of the active chat session. + */ + getCuratedHistory() { + if (!this.config) throw new Error('AppRig not initialized'); + const history = this.config.getGeminiClient().getChat().getHistory(true); + return this.scrubUnstableData(history); + } + + private scrubUnstableData< + T extends + | Content[] + | GenerateContentParameters['contents'] + | readonly Content[], + >(contents: T): T { + // Deeply scrub unstable data + const scrubbedString = JSON.stringify(contents) + .replace(new RegExp(this.testDir, 'g'), '') + .replace(new RegExp(this.appRigId, 'g'), '') + .replace(new RegExp(this.sessionId, 'g'), '') + .replace( + /([a-zA-Z0-9_]+)_([0-9]{13})_([0-9]+)\.txt/g, + '$1__.txt', + ) + .replace(/Process Group PGID: \d+/g, 'Process Group PGID: '); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const scrubbed = JSON.parse(scrubbedString) as T; + + if (Array.isArray(scrubbed) && scrubbed.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const firstItem = scrubbed[0] as Content; + if (firstItem.parts?.[0]?.text?.includes('')) { + firstItem.parts[0].text = ''; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + for (const content of scrubbed as Content[]) { + if (content.parts) { + for (const part of content.parts) { + if (part.functionCall) { + part.functionCall.id = ''; + } + if (part.functionResponse) { + part.functionResponse.id = ''; + if ( + part.functionResponse.response !== null && + typeof part.functionResponse.response === 'object' && + 'original_output_file' in part.functionResponse.response + ) { + part.functionResponse.response['original_output_file'] = + ''; + } + } + } + } + } + } + + return scrubbed; + } } diff --git a/packages/cli/src/test-utils/fixtures/auto-distillation.responses b/packages/cli/src/test-utils/fixtures/auto-distillation.responses new file mode 100644 index 0000000000..68ece6f858 --- /dev/null +++ b/packages/cli/src/test-utils/fixtures/auto-distillation.responses @@ -0,0 +1,3 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I will now fetch the data."},{"functionCall":{"name":"run_shell_command","args":{"command":"cat large.txt"}}}]},"finishReason":"STOP"}]}]} +{"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"- Line 1: Header\n- Lines 2-5000: User data\n- Line 5001: Footer"}]},"finishReason":"STOP"}]}} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I got the summarized output. Task complete."}]},"finishReason":"STOP"}]}]} diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 2ce5420335..69b054004a 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -46,6 +46,8 @@ export interface ContentGenerator { embedContent(request: EmbedContentParameters): Promise; + getSentRequests?(): GenerateContentParameters[]; + userTier?: UserTierId; userTierName?: string; diff --git a/packages/core/src/core/fakeContentGenerator.ts b/packages/core/src/core/fakeContentGenerator.ts index 9ecd75a99d..3e43c7060e 100644 --- a/packages/core/src/core/fakeContentGenerator.ts +++ b/packages/core/src/core/fakeContentGenerator.ts @@ -42,12 +42,17 @@ export type FakeResponse = // CLI argument. export class FakeContentGenerator implements ContentGenerator { private callCounter = 0; + private sentRequests: GenerateContentParameters[] = []; userTier?: UserTierId; userTierName?: string; paidTier?: GeminiUserTier; constructor(private readonly responses: FakeResponse[]) {} + getSentRequests(): GenerateContentParameters[] { + return this.sentRequests; + } + static async fromFile(filePath: string): Promise { const fileContent = await promises.readFile(filePath, 'utf-8'); const responses = fileContent @@ -84,6 +89,7 @@ export class FakeContentGenerator implements ContentGenerator { // eslint-disable-next-line @typescript-eslint/no-unused-vars role: LlmRole, ): Promise { + this.sentRequests.push(request); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return Object.setPrototypeOf( this.getNextResponse('generateContent', request), @@ -97,6 +103,7 @@ export class FakeContentGenerator implements ContentGenerator { // eslint-disable-next-line @typescript-eslint/no-unused-vars role: LlmRole, ): Promise> { + this.sentRequests.push(request); const responses = this.getNextResponse('generateContentStream', request); async function* stream() { for (const response of responses) { diff --git a/packages/core/src/core/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator.ts index 60144740c2..d483de0733 100644 --- a/packages/core/src/core/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator.ts @@ -168,6 +168,10 @@ export class LoggingContentGenerator implements ContentGenerator { return this.wrapped.paidTier; } + getSentRequests?(): GenerateContentParameters[] { + return this.wrapped.getSentRequests?.() || []; + } + private logApiRequest( contents: Content[], model: string, diff --git a/packages/core/src/core/recordingContentGenerator.ts b/packages/core/src/core/recordingContentGenerator.ts index f2193bb16d..56327d7e47 100644 --- a/packages/core/src/core/recordingContentGenerator.ts +++ b/packages/core/src/core/recordingContentGenerator.ts @@ -39,6 +39,10 @@ export class RecordingContentGenerator implements ContentGenerator { return this.realGenerator.userTierName; } + getSentRequests?(): GenerateContentParameters[] { + return this.realGenerator.getSentRequests?.() || []; + } + async generateContent( request: GenerateContentParameters, userPromptId: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e035dc4502..770431c534 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -22,6 +22,7 @@ export * from './policy/integrity.js'; export * from './billing/index.js'; export * from './confirmation-bus/types.js'; export * from './confirmation-bus/message-bus.js'; +export * from './safety/conseca/conseca.js'; // Export Commands logic export * from './commands/extensions.js'; diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index bf5b683a4a..babaa1ace4 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -335,8 +335,10 @@ describe('ToolExecutor', () => { it('should truncate large shell output', async () => { // 1. Setup Config for Truncation + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); + vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); const mockTool = new MockTool({ name: SHELL_TOOL_NAME }); const invocation = mockTool.build({}); @@ -396,8 +398,10 @@ describe('ToolExecutor', () => { it('should truncate large MCP tool output with single text Part', async () => { // 1. Setup Config for Truncation + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); + vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); const mcpToolName = 'get_big_text'; const messageBus = createMockMessageBus(); @@ -440,8 +444,9 @@ describe('ToolExecutor', () => { }); // 4. Verify Truncation Logic + const stringifiedLongText = JSON.stringify([{ text: longText }], null, 2); expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith( - longText, + stringifiedLongText, mcpToolName, 'call-mcp-trunc', expect.any(String), @@ -449,7 +454,7 @@ describe('ToolExecutor', () => { ); expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith( - longText, + stringifiedLongText, '/tmp/truncated_output.txt', 10, ); @@ -460,8 +465,9 @@ describe('ToolExecutor', () => { } }); - it('should not truncate MCP tool output with multiple Parts', async () => { + it('should truncate MCP tool output with multiple Parts', async () => { vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); + vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); const messageBus = createMockMessageBus(); const mcpTool = new DiscoveredMCPTool( @@ -501,9 +507,26 @@ describe('ToolExecutor', () => { onUpdateToolCall: vi.fn(), }); - // Should NOT have been truncated - expect(fileUtils.saveTruncatedToolOutput).not.toHaveBeenCalled(); - expect(fileUtils.formatTruncatedToolOutput).not.toHaveBeenCalled(); + const longText1 = 'This is long text that exceeds the threshold.'; + const stringifiedLongText = JSON.stringify( + [{ text: longText1 }, { text: 'second part' }], + null, + 2, + ); + + // Should HAVE been truncated now + expect(fileUtils.saveTruncatedToolOutput).toHaveBeenCalledWith( + stringifiedLongText, + 'get_big_text', + 'call-mcp-multi', + expect.any(String), + 'test-session-id', + ); + expect(fileUtils.formatTruncatedToolOutput).toHaveBeenCalledWith( + stringifiedLongText, + '/tmp/truncated_output.txt', + 10, + ); expect(result.status).toBe(CoreToolCallStatus.Success); }); @@ -668,8 +691,10 @@ describe('ToolExecutor', () => { it('should truncate large shell output even on cancellation', async () => { // 1. Setup Config for Truncation + vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); + vi.spyOn(config.storage, 'getProjectTempDir').mockReturnValue('/tmp'); const mockTool = new MockTool({ name: SHELL_TOOL_NAME }); const invocation = mockTool.build({}); diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index 1ec89fe41d..df6d9e766d 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -6,8 +6,6 @@ import { ToolErrorType, - ToolOutputTruncatedEvent, - logToolOutputTruncated, runInDevTraceSpan, type ToolCallRequestInfo, type ToolCallResponseInfo, @@ -17,14 +15,11 @@ import { type ToolLiveOutput, } from '../index.js'; import { isAbortError } from '../utils/errors.js'; -import { SHELL_TOOL_NAME } from '../tools/tool-names.js'; +import { ToolOutputDistillationService } from '../services/toolDistillationService.js'; + import { ShellToolInvocation } from '../tools/shell.js'; -import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { executeToolWithHooks } from '../core/coreToolHookTriggers.js'; -import { - saveTruncatedToolOutput, - formatTruncatedToolOutput, -} from '../utils/fileUtils.js'; + import { convertToFunctionResponse } from '../utils/generateContentResponseUtilities.js'; import { CoreToolCallStatus, @@ -195,90 +190,12 @@ export class ToolExecutor { call: ToolCall, content: PartListUnion, ): Promise<{ truncatedContent: PartListUnion; outputFile?: string }> { - const toolName = call.request.name; - const callId = call.request.callId; - let outputFile: string | undefined; - - if (typeof content === 'string' && toolName === SHELL_TOOL_NAME) { - const threshold = this.config.getTruncateToolOutputThreshold(); - - if (threshold > 0 && content.length > threshold) { - const originalContentLength = content.length; - const { outputFile: savedPath } = await saveTruncatedToolOutput( - content, - toolName, - callId, - this.config.storage.getProjectTempDir(), - this.context.promptId, - ); - outputFile = savedPath; - const truncatedContent = formatTruncatedToolOutput( - content, - outputFile, - threshold, - ); - - logToolOutputTruncated( - this.config, - new ToolOutputTruncatedEvent(call.request.prompt_id, { - toolName, - originalContentLength, - truncatedContentLength: truncatedContent.length, - threshold, - }), - ); - - return { truncatedContent, outputFile }; - } - } else if ( - Array.isArray(content) && - content.length === 1 && - 'tool' in call && - call.tool instanceof DiscoveredMCPTool - ) { - const firstPart = content[0]; - if (typeof firstPart === 'object' && typeof firstPart.text === 'string') { - const textContent = firstPart.text; - const threshold = this.config.getTruncateToolOutputThreshold(); - - if (threshold > 0 && textContent.length > threshold) { - const originalContentLength = textContent.length; - const { outputFile: savedPath } = await saveTruncatedToolOutput( - textContent, - toolName, - callId, - this.config.storage.getProjectTempDir(), - this.context.promptId, - ); - outputFile = savedPath; - const truncatedText = formatTruncatedToolOutput( - textContent, - outputFile, - threshold, - ); - - // We need to return a NEW array to avoid mutating the original toolResult if it matters, - // though here we are creating the response so it's probably fine to mutate or return new. - const truncatedContent: Part[] = [ - { ...firstPart, text: truncatedText }, - ]; - - logToolOutputTruncated( - this.config, - new ToolOutputTruncatedEvent(call.request.prompt_id, { - toolName, - originalContentLength, - truncatedContentLength: truncatedText.length, - threshold, - }), - ); - - return { truncatedContent, outputFile }; - } - } - } - - return { truncatedContent: content, outputFile }; + const distiller = new ToolOutputDistillationService( + this.config, + this.context.geminiClient, + this.context.promptId, + ); + return distiller.distill(call.request.name, call.request.callId, content); } private async createCancelledResult( diff --git a/packages/core/src/services/toolDistillationService.ts b/packages/core/src/services/toolDistillationService.ts new file mode 100644 index 0000000000..5fe430fa33 --- /dev/null +++ b/packages/core/src/services/toolDistillationService.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + LlmRole, + ToolOutputTruncatedEvent, + logToolOutputTruncated, + debugLogger, + type Config, +} from '../index.js'; +import type { PartListUnion } from '@google/genai'; +import { type GeminiClient } from '../core/client.js'; +import { DEFAULT_GEMINI_FLASH_LITE_MODEL } from '../config/models.js'; +import { + saveTruncatedToolOutput, + formatTruncatedToolOutput, +} from '../utils/fileUtils.js'; +import { + READ_FILE_TOOL_NAME, + READ_MANY_FILES_TOOL_NAME, +} from '../tools/tool-names.js'; + +export interface DistilledToolOutput { + truncatedContent: PartListUnion; + outputFile?: string; +} + +export class ToolOutputDistillationService { + constructor( + private readonly config: Config, + private readonly geminiClient: GeminiClient, + private readonly promptId: string, + ) {} + + /** + * Distills a tool's output if it exceeds configured length thresholds, preserving + * the agent's context window. This includes saving the raw output to disk, replacing + * the output with a truncated placeholder, and optionally summarizing the output + * via a secondary LLM call if the output is massively oversized. + */ + async distill( + toolName: string, + callId: string, + content: PartListUnion, + ): Promise { + // Explicitly bypass escape hatches that natively handle large outputs + if (this.isExemptFromDistillation(toolName)) { + return { truncatedContent: content }; + } + + const threshold = this.config.getTruncateToolOutputThreshold(); + if (threshold <= 0) { + return { truncatedContent: content }; + } + + const originalContentLength = this.calculateContentLength(content); + + if (originalContentLength > threshold) { + return this.performDistillation( + toolName, + callId, + content, + originalContentLength, + threshold, + ); + } + + return { truncatedContent: content }; + } + + private isExemptFromDistillation(toolName: string): boolean { + return ( + toolName === READ_FILE_TOOL_NAME || toolName === READ_MANY_FILES_TOOL_NAME + ); + } + + private calculateContentLength(content: PartListUnion): number { + if (typeof content === 'string') { + return content.length; + } + + if (Array.isArray(content)) { + return content.reduce((acc, part) => { + if ( + typeof part === 'object' && + part !== null && + 'text' in part && + typeof part.text === 'string' + ) { + return acc + part.text.length; + } + return acc; + }, 0); + } + + return 0; + } + + private stringifyContent(content: PartListUnion): string { + return typeof content === 'string' + ? content + : JSON.stringify(content, null, 2); + } + + private async performDistillation( + toolName: string, + callId: string, + content: PartListUnion, + originalContentLength: number, + threshold: number, + ): Promise { + const stringifiedContent = this.stringifyContent(content); + + // Save the raw, untruncated string to disk for human review + const { outputFile: savedPath } = await saveTruncatedToolOutput( + stringifiedContent, + toolName, + callId, + this.config.storage.getProjectTempDir(), + this.promptId, + ); + + let truncatedText = formatTruncatedToolOutput( + stringifiedContent, + savedPath, + threshold, + ); + + // If the output is massively oversized, attempt to generate a structural map + const summarizationThreshold = threshold * 1.5; + if (originalContentLength > summarizationThreshold) { + const summaryText = await this.generateStructuralMap( + toolName, + stringifiedContent, + Math.floor(summarizationThreshold), + ); + + if (summaryText) { + truncatedText += `\n\n--- Structural Map of Truncated Content ---\n${summaryText}`; + } + } + + logToolOutputTruncated( + this.config, + new ToolOutputTruncatedEvent(this.promptId, { + toolName, + originalContentLength, + truncatedContentLength: truncatedText.length, + threshold, + }), + ); + + return { + truncatedContent: + typeof content === 'string' ? truncatedText : [{ text: truncatedText }], + outputFile: savedPath, + }; + } + + /** + * Calls a fast, internal model (Flash-Lite) to provide a high-level summary + * of the truncated content's structure. + */ + private async generateStructuralMap( + toolName: string, + stringifiedContent: string, + maxPreviewLen: number, + ): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout + + const promptText = `The following output from the tool '${toolName}' is extremely large and has been truncated. Please provide a very brief, high-level structural map of its contents (e.g., key sections, JSON schema outline, or line number ranges for major components). Keep the summary under 10 lines. Do not attempt to summarize the specific data values, just the structure so another agent knows what is inside. + +Output to summarize: +${stringifiedContent.slice(0, maxPreviewLen)}...`; + + const summaryResponse = await this.geminiClient.generateContent( + { + model: DEFAULT_GEMINI_FLASH_LITE_MODEL, + overrideScope: 'internal-summarizer', + }, + [{ parts: [{ text: promptText }] }], + controller.signal, + LlmRole.MAIN, + ); + + clearTimeout(timeoutId); + + return summaryResponse.candidates?.[0]?.content?.parts?.[0]?.text; + } catch (e) { + // Fail gracefully, summarization is a progressive enhancement + debugLogger.debug( + 'Failed to generate structural map for truncated output:', + e, + ); + return undefined; + } + } +} diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 50960a9f7f..8612936cd3 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -22,7 +22,6 @@ import type { Config } from '../config/config.js'; import { ApprovalMode } from '../policy/types.js'; import { getResponseText } from '../utils/partUtils.js'; import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js'; -import { truncateString } from '../utils/textUtils.js'; import { convert } from 'html-to-text'; import { logWebFetchFallbackAttempt, @@ -37,11 +36,10 @@ import { resolveToolDeclaration } from './definitions/resolver.js'; import { LRUCache } from 'mnemonist'; const URL_FETCH_TIMEOUT_MS = 10000; -const MAX_CONTENT_LENGTH = 100000; + const MAX_EXPERIMENTAL_FETCH_SIZE = 10 * 1024 * 1024; // 10MB const USER_AGENT = 'Mozilla/5.0 (compatible; Google-Gemini-CLI/1.0; +https://github.com/google-gemini/gemini-cli)'; -const TRUNCATION_WARNING = '\n\n... [Content truncated due to size limit] ...'; // Rate limiting configuration const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute @@ -242,12 +240,6 @@ class WebFetchToolInvocation extends BaseToolInvocation< textContent = rawContent; } - textContent = truncateString( - textContent, - MAX_CONTENT_LENGTH, - TRUNCATION_WARNING, - ); - const geminiClient = this.config.getGeminiClient(); const fallbackPrompt = `The user requested the following: "${this.params.prompt}". @@ -441,7 +433,7 @@ ${textContent} }); const errorContent = `Request failed with status ${status} Headers: ${JSON.stringify(headers, null, 2)} -Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response truncated] ...')}`; +Response: ${rawResponseText}`; return { llmContent: errorContent, returnDisplay: `Failed to fetch ${url} (Status: ${status})`, @@ -454,11 +446,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun lowContentType.includes('text/plain') || lowContentType.includes('application/json') ) { - const text = truncateString( - bodyBuffer.toString('utf8'), - MAX_CONTENT_LENGTH, - TRUNCATION_WARNING, - ); + const text = bodyBuffer.toString('utf8'); return { llmContent: text, returnDisplay: `Fetched ${contentType} content from ${url}`, @@ -467,16 +455,12 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun if (lowContentType.includes('text/html')) { const html = bodyBuffer.toString('utf8'); - const textContent = truncateString( - convert(html, { - wordwrap: false, - selectors: [ - { selector: 'a', options: { ignoreHref: false, baseUrl: url } }, - ], - }), - MAX_CONTENT_LENGTH, - TRUNCATION_WARNING, - ); + const textContent = convert(html, { + wordwrap: false, + selectors: [ + { selector: 'a', options: { ignoreHref: false, baseUrl: url } }, + ], + }); return { llmContent: textContent, returnDisplay: `Fetched and converted HTML content from ${url}`, @@ -501,11 +485,7 @@ Response: ${truncateString(rawResponseText, 10000, '\n\n... [Error response trun } // Fallback for unknown types - try as text - const text = truncateString( - bodyBuffer.toString('utf8'), - MAX_CONTENT_LENGTH, - TRUNCATION_WARNING, - ); + const text = bodyBuffer.toString('utf8'); return { llmContent: text, returnDisplay: `Fetched ${contentType || 'unknown'} content from ${url}`,