Skip to content

feat(hooks): elide repeated read-only tool results instead of re-sending them - #3940

Open
dwin-gharibi wants to merge 3 commits into
docker:mainfrom
dwin-gharibi:feat/tool-result-cache
Open

feat(hooks): elide repeated read-only tool results instead of re-sending them#3940
dwin-gharibi wants to merge 3 commits into
docker:mainfrom
dwin-gharibi:feat/tool-result-cache

Conversation

@dwin-gharibi

Copy link
Copy Markdown
Contributor

Adds the elide_repeated_tool_results builtin. When a read-only tool returns output byte-for-byte
identical 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.

hooks:
  tool_response_transform:
    - { type: builtin, command: elide_repeated_tool_results }
  session_end:
    - { type: builtin, command: elide_repeated_tool_results }

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:

  1. the tool always executes;
  2. its fresh output is hashed;
  3. the payload is elided only if that hash matches what the model already saw.

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
ChangedOutputIsNeverElided pins this, including the harder direction: after content changes, the
new 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_file repeated five times across a session costs 40 KiB once instead of five times, and the
context window it would have consumed is what pulls compaction forward.

What the model sees

[docker-agent] The read_file tool ran and returned output byte-for-byte identical to its earlier
result for these same arguments in this session, so the 41,232-byte payload is not repeated here.
Nothing has changed since you last saw it.

Worded to say explicitly that the tool ran, so the model does not treat it as a cache hit of
unknown age.

Scope decisions

Rule Why
Read-only tools only A tool with side effects can return identical output for two calls that each did something; eliding the second would hide a real event
Never elide errors A repeated identical failure is itself information
Never elide below 256 bytes The marker would cost more tokens than the payload it replaces
Per-session state, dropped on session_end Two agents in one process must not cross-contaminate
Opt-in It changes what the model sees; that is not a default to impose

Read-only-ness comes from the tool's own ReadOnlyHint annotation rather than a hard-coded name
list, so it works for MCP tools that declare it too (tools.ToolAnnotations is
mcp.ToolAnnotations).

Implementation

Three small pieces, all at existing seams:

pkg/hooks/types.go — new Input.ToolReadOnly field mirroring the tool's ReadOnlyHint. It
is false whenever the hint is absent or the tool is unknown to the agent, which is the fail-safe
direction: a consumer keyed on read-only-ness stays inert rather than guessing.

pkg/runtime/toolexec/dispatcher.go — one line populating it in
applyToolResponseTransform, where c.tool is already in hand.

pkg/hooks/builtins/elide_repeated_tool_results.go — the builtin. Dispatches on event so a
single name covers both legs, the same pattern redact_secrets uses. State is a package-level
per-session map of (tool, args) → sha256(last output), mutex-guarded because parallel tool calls
dispatch hooks concurrently. Keys are sha256(toolName ‖ 0x00 ‖ json.Marshal(args)); encoding/json
sorts 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 directions
  • FirstCallPassesThrough, IdenticalRepeatIsElided — the happy path
  • NonReadOnlyToolIsNeverElided, ErrorResultIsNeverElided — the scope rules
  • DifferentArgsAreDistinct, ArgOrderIsIrrelevant — key correctness
  • SessionsAreIsolated, SessionEndForgetsState, PerSessionKeyCapIsBounded — state lifecycle
  • ConcurrentDispatch — runs under -race
  • IsRegistered, UnsupportedEventIsNoOp, NilInput, SmallPayloadNotWorthEliding

These tests deliberately do not use t.Parallel(): they share the package-level store, which is
the 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.

Check Result
go test ./pkg/hooks/... ok
go test ./pkg/runtime/... ok (all subpackages — the dispatcher change)
go test -race -count=1 ./pkg/hooks/... ok
golangci-lint run ./pkg/hooks/... ./pkg/runtime/toolexec/... (v2.12.2, CI's pin) 0 issues
go run ./lint . 1768 files, no offenses
gofmt -l, go build ./... clean
go test ./... only pkg/teamloader fails — pre-existing (Google Cloud ADC), unrelated

Not in this PR

  • No eval evidence that the marker is good for model behaviour. It might make some models
    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.
  • No agent-level flag. A hook entry is enough to try it; promoting it to a flag is a separate
    decision once behaviour is measured.
  • Latency is untouched by design. If skipping execution is ever wanted, that is a different
    feature with a real invalidation problem to solve, and should be argued separately.

@dwin-gharibi
dwin-gharibi requested a review from a team as a code owner August 7, 2026 05:27
@aheritier aheritier added area/core Core agent runtime, session management kind/feat PR adds a new feature (maps to feat:). Use on PRs only. labels Aug 7, 2026
@aheritier aheritier self-assigned this Aug 7, 2026
@aheritier
aheritier requested a review from docker-agent August 7, 2026 06:08

@docker-agent docker-agent left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core Core agent runtime, session management kind/feat PR adds a new feature (maps to feat:). Use on PRs only.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repeated read-only tool results are re-sent in full, costing tokens for bytes the model already has

3 participants