Skip to content

fix(truncate): persist + recursively cache truncated payloads so read_cache pagination works - #480

Merged
Dumbris merged 3 commits into
smart-mcp-proxy:mainfrom
HaloCollar:upstream-fix/truncate-cache-store
May 19, 2026
Merged

Dumbris merged 3 commits into
smart-mcp-proxy:mainfrom
HaloCollar:upstream-fix/truncate-cache-store

Conversation

@electrolobzik

@electrolobzik electrolobzik commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes two related bugs in the truncate-and-cache pipeline that together broke read_cache pagination for any response large enough to need it. Two commits, each independently testable.

1. forwardContentResult never stored the truncated payload

When an upstream tool call exceeded tool_response_limit (default 20 KB), the truncator generated a CacheKey and 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 subsequent read_cache call returned "cache key not found". The cache + pagination plumbing (cache.Manager.Store and Manager.GetRecords) was already there and correct — the truncation path simply skipped the Store step.

Real-world impact: github:pull_request_read get_reviews on a PR with a long CodeRabbit review (~70 KB body) gives a truncated banner pointing at a cache key that resolves to nothing.

forwardContentResult now takes an optional CacheStore (a small local interface mirroring cache.Manager.Store) and persists the original text under the truncator-generated key whenever TruncationResult.CacheAvailable is true. Both call sites in internal/server/mcp.go now thread p.cacheManager through.

The interface is defined locally to keep internal/server/content_forward.go decoupled from internal/cache. Callers that don't have a cache plumbed through (e.g. existing tests) can pass nil — that path is exercised by TestForwardContentResult_NoCacheStoreNoPanic.

2. read_cache's own output bypassed the truncator

Once commit 1 made read_cache actually work, the next-level bug surfaced: read_cache itself returns mcp.NewToolResultText(jsonResult) directly, bypassing forwardContentResult entirely. 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 handleReadCache produces 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 maybeTruncateAndCacheText lives next to forwardContentResult so 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 when paginableUnits <= 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 (forwardContentResult caches):

  • TestForwardContentResult_StoresFullContentInCache — single TextContent block triggers exactly one Store call with the full payload, correct key, tool name, args, and totalRecords
  • TestForwardContentResult_NoCacheStoreNoPanic — nil cache store stays safe for legacy callers
  • TestForwardContentResult_FallbackPathStoresCache — the non-*CallToolResult JSON-wrap fallback caches too
  • TestForwardContentResult_RoundTripViaRealCacheManager — real BBolt-backed cache.Manager end-to-end; GetRecords(0,10) and GetRecords(10,10) return distinct, correctly-ordered pages by id

Commit 2 (read_cache recursive):

  • TestMaybeTruncateAndCacheText_HappyPathStores — 30 records over the limit triggers exactly one Store carrying the full body
  • TestMaybeTruncateAndCacheText_NoRecurseOnSingleRecord — single oversize record short-circuits; no Store call, body returned as-is
  • TestMaybeTruncateAndCacheText_NoOpUnderLimit — under the limit, helper is a no-op (no banner, no cache churn)
  • TestMaybeTruncateAndCacheText_RecursiveRoundTripViaRealCacheManager — wires the real BBolt cache.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 new CacheStore param is nil for them).

Test plan

  • go build ./...
  • go test ./internal/server/ -run 'TestForwardContentResult|TestMaybeTruncateAndCacheText' -v — 12 tests, all green
  • go test ./internal/cache/... ./internal/truncate/... — unchanged, still pass
  • Manual live verification (already done against PR Enhance macOS support in GitHub Actions workflow and add autostart functionality #5 of HaloCollar/halo-tools): build, restart proxy, call github:pull_request_read get_reviews on a PR with a >20 KB review body, then call read_cache with the embedded key — returns the full record body. Call read_cache again with a high limit on 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 CacheStore interface and maybeTruncateAndCacheText helper 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

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
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 electrolobzik changed the title fix(truncate): persist full payloads so read_cache pagination works fix(truncate): persist + recursively cache truncated payloads so read_cache pagination works May 18, 2026
@electrolobzik
electrolobzik marked this pull request as ready for review May 19, 2026 06:37
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 electrolobzik left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 Dumbris left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 (GenerateKey is second-granularity on toolName:args), so later blocks overwrite earlier ones.
  • "Idempotent" / "bounded recursion" wording is slightly stronger than the timestamped-key behavior guarantees.
  • read_cache recursive truncation isn't fed into the truncation token metrics.

None of these block the fix. Merging.

@Dumbris
Dumbris merged commit 95d09b0 into smart-mcp-proxy:main May 19, 2026
22 checks passed
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants