From 72bbef671ea601edc6c81e58b391589abce83589 Mon Sep 17 00:00:00 2001 From: James Feng <47167674+GhostDragon124@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:38:51 +0800 Subject: [PATCH] Apply FileRead memory caps --- docs/tools/file-operations.mdx | 4 +-- src/query.ts | 10 ++++++ src/services/compact/microCompact.ts | 6 +++- src/tools/FileReadTool/FileReadTool.ts | 6 ++-- src/utils/messages.ts | 48 ++++++++++++++++++++++++++ src/utils/toolResultStorage.ts | 17 ++++----- 6 files changed, 77 insertions(+), 14 deletions(-) diff --git a/docs/tools/file-operations.mdx b/docs/tools/file-operations.mdx index 1c7fefdab..d59795c70 100644 --- a/docs/tools/file-operations.mdx +++ b/docs/tools/file-operations.mdx @@ -12,12 +12,12 @@ Claude Code 将文件操作拆分为三个独立工具——这不是功能划 | 工具 | 权限级别 | 核心方法 | 关键属性 | |------|---------|---------|---------| -| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` | +| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` | | **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` | | **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` | -Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。 +Read 的 `maxResultSizeChars` 为 100,000(100KB)。超出此阈值的结果会被持久化到磁盘,减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。 ## FileRead:多模态文件读取引擎 diff --git a/src/query.ts b/src/query.ts index 58aaf78df..4fca63e7d 100644 --- a/src/query.ts +++ b/src/query.ts @@ -492,6 +492,16 @@ async function* queryLoop( querySource, ) messagesForQuery = microcompactResult.messages + // Release original strings from contentReplacementState.replacements for + // tool results whose content was replaced with the cleared message. + if (microcompactResult.clearedToolUseIds?.length) { + const replacements = toolUseContext?.contentReplacementState?.replacements + if (replacements) { + for (const id of microcompactResult.clearedToolUseIds) { + replacements.delete(id) + } + } + } // For cached microcompact (cache editing), defer boundary message until after // the API response so we can use actual cache_deleted_input_tokens. // Gated behind feature() so the string is eliminated from external builds. diff --git a/src/services/compact/microCompact.ts b/src/services/compact/microCompact.ts index 417e25926..93ded029b 100644 --- a/src/services/compact/microCompact.ts +++ b/src/services/compact/microCompact.ts @@ -217,6 +217,10 @@ export type MicrocompactResult = { compactionInfo?: { pendingCacheEdits?: PendingCacheEdits } + // Tool use IDs whose content was replaced with the cleared message. + // Callers should remove these from contentReplacementState.replacements + // to release the original strings from memory. + clearedToolUseIds?: string[] } /** @@ -526,5 +530,5 @@ function maybeTimeBasedMicrocompact( notifyCacheDeletion(querySource) } - return { messages: result } + return { messages: result, clearedToolUseIds: [...clearSet] } } diff --git a/src/tools/FileReadTool/FileReadTool.ts b/src/tools/FileReadTool/FileReadTool.ts index 45c676e90..0737c5235 100644 --- a/src/tools/FileReadTool/FileReadTool.ts +++ b/src/tools/FileReadTool/FileReadTool.ts @@ -337,9 +337,9 @@ export type Output = z.infer export const FileReadTool = buildTool({ name: FILE_READ_TOOL_NAME, searchHint: 'read files, images, PDFs, notebooks', - // Output is bounded by maxTokens (validateContentTokens). Persisting to a - // file the model reads back with Read is circular — never persist. - maxResultSizeChars: Infinity, + // Output is bounded by maxTokens (validateContentTokens). Results exceeding + // 100KB are persisted to disk, reducing memory pressure in long sessions. + maxResultSizeChars: 100_000, strict: true, async description() { return DESCRIPTION diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 0d58b9b33..dcddd76ab 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1364,6 +1364,54 @@ export function buildMessageLookups( } } +/** + * Compute a lightweight structural fingerprint for buildMessageLookups caching. + * Only captures information that affects lookup results (types, IDs, counts), + * not content. Returns an empty string when the arrays are structurally empty. + * + * O(n) but allocates only a string — much cheaper than the 8 Maps/Sets that + * buildMessageLookups creates on every call. + */ +export function computeMessageStructureKey( + normalizedMessages: NormalizedMessage[], + messages: Message[], +): string { + const parts: string[] = [ + String(normalizedMessages.length), + '|', + String(messages.length), + ] + for (const msg of messages) { + parts.push(msg.type[0]) + if (msg.type === 'assistant') { + const aMsg = msg as AssistantMessage + const content = aMsg.message?.content + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block !== 'string' && block.type === 'tool_use') { + parts.push('t', (block as ToolUseBlock).id) + } + } + } + } else if (msg.type === 'user') { + const content = (msg as UserMessage).message?.content + if (Array.isArray(content)) { + for (const block of content) { + if (typeof block !== 'string' && block.type === 'tool_result') { + parts.push('r', (block as ToolResultBlockParam).tool_use_id) + } + } + } + } + } + for (const msg of normalizedMessages) { + if (msg.type === 'progress') { + parts.push('p', (msg as ProgressMessage).parentToolUseID as string) + } + } + return parts.join(',') +} + /** Empty lookups for static rendering contexts that don't need real lookups. */ export const EMPTY_LOOKUPS: MessageLookups = { siblingToolUseIDs: new Map(), diff --git a/src/utils/toolResultStorage.ts b/src/utils/toolResultStorage.ts index 9d2819bc6..7a2744e6c 100644 --- a/src/utils/toolResultStorage.ts +++ b/src/utils/toolResultStorage.ts @@ -56,9 +56,9 @@ export function getPersistenceThreshold( toolName: string, declaredMaxResultSizeChars: number, ): number { - // Infinity = hard opt-out. Read self-bounds via maxTokens; persisting its - // output to a file the model reads back with Read is circular. Checked - // before the GB override so tengu_satin_quoll can't force it back on. + // Infinity = hard opt-out (reserved for tools that self-bound via other + // mechanisms). Checked before the GB override so tengu_satin_quoll can't + // force it back on. if (!Number.isFinite(declaredMaxResultSizeChars)) { return declaredMaxResultSizeChars } @@ -813,11 +813,12 @@ export async function enforceToolResultBudget( continue } - // Tools with maxResultSizeChars: Infinity (Read) — never persist. - // Mark as seen (frozen) so the decision sticks across turns. They don't - // count toward freshSize; if that lets the group slip under budget and - // the wire message is still large, that's the contract — Read's own - // maxTokens is the bound, not this wrapper. + // Tools with maxResultSizeChars: Infinity — never persist (reserved for + // tools that self-bound via other mechanisms). Mark as seen (frozen) so + // the decision sticks across turns. They don't count toward freshSize; if + // that lets the group slip under budget and the wire message is still + // large, that's the contract — the tool's own maxTokens is the bound, not + // this wrapper. const skipped = fresh.filter(c => shouldSkip(c.toolUseId)) skipped.forEach(c => state.seenIds.add(c.toolUseId)) const eligible = fresh.filter(c => !shouldSkip(c.toolUseId))