Repository navigation
fix(truncate): persist + recursively cache truncated payloads so read_cache pagination works - #480
Merged
Dumbris merged 3 commits intoMay 19, 2026
Conversation
The truncator generated a cache key and embedded it in the "use read_cache" banner, but the full pre-truncation payload was never written to the cache backend. Every read_cache call returned "cache key not found", silently breaking pagination for any tool response >tool_response_limit (default 20 KB) — including the github MCP's review-list endpoints, which routinely emit 60–80 KB review bodies. forwardContentResult now takes an optional CacheStore (a local interface that mirrors cache.Manager.Store) and persists the full text under the truncator-generated key whenever TruncationResult.CacheAvailable is true. Both call sites in mcp.go now thread p.cacheManager through. Tested: - TestForwardContentResult_StoresFullContentInCache — single TextContent block triggers exactly one Store call with the full original payload - TestForwardContentResult_NoCacheStoreNoPanic — legacy nil-cacheStore path still works for callers that don't have a cache plumbed through - TestForwardContentResult_FallbackPathStoresCache — non-CallToolResult fallback (JSON-wrap) also caches - TestForwardContentResult_RoundTripViaRealCacheManager — wires the real cache.Manager (BBolt) end-to-end and asserts GetRecords(offset=0,limit=10) and GetRecords(offset=10,limit=10) return distinct, correctly-ordered pages by id, proving the read_cache contract is honored Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
electrolobzik
marked this pull request as draft
May 18, 2026 18:09
…ion) Until now, only upstream-proxied tool calls (call_tool_read, etc.) went through the truncator+cache pipeline. Built-in tools whose output is emitted directly via mcp.NewToolResultText — notably read_cache itself — bypassed it. So when a single read_cache page exceeded tool_response_limit, the oversized JSON was returned unbounded. For tools that produce naturally-large pages (CodeRabbit reviews, log queries, big GitHub diffs) this defeated the whole truncate-and-cache escape hatch the prior commit just fixed. This commit extracts the truncate+store contract into a small helper, `maybeTruncateAndCacheText`, and wires it into `handleReadCache`. Now a recursive read_cache walk works uniformly: each oversized response is truncated, its full body stored under a fresh cache key, and a banner points the agent at that key for further pagination. The single-record infinite-recursion edge case is guarded explicitly. If the response carries `paginableUnits <= 1` (e.g. read_cache(limit=1) where the one record happens to be larger than the limit), recursive caching would just hand the agent a new key resolving to the exact same oversized payload — an inescapable loop. In that case the helper short-circuits and returns the oversize text unchanged; the agent already has the data they asked for and nothing more can be done at this layer. Tests: - TestMaybeTruncateAndCacheText_HappyPathStores — happy path: 30 records over the limit triggers exactly one Store call carrying the full body - TestMaybeTruncateAndCacheText_NoRecurseOnSingleRecord — single oversized record short-circuits; no Store call, text returned as-is - TestMaybeTruncateAndCacheText_NoOpUnderLimit — under the limit, the helper is a no-op (no banner, no cache churn) - TestMaybeTruncateAndCacheText_RecursiveRoundTripViaRealCacheManager — wires the real BBolt-backed cache.Manager: a 40-record read_cache- shaped response gets truncated, the new key is parsed out of the banner, and GetRecords(newKey, 0, 5) returns the same records 0..4 in order. Proves the recursion is stable and read_cache pagination remains valid at each level. No API surface change. The 20K threshold and the read_cache MCP tool signature are untouched. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
electrolobzik
marked this pull request as ready for review
May 19, 2026 06:37
2 of 3 tasks
electrolobzik
added a commit
to HaloCollar/mcpproxy-go
that referenced
this pull request
May 19, 2026
Truncate+cache pagination fix (mirrors upstream PR smart-mcp-proxy#480). Persists truncated payloads in BBolt so read_cache pagination resolves keys, and applies the same contract recursively to read_cache's own output with a single-record short-circuit guard.
electrolobzik
commented
May 19, 2026
electrolobzik
left a comment
Contributor
Author
There was a problem hiding this comment.
Code review
No issues found. Checked for bugs and CLAUDE.md compliance.
🤖 Generated with Claude Code
The two prior commits left the three cacheStore.Store error returns silently discarded via `_ = cacheStore.Store(...)`. If BBolt rejects a write (disk full, transaction abort, lock contention), the agent still receives a truncation banner pointing at a cache key that no longer resolves — recreating the exact "cache key not found" symptom this PR was filed to fix, just from a different root cause and with no server- side signal to debug from. Now forwardContentResult and maybeTruncateAndCacheText both take an optional `logger *zap.Logger` and emit zap.Warn via the shared helper `logCacheStoreFailure` on Store failure. The log carries `tool`, `cache_key`, `total_records`, `payload_bytes`, and the wrapped error so the operator can correlate a failing read_cache call to its upstream BBolt write. Nil logger is a no-op (test callers pass nil; production call sites in mcp.go pass `p.logger`). Matches the pattern already used in internal/experiments/guesser.go. Tests added: - TestForwardContentResult_LogsAndForwardsOnStoreFailure — observer.New captures the Warn; asserts tool name, cache key (matches the one in the banner the agent received), and that the truncated banner still flows so the agent sees consistent output even on failure. - TestMaybeTruncateAndCacheText_LogsAndForwardsOnStoreFailure — same contract for the recursive read_cache path. - TestMaybeTruncateAndCacheText_TwoLevelRecursionStablePagination — walks two full recursion levels against a real BBolt cache.Manager: level-0 payload caches under K1; read_cache(K1, 0, 20) caches under a distinct K2; read_cache(K2, 5, 5) returns records 5..9 in order with correct meta. Proves the recursive truncate-and-cache chain (the half of the PR commit 36f6a75 introduced) is stable end-to-end, not just at a single level. Existing 12 tests updated to pass nil for the new logger parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dumbris
approved these changes
May 19, 2026
Dumbris
left a comment
Member
There was a problem hiding this comment.
Thank you @electrolobzik for the thorough fix and excellent test coverage! 🙏
Verified locally: go build ./... clean, targeted tests green. The bug analysis is spot-on — the cache plumbing existed and only the Store call was missing on the truncation path. The local CacheStore interface keeps package coupling clean, graceful degradation on Store failure is the right tradeoff, and the two-level recursion round-trip test is great.
A few minor, non-blocking follow-ups I'll open a separate PR for:
- Multi-
TextContent-block results can collide on a single cache key (GenerateKeyis second-granularity ontoolName:args), so later blocks overwrite earlier ones. - "Idempotent" / "bounded recursion" wording is slightly stronger than the timestamped-key behavior guarantees.
read_cacherecursive truncation isn't fed into the truncation token metrics.
None of these block the fix. Merging.
Dumbris
added a commit
that referenced
this pull request
May 19, 2026
…observability (#482) Follow-ups from PR #480 review: 1. Multi-block cache-key collision. cache.GenerateKey mixed the timestamp in at second granularity. forwardContentResult truncates each oversized TextContent block independently and the truncator derives the key from toolName+args+time.Now(); two blocks truncated in the same wall-clock second produced the SAME key, so the second Store overwrote the first and the first banner resolved to the wrong payload. Same hazard for rapid recursive read_cache truncation. Now nanosecond granularity. 2. Doc wording. maybeTruncateAndCacheText doc now states termination is agent-driven (not strictly algorithmic) and that successive identical calls are NOT idempotent (distinct timestamped keys), bounded only by cache TTL + cleanup. 3. read_cache truncation observability. handleReadCache discarded wasTruncated; it has no token-metrics plumbing (reports via activity log). A recursively re-truncated page was invisible to operators — now surfaced as a structured Info log with key/offset/limit/size. Tests: new GenerateKey sub-second regression assertion; TestForwardContentResult_MultipleTextBlocksDistinctKeys. Full internal/server, internal/cache, internal/truncate pass (incl. -race).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes two related bugs in the truncate-and-cache pipeline that together broke
read_cachepagination for any response large enough to need it. Two commits, each independently testable.1.
forwardContentResultnever stored the truncated payloadWhen an upstream tool call exceeded
tool_response_limit(default 20 KB), the truncator generated aCacheKeyand embedded it in a "use read_cache key=..." banner. But the full pre-truncation payload was never written to the BBolt cache backend — only the banner was emitted. Every subsequentread_cachecall returned"cache key not found". The cache + pagination plumbing (cache.Manager.StoreandManager.GetRecords) was already there and correct — the truncation path simply skipped theStorestep.Real-world impact:
github:pull_request_read get_reviewson a PR with a long CodeRabbit review (~70 KB body) gives a truncated banner pointing at a cache key that resolves to nothing.forwardContentResultnow takes an optionalCacheStore(a small local interface mirroringcache.Manager.Store) and persists the original text under the truncator-generated key wheneverTruncationResult.CacheAvailableis true. Both call sites ininternal/server/mcp.gonow threadp.cacheManagerthrough.The interface is defined locally to keep
internal/server/content_forward.godecoupled frominternal/cache. Callers that don't have a cache plumbed through (e.g. existing tests) can passnil— that path is exercised byTestForwardContentResult_NoCacheStoreNoPanic.2.
read_cache's own output bypassed the truncatorOnce commit 1 made
read_cacheactually work, the next-level bug surfaced:read_cacheitself returnsmcp.NewToolResultText(jsonResult)directly, bypassingforwardContentResultentirely. So an oversized page (50 records totalling 200 KB) was emitted unbounded — the very thing the truncator is supposed to prevent. The fix on naturally-large responses (CodeRabbit reviews, log queries, big diffs) was incomplete.This is the recursive truncate-and-cache contract: when
handleReadCacheproduces an oversized response, it now gets truncated, the full body stored under a fresh cache key, and the agent sees the standard banner pointing at the new key. Subsequent reads against the new key paginate normally — recursion is bounded (each level emits a distinct key) and idempotent.A new helper
maybeTruncateAndCacheTextlives next toforwardContentResultso other built-in tools that bypass the upstream pipeline can adopt the same contract in a one-liner.Single-record infinite-recursion guard. If
read_cache(limit=1)returns one record that itself exceeds the limit, recursively caching it would produce a new key resolving to the exact same oversized payload — an inescapable loop. The helper short-circuits whenpaginableUnits <= 1: the oversize text passes through unchanged. The agent already has the data they explicitly asked for; nothing more can be done at this layer.Tests
All in
internal/server/content_forward_test.go:Commit 1 (
forwardContentResultcaches):TestForwardContentResult_StoresFullContentInCache— single TextContent block triggers exactly oneStorecall with the full payload, correct key, tool name, args, andtotalRecordsTestForwardContentResult_NoCacheStoreNoPanic—nilcache store stays safe for legacy callersTestForwardContentResult_FallbackPathStoresCache— the non-*CallToolResultJSON-wrap fallback caches tooTestForwardContentResult_RoundTripViaRealCacheManager— real BBolt-backedcache.Managerend-to-end;GetRecords(0,10)andGetRecords(10,10)return distinct, correctly-ordered pages by idCommit 2 (
read_cacherecursive):TestMaybeTruncateAndCacheText_HappyPathStores— 30 records over the limit triggers exactly oneStorecarrying the full bodyTestMaybeTruncateAndCacheText_NoRecurseOnSingleRecord— single oversize record short-circuits; no Store call, body returned as-isTestMaybeTruncateAndCacheText_NoOpUnderLimit— under the limit, helper is a no-op (no banner, no cache churn)TestMaybeTruncateAndCacheText_RecursiveRoundTripViaRealCacheManager— wires the real BBoltcache.Manager: a 40-record response gets truncated, the new key is parsed out of the banner,GetRecords(newKey, 0, 5)returns the same records 0..4 in order. Proves recursion is stable.All 4 pre-existing
TestForwardContentResult_*tests still pass (the newCacheStoreparam isnilfor them).Test plan
go build ./...go test ./internal/server/ -run 'TestForwardContentResult|TestMaybeTruncateAndCacheText' -v— 12 tests, all greengo test ./internal/cache/... ./internal/truncate/...— unchanged, still passHaloCollar/halo-tools): build, restart proxy, callgithub:pull_request_read get_reviewson a PR with a >20 KB review body, then callread_cachewith the embedded key — returns the full record body. Callread_cacheagain with a highlimiton a many-record payload — gets re-truncated with a fresh key, follow-up walks paginate correctly.Notes
No MCP API surface change. No config flag. No backwards-compat shim — the previous behavior was straight-up broken in both layers, and the fix only affects the truncation/pagination path.
The local
CacheStoreinterface andmaybeTruncateAndCacheTexthelper give other built-in tools (tail_log,retrieve_tools, etc.) a one-line adoption path if they want the same guarantee. Not done here to keep scope tight.🤖 Generated with Claude Code