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