feat(hooks): elide repeated read-only tool results instead of re-sending them - #3940
feat(hooks): elide repeated read-only tool results instead of re-sending them#3940dwin-gharibi wants to merge 3 commits into
Conversation
…hing for improving costs and tool calls count
…e elide hook to make sure cache consistency
…some edge case tests for new elide hook added
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The implementation is well-structured and the design decisions are sound. The mutex-guarded package-level state, session isolation, cap enforcement (4096 keys), fail-safe defaults for unknown/unavailable tools, and the hash-based elision logic are all correct. The concurrency model handles concurrent tool_response_transform dispatches and concurrent session_end cleanup safely. The json.Marshal key stability claim holds because encoding/json sorts map keys recursively. No bugs introduced by this PR were found.
aheritier
left a comment
There was a problem hiding this comment.
Thanks for this — the central design decision is the right one, and I want to say so before the findings. Hashing fresh output and comparing, rather than storing and serving, sidesteps the invalidation problem entirely instead of trying to solve it, and ChangedOutputIsNeverElided pins that property in both directions including the revert case. The comments explain why throughout. That is a higher standard than most contributions arrive at.
I found two blocking issues, both against runtime behaviour that is on by default, and both reproduced.
1. [blocking] The marker is discarded for exactly the large results this targets.
ApplyAgentDefaults prepends limit_large_tool_results to ToolResponseTransform (pkg/hooks/builtins/builtins.go:167-170), and pkg/hooks/executor.go:535 applies only the first non-nil UpdatedToolResponse in config order. So whenever limit_large_tool_results fires (>50 KiB or >2000 lines) it wins and the elide marker is dropped. Driving the real executor with the effective config (user entry + prepended default) and a 60 KiB payload:
call 1 rewrite is limit_large notice: true
call 2 contains elide marker: false
call 2 contains limit_large notice: true (51781 bytes)
elide store DID record a fingerprint anyway: 1 key
An 8 KiB repeat is elided, so the effective window is [256 B, 50 KiB and ≤2000 lines). #3939's motivating table says limit_large "bounds a single result; a 40 KiB result repeated five times still costs 5 × 40 KiB" — above the threshold that stays true after this PR. Worth a test, since the competing hook is injected unconditionally and cannot be removed from config.
2. [blocking] Compaction invalidates the "the model already saw it" premise, and nothing clears the store.
Compaction is on by default (pkg/runtime/runtime.go:687, pkg/agent/agent.go:363) and drops every message before FirstKeptEntry, replacing the prefix with a summary (pkg/runtime/session_compaction.go:183). elideStore is keyed on SessionID and cleared only on session_end (elide_repeated_tool_results.go:70-72), so after compaction the fingerprint survives while the payload is gone from context:
keys still recorded for s1 after compaction: 1
attempt 1 -> "...Nothing has changed since you last saw it."
attempt 2 -> (same)
attempt 3 -> (same)
The model is told nothing changed about bytes it can no longer see, permanently — only a byte change would ever release the payload. The same happens with max_old_tool_call_tokens, where truncateOldToolContent (pkg/session/session.go:2124) swaps older tool results for [content truncated]. Handling EventAfterCompaction (pkg/hooks/types.go:176) plus session_start with source=compact/clear would cover it.
3. [should-fix] ReadOnlyHint isn't a purity signal in this codebase, and for MCP it's untrusted input.
The scope rule assumes ToolReadOnly implies no side effects. It doesn't: pkg/tools/builtin/todo/todo.go:345,357,369 annotate create_todo/create_todos/update_todos with ReadOnlyHint: true // Technically not read-only but has practically no destructive side effects., and handoff.go:38 does the same. pkg/safety/safety.go:33 shows its real role is approval gating. For MCP tools the annotation is copied verbatim from the remote server (pkg/tools/mcp/mcp.go:682-683). Since the rewrite reaches the UI feed and the persisted session file (dispatcher.go:1071-1077), a third-party MCP server that self-declares readOnlyHint can suppress its own repeated output from the transcript. Opt-in mitigates, but a category allow-list alongside the hint — as limit_large_tool_results.go:38-43 does — would fence it properly, plus a note on the trust assumption.
4. [should-fix] Docs. A new builtin needs four updates that are currently complete for every other builtin: the table at docs/configuration/hooks/index.md:219; the builtin enumeration in the type description at agent-schema.json:1395; the tool_response_transform extra-fields row at docs/configuration/hooks/index.md:314 for tool_read_only (it's part of the JSON contract external command hooks receive); and an examples/*.yaml, which AGENTS.md requires and which redact_secrets_hooks.yaml / unload_on_switch.yaml / snapshot_hooks.yaml all have.
5. [should-fix] Input.ToolReadOnly doc is broader than the field. pkg/hooks/types.go:277-282 presents it as generally mirroring ReadOnlyHint, but the only assignment is dispatcher.go:1084 in applyToolResponseTransform. Hook authors on pre_tool_use/post_tool_use/permission_request get false, which the doc tells them means "the hint is absent". Either populate it in NewHooksInput — c.tool is in hand at every tool event — or scope the comment.
6. [should-fix] Session count is unbounded and cleanup is optional. maxElideKeysPerSession caps keys within a session, but nothing caps sessions, and freeing depends on the operator also wiring the session_end leg — optional here, unlike limit_large_tool_results, which is auto-injected on both legs. 50,000 distinct sessions retain 50,000 maps in my repro. Under serve api/serve mcp that's ~4096 entries per session held for process lifetime, plus every session that ends abnormally. An LRU cap on sessions, or hanging the store off the executor instead of the package, would close it.
7. [optional] minElidableBytes = 256 is below the marker's own size for long tool names. The marker is 234 B for read_file, 245 B for search_files_content, 255 B for a 30-char MCP name — so the saving at the boundary is 1–22 bytes and goes negative at ≥31 chars (mcp__github__list_pull_requests). Comparing len(marker) with len(payload) before returning would make the invariant hold by construction.
8. [optional] elide_repeated_tool_results.go:48-52 cites limit_large_tool_results as precedent for package-level state, but that builtin keeps none — its per-session scratch is a temp directory on disk.
9. [optional] Squash merge is off here, so all three commit subjects land in history. They're 115–120 chars, scoped by file path rather than component, and call the change "readonly tool caching" / "cache consistency" — which reads against the PR's own "deliberately not a cache" framing.
On whether to land this at all. The mechanism is sound; my hesitation is about sequencing. #3939 is self-filed with no maintainer comment, so there's no signal yet that this is wanted, and the PR itself names the decisive unknown: "No eval evidence that the marker is good for model behaviour. It might make some models re-read anyway." That's the whole bet — a model told "nothing has changed" may re-call with a slightly different argument, which changes the key, defeats the elision, and costs an extra round trip plus the full payload. With finding 1 narrowing the benefit to [256 B, 50 KiB) and finding 2 making the in-band failure silent context loss, I'd want the eval pass before the builtin lands.
A concrete suggestion: split the ToolReadOnly plumbing (pkg/hooks/types.go + pkg/runtime/toolexec/dispatcher.go, ~10 lines) into its own PR with finding 5 fixed. It's useful to hooks generally and should go in easily. Then bring the builtin back with 1 and 2 addressed and some eval numbers behind it.
Adds the
elide_repeated_tool_resultsbuiltin. When a read-only tool returns output byte-for-byteidentical to what the model was already shown for the same arguments in the same session, the
payload is replaced with a one-line marker instead of being repeated.
Closes #3939.
This is deliberately not a cache
The obvious version of this feature caches results and skips re-execution — and then hands the
agent a stale file when something changes underneath it. That is the failure mode worth designing
away, so this does not store payloads at all:
There is no stored payload to go stale, no expiry to tune, and no invalidation to get wrong. A
one-byte change means the hashes differ and the full new output passes through untouched. The test
ChangedOutputIsNeverElidedpins this, including the harder direction: after content changes, thenew output becomes the baseline, and reverting to the older content is also passed through in
full rather than matching a remembered older hash.
The honest trade-off: the saving is tokens, not latency. The tool still runs. A 40 KiB
read_filerepeated five times across a session costs 40 KiB once instead of five times, and thecontext window it would have consumed is what pulls compaction forward.
What the model sees
Worded to say explicitly that the tool ran, so the model does not treat it as a cache hit of
unknown age.
Scope decisions
session_endRead-only-ness comes from the tool's own
ReadOnlyHintannotation rather than a hard-coded namelist, so it works for MCP tools that declare it too (
tools.ToolAnnotationsismcp.ToolAnnotations).Implementation
Three small pieces, all at existing seams:
pkg/hooks/types.go— newInput.ToolReadOnlyfield mirroring the tool'sReadOnlyHint. Itis
falsewhenever the hint is absent or the tool is unknown to the agent, which is the fail-safedirection: a consumer keyed on read-only-ness stays inert rather than guessing.
pkg/runtime/toolexec/dispatcher.go— one line populating it inapplyToolResponseTransform, wherec.toolis already in hand.pkg/hooks/builtins/elide_repeated_tool_results.go— the builtin. Dispatches on event so asingle name covers both legs, the same pattern
redact_secretsuses. State is a package-levelper-session map of
(tool, args) → sha256(last output), mutex-guarded because parallel tool callsdispatch hooks concurrently. Keys are
sha256(toolName ‖ 0x00 ‖ json.Marshal(args));encoding/jsonsorts map keys, so the key does not depend on Go's randomized map iteration order — there's a test
that hammers that 20×.
Per-session keys are capped at 4096. Past the cap, new fingerprints are simply not recorded, so
those calls are never elided — bounded memory with no correctness impact.
Tests
15 tests in
pkg/hooks/builtins/elide_repeated_tool_results_test.go:ChangedOutputIsNeverElided— the consistency property, both directionsFirstCallPassesThrough,IdenticalRepeatIsElided— the happy pathNonReadOnlyToolIsNeverElided,ErrorResultIsNeverElided— the scope rulesDifferentArgsAreDistinct,ArgOrderIsIrrelevant— key correctnessSessionsAreIsolated,SessionEndForgetsState,PerSessionKeyCapIsBounded— state lifecycleConcurrentDispatch— runs under-raceIsRegistered,UnsupportedEventIsNoOp,NilInput,SmallPayloadNotWorthElidingThese tests deliberately do not use
t.Parallel(): they share the package-level store, which isthe same state the runtime shares across a process. There's a comment saying so, so nobody
"fixes" it later.
Verification
Toolchain
go1.26.5, darwin/arm64.go test ./pkg/hooks/...go test ./pkg/runtime/...go test -race -count=1 ./pkg/hooks/...golangci-lint run ./pkg/hooks/... ./pkg/runtime/toolexec/...(v2.12.2, CI's pin)go run ./lint .gofmt -l,go build ./...go test ./...pkg/teamloaderfails — pre-existing (Google Cloud ADC), unrelatedNot in this PR
re-read anyway. That is the main open question and the reason this ships opt-in rather than
auto-injected; it wants an eval pass before anyone considers a default.
decision once behaviour is measured.
feature with a real invalidation problem to solve, and should be argued separately.