Skip to content

feat(caching): multi-block system prompt with tiered TTLs (v2) - #5713

Closed
Deland78 wants to merge 2 commits into
NousResearch:mainfrom
Deland78:feat/prompt-caching-v2
Closed

feat(caching): multi-block system prompt with tiered TTLs (v2)#5713
Deland78 wants to merge 2 commits into
NousResearch:mainfrom
Deland78:feat/prompt-caching-v2

Conversation

@Deland78

@Deland78 Deland78 commented Apr 7, 2026

Copy link
Copy Markdown

Summary

Refactor Anthropic prompt caching to use a structured multi-block system prompt with per-block cache_control markers instead of a single monolithic system message. This maximizes cache hits by isolating volatile content (timestamps, platform hints) from stable content (identity, skills, memory).

Architecture

The system prompt is now assembled as three SystemPromptBlock instances with different cache TTLs:

Block TTL Contents
static 1h Soul.md / default identity, tool-aware guidance (memory, session_search, skills), Nous subscription prompt, tool-use enforcement, model-specific operational guidance (Google/OpenAI), skills system prompt
session 5m Custom system_message, memory store blocks (memory + user), external memory provider block, context files (AGENTS.md/CLAUDE.md/etc.)
ephemeral none Timestamp + session/model/provider line, Alibaba identity workaround, platform hints

At API call time, blocks are converted to Anthropic content block format (`[{type: text, text: ..., cache_control: ...}, ...]`) and sent as the system message. Non-caching models fall through to the flat-string path unchanged.

New public API in `agent/prompt_caching.py`

  • `SystemPromptBlock`, `CacheMetrics`, `AggregatedCacheMetrics` dataclasses
  • `build_system_content_blocks(blocks)` — convert blocks to Anthropic format
  • `apply_anthropic_cache_control_v2(messages, tools, cache_ttl, native_anthropic)` — multi-block + tool caching with budget management (max 4 breakpoints across tools + system + messages)
  • `extract_cache_metrics(usage, api_mode)` — per-call cache extraction supporting both native Anthropic (`cache_read_input_tokens`, `cache_creation_input_tokens`) and OpenRouter (`prompt_tokens_details.cached_tokens`) response formats
  • `aggregate_cache_metrics(metrics_list)` — cross-turn aggregation

The v1 `apply_anthropic_cache_control` function and `_apply_cache_marker` helper are preserved unchanged for backward compatibility.

Integration in `run_agent.py`

  • New `_build_system_prompt_blocks()` method assembles the three tiered blocks and caches them on `self._cached_system_blocks`
  • The existing `_build_system_prompt()` method still returns a flat string (for backward compatibility with code paths that expect one) but now delegates to the block builder
  • Cached blocks are invalidated on context compression (`_cached_system_blocks = None` alongside `_cached_system_prompt = None`)
  • At API call time, when `_use_prompt_caching` is enabled and `_cached_system_blocks` is populated, a multi-block path builds `{role: system, content: [...]}` with cache_control markers already set per block
  • Plugin turn context (`_plugin_turn_context`) remains reserved for future system-level plugin instructions; plugin context from pre_llm_call hooks still goes into user messages (unchanged)
  • Fallback flat-string path handles non-caching models and pre-structured content correctly

Test coverage

  • `tests/agent/test_prompt_caching.py` — 46 unit tests covering v1 (preserved) and v2 functions: data structures, cache markers, content block conversion, pre-structured detection, breakpoint budgeting, metrics extraction and aggregation
  • `tests/agent/test_prompt_caching_v2.py` — 38 additional integration tests for v2 behavior (tool caching interaction with system blocks, budget with pre-structured content, backward compatibility with v1 code paths)
  • `tests/test_prompt_caching_integration.py` — 10 integration tests against `run_agent.py` block assembly (three-block structure, tier TTLs, timestamp in ephemeral block only, cache invalidation, backward-compat string return, non-caching models unaffected)

Verified: 317 tests passing (all of the above plus `tests/test_run_agent.py` regression suite).

Test plan

  • All new v2 unit tests pass (`pytest tests/agent/test_prompt_caching.py tests/agent/test_prompt_caching_v2.py`)
  • Integration tests against `run_agent.py` block assembly pass (`pytest tests/test_prompt_caching_integration.py`)
  • Full run_agent.py regression suite passes (`pytest tests/test_run_agent.py`)
  • `run_agent` imports cleanly
  • Manual: verify cache hit rate improves on a multi-turn conversation with stable context files (reviewer action)
  • Manual: verify non-caching models (e.g. local Ollama) still work via flat-string fallback (reviewer action)

Platforms tested

Linux (WSL2, Ubuntu 22.04), Python 3.11

🤖 Generated with Claude Code

Deland78 and others added 2 commits April 6, 2026 21:50
Refactor prompt caching to use structured SystemPromptBlocks with
per-block cache_control markers instead of a single monolithic system
prompt. This maximizes Anthropic prompt cache hits by isolating volatile
content (timestamps, platform hints) from stable content (identity,
skills, memory).

Architecture:
  - static block  (1h TTL): identity, tool guidance, skills, model-specific
                            guidance — cross-session stable
  - session block (5m TTL): memory, context files, custom system_message —
                            session-stable
  - ephemeral block (none): timestamp, platform hints, alibaba workaround —
                            changes per-turn

New public API in agent/prompt_caching.py:
  - SystemPromptBlock, CacheMetrics, AggregatedCacheMetrics dataclasses
  - build_system_content_blocks() — convert blocks to Anthropic format
  - apply_anthropic_cache_control_v2() — multi-block + tool caching
  - extract_cache_metrics() — per-call cache extraction (native + OpenRouter)
  - aggregate_cache_metrics() — cross-turn aggregation

In run_agent.py:
  - _build_system_prompt_blocks() assembles the three tiered blocks and
    caches them on self._cached_system_blocks
  - At API call time, blocks are converted to content blocks with
    cache_control markers and sent as the system message
  - Falls back to flat-string path for non-caching models
  - Plugin context stays in user messages (unchanged from v1)

Test coverage:
  - tests/agent/test_prompt_caching.py — 46 unit tests covering all v2
    functions (data structures, marker building, content block conversion,
    pre-structured detection, breakpoint budgeting, metrics)
  - tests/agent/test_prompt_caching_v2.py — 38 additional tests for v2
    integration (tool caching, budget interaction, backward compat)
  - tests/test_prompt_caching_integration.py — 10 integration tests against
    run_agent.py block assembly (tier structure, cache invalidation,
    backward compat with v1 code paths)

Verified: 317 tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@markojak

Copy link
Copy Markdown

Linking this into the #17459 direction.

The overall cache architecture here may still be useful, but please keep it aligned with the simpler rule from #17459/#17476: stable cached prompt/cacheable prefix, volatile current time in ephemeral runtime/user-message/tool context.

This PR should not be required as a prerequisite for fixing the immediate duplicate-tool cache bug (#17335), and it should not introduce hidden quiet-hours/control-plane policy.

@alt-glitch alt-glitch added type/perf Performance improvement or optimization P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API labels Apr 29, 2026

@teknium1 teknium1 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.

Thanks for the structured caching work. The idea remains relevant to the #17459 cache-direction discussion, but this implementation has two blocking wiring issues and now needs a substantial port.

Problems

  • run_agent.py:9231 stores _cached_tools, but the PR head contains no read of _current_cached_tools; request construction still passes self.tools at run_agent.py:6870, 6889, 6911, 8903, and 8949. The tool cache_control marker is therefore not sent.
  • agent/prompt_caching.py:264-267 allocates breakpoints to all non-system messages. Current main's agent/prompt_caching.py:52-73 and 110-117 filters empty envelope-layout assistant/tool messages because they cannot carry an effective marker; v2 needs the same rule.
  • Main has moved this implementation surface: prompt assembly is now agent/system_prompt.py:504-527 and request assembly is agent/conversation_loop.py:832-894.

Suggested changes

  • Wire the copied marked tools into the actual API payload and test the transmitted payload.
  • Reuse the cache-carrier predicate before budgeting v2 markers, including empty assistant/tool regression cases.
  • Port the narrowed design onto the current prompt and conversation modules, consistent with #17459's stable-prompt/volatile-context rule.

Automated hermes-sweeper review.

Comment thread run_agent.py
api_messages, _cached_tools = cache_result
# Tools with cache_control are passed separately to the API
# Store temporarily for this API call
self._current_cached_tools = _cached_tools

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.

_current_cached_tools is only assigned in this PR. The request builders still pass self.tools, so the marked copy never reaches the provider and tool-definition caching is ineffective. Thread this per-call copy into the actual API payload and add a payload-level integration test.

Comment thread agent/prompt_caching.py
# --- Message caching: remaining budget goes to last N non-system messages ---
remaining = max(0, 4 - breakpoints_used)
if remaining > 0:
non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"]

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.

This allocates slots to empty assistant/tool messages on OpenRouter even though _apply_cache_marker cannot place an effective envelope marker there. Filter candidates with the current _can_carry_marker rule before calculating the rolling tail, otherwise cacheable later messages lose breakpoint budget.

@teknium1 teknium1 added sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
@kshitijk4poor

Copy link
Copy Markdown
Collaborator

Thanks for this — closing after a full review, and the reasoning deserves the detail because the work itself was ahead of its time.

Since April, main independently shipped the parts of this design that survived contact with production:

  • Tool-array caching + static-prefix 1h markers landed via feat(prompt-cache): cross-session 1h prefix cache for Claude on Anthropic / OpenRouter / Nous Portal #23828 (build_prompt_cache_plan / _apply_system_cache_markers in agent/prompt_caching.py) — tools[-1] + stable system prefix get cross-session markers, volatile content rides unmarked, same breakpoint budgeting your v2 proposed.
  • Cache metrics + user-facing hit-rate display exist in agent/usage_pricing.py (canonical usage normalization across Anthropic/OpenRouter/Codex shapes) and the 💾 cache line in agent/conversation_loop.py.

The core of this PR — the multi-block system message with a per-turn-ticking block (timestamp/platform hints) inside it — is the one part main tried and then deliberately removed. b06e999 (#24778) killed the multi-block layout after live wire-format diffing showed the volatile block's bytes mutating mid-session flipped the system-block sha at minute boundaries and dropped cached_tokens to 0 on those turns (66.6% cumulative hit rate vs 83.3% for the single-block layout). "System prompt byte-stable for the life of a conversation" is now a hard invariant in AGENTS.md, so this architecture can't come back in this shape.

The branch is also ~16k commits behind with heavy churn in every touched file (run_agent.py alone has 541 intervening commits), so there's no salvage path — the good ideas are already on main via #23828, and the remaining delta is the retired layout.

Appreciate the thorough test coverage here (76 tests) — the ideas were right; production data just picked a different winner for the layout. 🙏

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/anthropic Anthropic native Messages API sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/perf Performance improvement or optimization

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants