Three-Layer Tool Result Flow
- Entity ID:
ent-20260410-0b724f0ffb2c - Type:
pattern - Scope:
shared - Status:
active
Description
The Three-Layer Tool Result Flow is the system Claude Code uses to manage tool output of varying sizes without overwhelming the context window. Every tool declares a maxResultSizeChars property on its ToolDef, which is clamped against the system-wide DEFAULT_MAX_RESULT_SIZE_CHARS (50,000 characters) via getPersistenceThreshold(). When a tool result exceeds this threshold, the full content is persisted to a session-local file on disk, and the model receives only a compact reference containing a 2,000-byte preview and the file path.
Layer 1 (Index) is the compact reference the model always sees inline: a <persisted-output> XML-wrapped message containing the file size, the disk path, and a truncated preview (first ~2KB, cut at a newline boundary). Layer 2 (Detailed) is the full tool result persisted to {projectDir}/{sessionId}/tool-results/{toolUseId}.{txt|json}, which the model can read on demand using the FileRead tool. Layer 3 is the aggregate per-message budget enforcement: when multiple parallel tool calls in a single turn collectively exceed MAX_TOOL_RESULTS_PER_MESSAGE_CHARS (200,000 characters), the largest blocks are evicted to disk even if each individually was under the per-tool threshold. This prevents N parallel tools from producing, e.g., 10 x 40K = 400K in one turn.
A ContentReplacementState object tracks which tool_use_ids have been replaced across the entire conversation, ensuring that budget decisions are stable (idempotent) across turns to preserve prompt cache prefixes. Replacement records are written to the transcript so they survive session resume. GrowthBook feature flags (tengu_satin_quoll for per-tool threshold overrides, tengu_hawthorn_window for the aggregate budget, tengu_hawthorn_steeple for enabling the aggregate feature) allow runtime tuning without code deploys.
Key claims
- Per-tool persistence threshold is resolved by
getPersistenceThreshold()insrc/utils/toolResultStorage.ts(line 54-77): GrowthBook override wins when present, otherwiseMath.min(declaredMaxResultSizeChars, DEFAULT_MAX_RESULT_SIZE_CHARS)where the default is 50,000 chars (src/constants/toolLimits.tsline 13). - Persisted output previews are exactly
PREVIEW_SIZE_BYTES = 2000bytes, truncated at a newline boundary when possible (generatePreview()at line 339-356 oftoolResultStorage.ts). - The per-message aggregate budget is
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000(src/constants/toolLimits.tsline 49), enforced byenforceToolResultBudget()(line 769) which sorts candidates by size descending and evicts the largest until total is under budget. - Empty tool results are replaced with
"({toolName} completed with no output)"to prevent models from misinterpreting a bare</function_results>boundary as a turn boundary (documented at line 281-295 as inc-4586). - Replacement decisions are frozen per conversation via
ContentReplacementState(line 390-397); once a tool_use_id passes through the budget check, its fate never changes, guaranteeing prompt cache stability.
Relations
- uses:
src/constants/toolLimits.ts-- defines all numeric constants (DEFAULT_MAX_RESULT_SIZE_CHARS, MAX_TOOL_RESULT_TOKENS, MAX_TOOL_RESULTS_PER_MESSAGE_CHARS) - implements:
src/utils/toolResultStorage.ts-- core persistence logic, budget enforcement, preview generation - consumed-by:
src/query.ts-- callsapplyToolResultBudget()before every API request - consumed-by:
src/tools/BashTool/BashTool.tsx-- importsbuildLargeToolResultMessage,ensureToolResultsDir,getToolResultPath - property-of:
src/Tool.ts-- every tool declaresmaxResultSizeCharson itsToolDef
Sources
src/utils/toolResultStorage.tssrc/constants/toolLimits.tssrc/Tool.tssrc/query.ts