Apply FileRead memory caps
This commit is contained in:
parent
184160ebb8
commit
72bbef671e
|
|
@ -12,12 +12,12 @@ Claude Code 将文件操作拆分为三个独立工具——这不是功能划
|
||||||
|
|
||||||
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|
| 工具 | 权限级别 | 核心方法 | 关键属性 |
|
||||||
|------|---------|---------|---------|
|
|------|---------|---------|---------|
|
||||||
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: Infinity` |
|
| **Read** | 只读(免审批) | `isReadOnly() → true` | `maxResultSizeChars: 100,000` |
|
||||||
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
| **Edit** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||||
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
| **Write** | 写入(需确认) | `checkWritePermissionForTool()` | `maxResultSizeChars: 100,000` |
|
||||||
|
|
||||||
<Tip>
|
<Tip>
|
||||||
Read 的 `maxResultSizeChars` 是 `Infinity`,但这并不意味着无限制输出——真正的截断发生在 `validateContentTokens()` 中基于 token 预算的动态判定,而非字符数硬限制。
|
Read 的 `maxResultSizeChars` 为 100,000(100KB)。超出此阈值的结果会被持久化到磁盘,减少长会话的内存压力。实际的 token 级别截断由 `validateContentTokens()` 动态控制。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
## FileRead:多模态文件读取引擎
|
## FileRead:多模态文件读取引擎
|
||||||
|
|
|
||||||
10
src/query.ts
10
src/query.ts
|
|
@ -492,6 +492,16 @@ async function* queryLoop(
|
||||||
querySource,
|
querySource,
|
||||||
)
|
)
|
||||||
messagesForQuery = microcompactResult.messages
|
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
|
// For cached microcompact (cache editing), defer boundary message until after
|
||||||
// the API response so we can use actual cache_deleted_input_tokens.
|
// the API response so we can use actual cache_deleted_input_tokens.
|
||||||
// Gated behind feature() so the string is eliminated from external builds.
|
// Gated behind feature() so the string is eliminated from external builds.
|
||||||
|
|
|
||||||
|
|
@ -217,6 +217,10 @@ export type MicrocompactResult = {
|
||||||
compactionInfo?: {
|
compactionInfo?: {
|
||||||
pendingCacheEdits?: PendingCacheEdits
|
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)
|
notifyCacheDeletion(querySource)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { messages: result }
|
return { messages: result, clearedToolUseIds: [...clearSet] }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -337,9 +337,9 @@ export type Output = z.infer<OutputSchema>
|
||||||
export const FileReadTool = buildTool({
|
export const FileReadTool = buildTool({
|
||||||
name: FILE_READ_TOOL_NAME,
|
name: FILE_READ_TOOL_NAME,
|
||||||
searchHint: 'read files, images, PDFs, notebooks',
|
searchHint: 'read files, images, PDFs, notebooks',
|
||||||
// Output is bounded by maxTokens (validateContentTokens). Persisting to a
|
// Output is bounded by maxTokens (validateContentTokens). Results exceeding
|
||||||
// file the model reads back with Read is circular — never persist.
|
// 100KB are persisted to disk, reducing memory pressure in long sessions.
|
||||||
maxResultSizeChars: Infinity,
|
maxResultSizeChars: 100_000,
|
||||||
strict: true,
|
strict: true,
|
||||||
async description() {
|
async description() {
|
||||||
return DESCRIPTION
|
return DESCRIPTION
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Empty lookups for static rendering contexts that don't need real lookups. */
|
||||||
export const EMPTY_LOOKUPS: MessageLookups = {
|
export const EMPTY_LOOKUPS: MessageLookups = {
|
||||||
siblingToolUseIDs: new Map(),
|
siblingToolUseIDs: new Map(),
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ export function getPersistenceThreshold(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
declaredMaxResultSizeChars: number,
|
declaredMaxResultSizeChars: number,
|
||||||
): number {
|
): number {
|
||||||
// Infinity = hard opt-out. Read self-bounds via maxTokens; persisting its
|
// Infinity = hard opt-out (reserved for tools that self-bound via other
|
||||||
// output to a file the model reads back with Read is circular. Checked
|
// mechanisms). Checked before the GB override so tengu_satin_quoll can't
|
||||||
// before the GB override so tengu_satin_quoll can't force it back on.
|
// force it back on.
|
||||||
if (!Number.isFinite(declaredMaxResultSizeChars)) {
|
if (!Number.isFinite(declaredMaxResultSizeChars)) {
|
||||||
return declaredMaxResultSizeChars
|
return declaredMaxResultSizeChars
|
||||||
}
|
}
|
||||||
|
|
@ -813,11 +813,12 @@ export async function enforceToolResultBudget(
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tools with maxResultSizeChars: Infinity (Read) — never persist.
|
// Tools with maxResultSizeChars: Infinity — never persist (reserved for
|
||||||
// Mark as seen (frozen) so the decision sticks across turns. They don't
|
// tools that self-bound via other mechanisms). Mark as seen (frozen) so
|
||||||
// count toward freshSize; if that lets the group slip under budget and
|
// the decision sticks across turns. They don't count toward freshSize; if
|
||||||
// the wire message is still large, that's the contract — Read's own
|
// that lets the group slip under budget and the wire message is still
|
||||||
// maxTokens is the bound, not this wrapper.
|
// large, that's the contract — the tool's own maxTokens is the bound, not
|
||||||
|
// this wrapper.
|
||||||
const skipped = fresh.filter(c => shouldSkip(c.toolUseId))
|
const skipped = fresh.filter(c => shouldSkip(c.toolUseId))
|
||||||
skipped.forEach(c => state.seenIds.add(c.toolUseId))
|
skipped.forEach(c => state.seenIds.add(c.toolUseId))
|
||||||
const eligible = fresh.filter(c => !shouldSkip(c.toolUseId))
|
const eligible = fresh.filter(c => !shouldSkip(c.toolUseId))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user