feat(core): persist oversized tool results to disk (#4095 Phase 4) (#5042)

* feat(core): persist oversized tool results to disk (#4095 Phase 4)

Large tool outputs (>28K chars) are now saved to disk as
tool-results/<callId>.txt and replaced with a 2KB preview stub
in the LLM context, preventing OOM and context pollution.

Key mechanics:
- Triple-skip gate: read_file exempt → already-truncated skip → threshold+3K headroom
- Budget: 50MB per-file cap, 500MB per-session cumulative (Buffer.byteLength)
- Security: atomicWriteFile with mode 0o600, noFollow, forceMode; path.basename sanitization
- Cleanup: 24h expiry on startup and /clear
- Error branch: large stderr also persisted
- Fallback: budget exhausted → preview-only stub; write failure → legacy truncateAndSaveToFile

* fix(core): suppress no-control-regex lint for null-byte sanitization

The \x00 regex is intentional security hardening to strip null bytes
from callIds before using them as filenames.

* fix(core): address wenshao review round 1

- Fix mock Config in coreToolScheduler.test.ts: add getToolResultBytesWritten,
  trackToolResultBytes, and storage.getToolResultsDir to all 4 mock instances
- isAlreadyTruncated: change includes to startsWith for <persisted-output>
  to avoid false positives from tool output containing the literal string
- Remove dead code: recalcContentLength function and its call site
  (contentLength is unconditionally overwritten downstream)
- buildStub: non-persisted stubs no longer use <persisted-output> tag
  to avoid misleading model into wasted read_file calls
- Add GATE_HEADROOM rationale comment

* fix(core): update prompts.test.ts snapshots for persisted-output guidance

The new <persisted-output> model guidance added to prompts.ts changed
the system prompt output, requiring snapshot updates.
This commit is contained in:
jinye 2026-06-13 01:27:13 +08:00 committed by GitHub
parent a283ca0479
commit a99b01b020
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 489 additions and 3 deletions

View file

@ -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;
}

View file

@ -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 });
}

View file

@ -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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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

View file

@ -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.

View file

@ -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,

View file

@ -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<string, unknown> };
}
).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<PartListUnion> {
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('<persisted-output>')) return null;
let truncated: { content: string; outputFile?: string };
try {

View file

@ -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 <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
- When you see a <persisted-output> 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

View file

@ -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

View file

@ -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<CleanupResult> {
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<void> {
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<void> {
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<void> {
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++;
}
}

View file

@ -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('<persisted-output>')
);
}
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) => `&lt;${m.slice(1, -1)}&gt;`)
.replace(/<\/?system-reminder>/g, (m) => `&lt;${m.slice(1, -1)}&gt;`);
return text;
}
export interface PersistResult {
content: string;
outputFile?: string;
bytesWritten: number;
}
export async function persistAndTruncateToolResult(
callId: string,
toolName: string,
content: string,
config: Config,
): Promise<PersistResult> {
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 `<persisted-output>
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}
</persisted-output>`;
}
return `Output too large (${sizeKb} KB). ${filePathOrNote}
Preview (up to ${PREVIEW_SIZE_CHARS} chars):
${preview}`;
}