diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e445247b58e..8e59d07decf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1166,6 +1166,9 @@ export class Config { private sessionService: SessionService | undefined = undefined; private chatRecordingService: ChatRecordingService | undefined = undefined; private fileCheckpointingEnabled: boolean; + // Object (not primitive) so sub-agents via Object.create(parentConfig) + // share the same budget instance through prototype lookup. + private readonly toolResultBudget = { bytesWritten: 0 }; private fileHistoryService: FileHistoryService | undefined; private readonly proxy: string | undefined; private cwd: string; @@ -2269,6 +2272,7 @@ export class Config { // constructed via Object.create — those should clear their own // cache, not the parent's. this.getFileReadCache().clear(); + this.toolResultBudget.bytesWritten = 0; this.getMemoryPressureMonitor()?.resetForNewSession(); this.fileHistoryService = undefined; refreshSessionContext(this.sessionId); @@ -4245,6 +4249,14 @@ export class Config { return this.toolOutputBatchBudget; } + trackToolResultBytes(n: number): void { + this.toolResultBudget.bytesWritten += n; + } + + getToolResultBytesWritten(): number { + return this.toolResultBudget.bytesWritten; + } + getOutputFormat(): OutputFormat { return this.outputFormat; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index b8580ba3062..e7bf967bee4 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -320,6 +320,10 @@ export class Storage { return targetDir; } + getToolResultsDir(): string { + return path.join(this.getProjectTempDir(), 'tool-results'); + } + ensureProjectTempDirExists(): void { fs.mkdirSync(this.getProjectTempDir(), { recursive: true }); } diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 9bf6fe4baa5..f4bdc912f4d 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -78,6 +78,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -308,6 +309,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -553,6 +555,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -778,6 +781,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -1003,6 +1007,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -1228,6 +1233,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -1453,6 +1459,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -1678,6 +1685,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -1903,6 +1911,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -2128,6 +2137,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -2376,6 +2386,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -2687,6 +2698,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -2935,6 +2947,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -3242,6 +3255,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications @@ -3467,6 +3481,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 146d097c990..447b619af88 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -17,6 +17,8 @@ import process from 'node:process'; // Config import { ApprovalMode, type Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { cleanupOldToolResults } from '../utils/toolResultCleanup.js'; +import { Storage } from '../config/storage.js'; import { recordStartupEvent } from '../utils/startupEventSink.js'; import { microcompactHistory, @@ -311,6 +313,12 @@ export class GeminiClient { } this.initializedSessionId = sessionId; + + // Clean up stale tool result files from previous sessions (fire-and-forget) + void cleanupOldToolResults( + Storage.getGlobalTempDir(), + 24 * 60 * 60 * 1000, + ); } /** @@ -642,6 +650,11 @@ export class GeminiClient { // pointing at content the model can no longer retrieve. debugLogger.debug('[FILE_READ_CACHE] clear after resetChat'); this.config.getFileReadCache().clear(); + // Clean up old tool result overflow files on /clear + void cleanupOldToolResults( + Storage.getGlobalTempDir(), + 24 * 60 * 60 * 1000, + ); this.config.getBaseLlmClient().clearPerModelGeneratorCache(); // Abort any in-flight auto-memory recall so the stale controller // does not leak into the next session. diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index f5c6b393b88..107ef972723 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -724,7 +724,10 @@ describe('CoreToolScheduler', () => { }), storage: { getProjectTempDir: () => '/tmp', + getToolResultsDir: () => '/tmp/tool-results', }, + getToolResultBytesWritten: () => 0, + trackToolResultBytes: vi.fn(), getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -3030,7 +3033,10 @@ describe('CoreToolScheduler', () => { }), storage: { getProjectTempDir: () => '/tmp', + getToolResultsDir: () => '/tmp/tool-results', }, + getToolResultBytesWritten: () => 0, + trackToolResultBytes: vi.fn(), getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -3119,7 +3125,10 @@ describe('CoreToolScheduler', () => { }), storage: { getProjectTempDir: () => '/tmp', + getToolResultsDir: () => '/tmp/tool-results', }, + getToolResultBytesWritten: () => 0, + trackToolResultBytes: vi.fn(), getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, @@ -8103,7 +8112,10 @@ describe('Fire hook functions integration', () => { }), storage: { getProjectTempDir: () => '/tmp', + getToolResultsDir: () => '/tmp/tool-results', }, + getToolResultBytesWritten: () => 0, + trackToolResultBytes: vi.fn(), getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 2afe5c11fb8..53fb3397206 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -67,6 +67,10 @@ import type { MemoryPressureMonitor } from '../services/memoryPressureMonitor.js import { CONCURRENCY_SAFE_KINDS } from '../tools/tools.js'; import { isShellCommandReadOnly } from '../utils/shellReadOnlyChecker.js'; import { stripShellWrapper } from '../utils/shell-utils.js'; +import { + isAlreadyTruncated, + persistAndTruncateToolResult, +} from '../utils/truncation.js'; import { injectPermissionRulesIfMissing, persistPermissionOutcome, @@ -129,6 +133,41 @@ import { import { safeJsonStringify } from '../utils/safeJsonStringify.js'; import { acquireSleepInhibitor } from '../services/sleepInhibitor.js'; +// Gap between the persistence gate and per-tool truncation thresholds. +// Tools that self-truncate to ~25K add headers bringing output to ~25.4K; +// the headroom ensures the gate only fires for genuinely un-truncated output +// and must exceed the stub size (~2.3K) to avoid cascading re-persistence. +const GATE_HEADROOM = 3000; +const GATE_EXEMPT_TOOLS = new Set(['read_file']); + + +function extractTextFromPartListUnion(c: PartListUnion): string { + if (typeof c === 'string') return c; + if (Array.isArray(c)) { + const parts = toParts(c); + return parts.map((p) => p.text ?? '').join('\n'); + } + if (c && typeof c === 'object') { + if ('text' in c) { + const text = (c as { text?: string }).text; + if (typeof text === 'string') return text; + } + if ('functionResponse' in c) { + const fr = ( + c as { + functionResponse?: { response?: Record }; + } + ).functionResponse; + const resp = fr?.response; + if (resp) { + if (typeof resp['output'] === 'string') return resp['output']; + if (typeof resp['content'] === 'string') return resp['content']; + } + } + } + return ''; +} + const TOOL_FAILURE_KIND_ATTRIBUTE = 'tool.failure_kind'; const TOOL_FAILURE_KIND_PRE_HOOK_BLOCKED = 'pre_hook_blocked'; const TOOL_FAILURE_KIND_POST_HOOK_STOPPED = 'post_hook_stopped'; @@ -3121,6 +3160,8 @@ export class CoreToolScheduler { if (toolResult.error === undefined) { let content = toolResult.llmContent; + let contentLength: number | undefined = + typeof content === 'string' ? content.length : undefined; // Deferred metadata: PostToolUse hook context and skill/rule reminders // are captured here and appended AFTER the model-facing truncation @@ -3198,6 +3239,14 @@ export class CoreToolScheduler { } } + // Universal post-execution truncation gate — persists oversized + // tool results to disk before system-reminders are appended. + content = await this.maybePersistLargeToolResult( + callId, + toolName, + content, + ); + // Collect filesystem paths the tool just touched. Different tools // use different parameter names: `file_path` (read/edit/write), // `path` (ls, glob), `filePath` (grep, lsp), and `paths` @@ -3420,9 +3469,9 @@ export class CoreToolScheduler { ); } - // Computed AFTER truncation so it reflects the model-facing length, + // Recompute AFTER truncation so it reflects the model-facing length, // consistent with the batch-offload path (which also updates it). - const contentLength = + contentLength = typeof content === 'string' ? content.length : undefined; const response = convertToFunctionResponse(toolName, callId, content); @@ -3480,6 +3529,22 @@ export class CoreToolScheduler { } } + // Truncate oversized error messages (e.g., large stderr) + const errorGateThreshold = + this.config.getTruncateToolOutputThreshold() + GATE_HEADROOM; + if ( + errorMessage.length > errorGateThreshold && + !isAlreadyTruncated(errorMessage) + ) { + const persistResult = await persistAndTruncateToolResult( + callId, + toolName, + errorMessage, + this.config, + ); + errorMessage = persistResult.content; + } + addToolResultAttributes( this.config, span, @@ -3733,6 +3798,47 @@ export class CoreToolScheduler { } } + private async maybePersistLargeToolResult( + callId: string, + toolName: string, + content: PartListUnion, + ): Promise { + if (GATE_EXEMPT_TOOLS.has(toolName)) return content; + + const text = extractTextFromPartListUnion(content); + if (!text || isAlreadyTruncated(text)) return content; + + const gateThreshold = + this.config.getTruncateToolOutputThreshold() + GATE_HEADROOM; + if (text.length <= gateThreshold) return content; + + const result = await persistAndTruncateToolResult( + callId, + toolName, + text, + this.config, + ); + + if (result.outputFile) { + debugLogger.debug( + `Persisted ${toolName} result (${result.bytesWritten} bytes) to ${result.outputFile}`, + ); + } + + // Preserve non-text parts (media) when content is Part[] + if (Array.isArray(content)) { + const mediaParts = content.filter( + (p) => + (p as { inlineData?: unknown }).inlineData || + (p as { fileData?: unknown }).fileData, + ); + const stubPart: Part = { text: result.content }; + return mediaParts.length > 0 ? [stubPart, ...mediaParts] : [stubPart]; + } + + return result.content; + } + /** * Records tool results to the chat recording service. * This captures both the raw Content (for API reconstruction) and @@ -3826,6 +3932,7 @@ export class CoreToolScheduler { if (typeof output !== 'string') return null; if (fr.parts && fr.parts.length > 0) return null; // media present if (output.startsWith(TOOL_OUTPUT_TRUNCATED_PREFIX)) return null; // already + if (output.startsWith('')) return null; let truncated: { content: string; outputFile?: string }; try { diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index fba142a3d42..12b2ae68407 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -214,6 +214,7 @@ When requested to perform tasks like fixing bugs, adding features, refactoring, **Key Principle:** Start with a reasonable plan based on available information, then adapt as you learn. Users prefer seeing progress quickly rather than waiting for perfect understanding. - Tool results and user messages may include tags. tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result. +- When you see a tag in a tool result, the full output was saved to disk because it was too large. Use the read_file tool to access the complete content if the preview is insufficient. ## New Applications diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index fcb8ca8d017..f33880c9815 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -786,6 +786,31 @@ export class ToolOutputTruncatedEvent implements BaseTelemetryEvent { } } +export class ToolResultPersistedEvent implements BaseTelemetryEvent { + readonly eventName = 'tool_result_persisted'; + readonly 'event.timestamp' = new Date().toISOString(); + 'event.name': string; + tool_name: string; + bytes_written: number; + output_file: string; + prompt_id: string; + + constructor( + prompt_id: string, + details: { + toolName: string; + bytesWritten: number; + outputFile: string; + }, + ) { + this['event.name'] = this.eventName; + this.prompt_id = prompt_id; + this.tool_name = details.toolName; + this.bytes_written = details.bytesWritten; + this.output_file = details.outputFile; + } +} + export class ExtensionUninstallEvent implements BaseTelemetryEvent { 'event.name': 'extension_uninstall'; 'event.timestamp': string; @@ -1032,6 +1057,7 @@ export type TelemetryEvent = | ExtensionInstallEvent | ExtensionUninstallEvent | ToolOutputTruncatedEvent + | ToolResultPersistedEvent | ModelSlashCommandEvent | AuthEvent | HookCallEvent diff --git a/packages/core/src/utils/toolResultCleanup.ts b/packages/core/src/utils/toolResultCleanup.ts new file mode 100644 index 00000000000..cacb14b1977 --- /dev/null +++ b/packages/core/src/utils/toolResultCleanup.ts @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { createDebugLogger } from './debugLogger.js'; + +const debugLogger = createDebugLogger('TOOL_RESULT_CLEANUP'); + +export interface CleanupResult { + filesDeleted: number; + bytesFreed: number; + errors: number; +} + +export async function cleanupOldToolResults( + globalTempDir: string, + maxAgeMs: number, +): Promise { + const result: CleanupResult = { filesDeleted: 0, bytesFreed: 0, errors: 0 }; + + let projectDirs: string[]; + try { + projectDirs = await fs.readdir(globalTempDir); + } catch (error) { + debugLogger.debug(`Cannot read globalTempDir ${globalTempDir}:`, error); + return result; + } + + const now = Date.now(); + + for (const projectHash of projectDirs) { + const projectDir = path.join(globalTempDir, projectHash); + + let projectStat; + try { + projectStat = await fs.lstat(projectDir); + } catch (error) { + debugLogger.debug(`Cannot stat ${projectDir}:`, error); + result.errors++; + continue; + } + if (!projectStat.isDirectory()) continue; + + await cleanupDirectory( + path.join(projectDir, 'tool-results'), + now, + maxAgeMs, + result, + ); + await cleanupLegacyOutputFiles(projectDir, now, maxAgeMs, result); + } + + if (result.filesDeleted > 0) { + debugLogger.debug( + `Cleaned up ${result.filesDeleted} tool result files, freed ${Math.round(result.bytesFreed / 1024)} KB`, + ); + } + if (result.errors > 0) { + debugLogger.warn( + `${result.errors} errors during tool result cleanup`, + ); + } + + return result; +} + +async function cleanupDirectory( + dir: string, + now: number, + maxAgeMs: number, + result: CleanupResult, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch (error) { + debugLogger.debug(`Cannot read directory ${dir}:`, error); + return; + } + + for (const entry of entries) { + await tryDeleteIfOld(path.join(dir, entry), now, maxAgeMs, result); + } +} + +async function cleanupLegacyOutputFiles( + dir: string, + now: number, + maxAgeMs: number, + result: CleanupResult, +): Promise { + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch (error) { + debugLogger.debug(`Cannot read directory ${dir}:`, error); + return; + } + + for (const entry of entries) { + if (!entry.endsWith('.output')) continue; + await tryDeleteIfOld(path.join(dir, entry), now, maxAgeMs, result); + } +} + +async function tryDeleteIfOld( + filePath: string, + now: number, + maxAgeMs: number, + result: CleanupResult, +): Promise { + try { + const stat = await fs.lstat(filePath); + if (!stat.isFile()) return; + if (now - stat.mtimeMs < maxAgeMs) return; + + await fs.unlink(filePath); + result.filesDeleted++; + result.bytesFreed += stat.size; + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return; + } + debugLogger.debug(`Failed to clean up ${filePath}:`, error); + result.errors++; + } +} diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index e7dfaeeb749..4d9b27959da 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -10,9 +10,17 @@ import * as crypto from 'node:crypto'; import type { Part, PartListUnion } from '@google/genai'; import { ReadFileTool } from '../tools/read-file.js'; import type { Config } from '../config/config.js'; +import { atomicWriteFile } from './atomicFileWrite.js'; +import { createDebugLogger } from './debugLogger.js'; import { logToolOutputTruncated } from '../telemetry/loggers.js'; import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; +const debugLogger = createDebugLogger('TRUNCATION'); + +const PREVIEW_SIZE_CHARS = 2000; +const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB +const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500MB + /** * Stable prefix every truncated tool output starts with. Used as an * idempotency sentinel so content that was already truncated (by a tool's own @@ -163,7 +171,11 @@ ${truncatedContent}`; content: wrappedMessage, outputFile, }; - } catch (_error) { + } catch (error) { + debugLogger.warn( + `Failed to save truncated output to ${outputFile}:`, + error, + ); return { content: truncatedContent + `\n[Note: Could not save full output to file]`, @@ -320,3 +332,151 @@ export async function truncateLlmContent( outputFile: result.outputFile, }; } + +export function isAlreadyTruncated(content: string): boolean { + return ( + content.includes('... [CONTENT TRUNCATED] ...') || + content.startsWith('') + ); +} + +function generatePreview(content: string): string { + let text = + content.length <= PREVIEW_SIZE_CHARS + ? content + : (() => { + const slice = content.slice(0, PREVIEW_SIZE_CHARS); + const lastNewline = slice.lastIndexOf('\n'); + return ( + (lastNewline > 0 ? slice.slice(0, lastNewline) : slice) + '\n...' + ); + })(); + // Escape tags that could reshape the model-visible structure + text = text + .replace(/<\/?persisted-output>/g, (m) => `<${m.slice(1, -1)}>`) + .replace(/<\/?system-reminder>/g, (m) => `<${m.slice(1, -1)}>`); + return text; +} + +export interface PersistResult { + content: string; + outputFile?: string; + bytesWritten: number; +} + +export async function persistAndTruncateToolResult( + callId: string, + toolName: string, + content: string, + config: Config, +): Promise { + const byteSize = Buffer.byteLength(content, 'utf-8'); + + // Hard size cap — content already in memory, just skip disk persistence + if (byteSize > MAX_FILE_SIZE_BYTES) { + debugLogger.warn( + `Tool result for ${toolName} exceeds ${MAX_FILE_SIZE_BYTES} bytes (${byteSize}), skipping disk persistence`, + ); + return { + content: buildStub(content, byteSize, '(file too large to persist)'), + bytesWritten: 0, + }; + } + + // Session budget check — reserve bytes synchronously before async I/O + // to prevent parallel tool calls from all passing the check simultaneously. + const budgetUsed = config.getToolResultBytesWritten(); + if (budgetUsed + byteSize > MAX_SESSION_BYTES) { + debugLogger.warn( + `Session tool result budget exhausted (${budgetUsed} + ${byteSize} > ${MAX_SESSION_BYTES}), skipping disk persistence`, + ); + return { + content: buildStub( + content, + byteSize, + '(session disk budget exhausted)', + ), + bytesWritten: 0, + }; + } + // Reserve budget before async write; rollback on failure below. + config.trackToolResultBytes(byteSize); + + // eslint-disable-next-line no-control-regex + const safeCallId = path.basename(callId).replace(/\x00/g, '_'); + if (!safeCallId || safeCallId === '.' || safeCallId === '..') { + debugLogger.warn(`Invalid callId for disk persistence: ${JSON.stringify(callId)}`); + config.trackToolResultBytes(-byteSize); + return { + content: buildStub(content, byteSize, '(invalid callId)'), + bytesWritten: 0, + }; + } + const toolResultsDir = config.storage.getToolResultsDir(); + const outputFile = path.join(toolResultsDir, `${safeCallId}.txt`); + + try { + await fs.mkdir(toolResultsDir, { recursive: true }); + await atomicWriteFile(outputFile, content, { + mode: 0o600, + forceMode: true, + noFollow: true, + flush: false, + }); + + return { + content: buildStub(content, byteSize, outputFile), + outputFile, + bytesWritten: byteSize, + }; + } catch (error) { + // Rollback budget reservation on write failure + config.trackToolResultBytes(-byteSize); + debugLogger.warn( + `Failed to persist tool result to ${outputFile}:`, + error, + ); + try { + const fallback = await truncateAndSaveToFile( + content, + `${toolName}_${crypto.randomBytes(6).toString('hex')}`, + config.storage.getProjectTempDir(), + config.getTruncateToolOutputThreshold(), + config.getTruncateToolOutputLines(), + ); + return { content: fallback.content, bytesWritten: 0 }; + } catch (fallbackError) { + debugLogger.warn('Fallback truncation also failed:', fallbackError); + return { + content: buildStub(content, byteSize, '(disk persistence unavailable)'), + bytesWritten: 0, + }; + } + } +} + +function buildStub( + content: string, + byteSize: number, + filePathOrNote: string, +): string { + const preview = generatePreview(content); + const sizeKb = Math.round(byteSize / 1024); + const isFilePath = path.isAbsolute(filePathOrNote); + + if (isFilePath) { + return ` +Output too large (${sizeKb} KB). Full output saved to: ${filePathOrNote} +Note: this file may be cleaned up after 24 hours. +To read the complete output, use the ${ReadFileTool.Name} tool with the absolute file path above. + +Preview (up to ${PREVIEW_SIZE_CHARS} chars): +${preview} +`; + } + + return `Output too large (${sizeKb} KB). ${filePathOrNote} + +Preview (up to ${PREVIEW_SIZE_CHARS} chars): +${preview}`; +}