mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
* 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:
parent
a283ca0479
commit
a99b01b020
10 changed files with 489 additions and 3 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
packages/core/src/utils/toolResultCleanup.ts
Normal file
136
packages/core/src/utils/toolResultCleanup.ts
Normal 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++;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => `<${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<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}`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue