Bug Description
On the OpenRouter/envelope cache layout (anthropic_prompt_cache_policy() → (True, False)), the system_and_3 strategy in agent/prompt_caching.py places its "last 3 non-system messages" breakpoints on messages that cannot carry an effective marker during agentic tool loops:
role: "tool" messages are skipped entirely. _apply_cache_marker() returns without doing anything for tool messages when native_anthropic=False. The existing test documents why: a top-level cache_control on role: "tool" is invalid on OpenRouter and causes a silent hang. But a marker inside a content part of a tool message is accepted and honored — verified live, details below.
- Assistant messages with empty content (pure
tool_calls turns) get msg["cache_control"] at the message top level, which OpenRouter ignores on the OpenAI-wire path.
In an agentic loop, the trailing messages are almost always tool results and empty assistant tool-call turns — so all three conversation breakpoints silently no-op, and only the system-prompt breakpoint is effective. The entire conversation (including large tool results) is re-billed at full input price on every API call until an assistant text message happens to land in the last-3 window.
Impact: roughly 2× input-token cost on every tool-heavy session via OpenRouter + Claude. In the session where we found this, ~150K tokens (~60% of session cost) were re-billed at full price across 7 consecutive calls.
Related: #20957 reports "caching not applied at all" on OpenRouter + Claude chat_completions. Our data partially contradicts that: markers in content parts (system prompt, user messages, assistant text messages — the existing string-content wrapping path) do produce real cache hits through OpenRouter. The gap is specifically tool messages and empty assistant messages. Possibly also related: #56776.
Steps to Reproduce
- Configure a Claude model via OpenRouter (
provider: openrouter, model: anthropic/claude-sonnet-5, chat_completions).
- Run any session that triggers a multi-call tool loop (e.g. a task with 8–12
terminal/search_files calls).
- Watch the per-call
cache=X/Y figures in agent.log.
Observed pattern from a real 14-call session (Sonnet-5 via OpenRouter):
| Calls |
Input tokens |
Cached |
Note |
| 2–4 |
26.5–28K |
25,998 (98→93%) |
system-prompt breakpoint works |
| 5–11 |
45–50K |
25,998 (58→52%) |
cache pinned at the system prompt for 7 calls; ~20–23K re-billed full price each call |
| 12–14 |
50K |
49,136–50,174 (98–100%) |
cache advances only after assistant text messages reach the last-3 window |
Expected Behavior
Conversation breakpoints should land on messages the provider actually honors, so the growing prefix (including tool results) is cache-read on subsequent calls.
Actual Behavior
During tool loops, only the system-prompt breakpoint is effective; the conversation is re-sent uncached on every call.
Verification that the fix direction is safe (live against OpenRouter)
Two experiments against openrouter.ai/api/v1/chat/completions with anthropic/claude-haiku-4.5, marker placed inside a content part of a role: "tool" message ("content": [{"type": "text", "text": ..., "cache_control": {"type": "ephemeral"}}]):
- Hand-built payload: request 1
cached=0, cache_write=40771; identical request 2 cached=40771, cost $0.051 → $0.004. No hang, no 400.
- Same conversation shaped like a real Hermes tool loop, run through the patched
apply_anthropic_cache_control(): request 2 cached=28572/28578, cost $0.036 → $0.003.
So the historical hang applies only to top-level cache_control on tool messages; the content-part form is safe and effective.
Proposed Fix
Two changes in agent/prompt_caching.py (patch below, tests included — 17/17 pass):
- In
_apply_cache_marker(): on the envelope layout, wrap a tool message's string content as [{"type": "text", "text": ..., "cache_control": ...}] (same as user messages) instead of skipping. Never set top-level cache_control on tool messages; skip only when the tool message has no content to carry the marker.
- New
_can_carry_marker() used by apply_anthropic_cache_control(): on the envelope layout, exclude messages that can only take a top-level marker (empty-content assistant/tool messages) from last-3 selection, so breakpoints land on messages that count. Native layout behavior is unchanged.
diff --git a/agent/prompt_caching.py b/agent/prompt_caching.py
index 0000000..0000000 100644
--- a/agent/prompt_caching.py
+++ b/agent/prompt_caching.py
@@ -15,16 +15,25 @@ from typing import Any, Dict, List
def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool = False) -> None:
"""Add cache_control to a single message, handling all format variations."""
role = msg.get("role", "")
content = msg.get("content")
- if role == "tool":
- if native_anthropic:
- msg["cache_control"] = cache_marker
+ if role == "tool" and native_anthropic:
+ # Native Anthropic layout: top-level marker; the adapter moves it
+ # inside the tool_result block.
+ msg["cache_control"] = cache_marker
return
if content is None or content == "":
+ if role == "tool":
+ # OpenRouter rejects top-level cache_control on role:tool (silent
+ # hang) and an empty message has no content part to carry the
+ # marker — skip. Non-empty tool content falls through below and
+ # gets the marker on a content part, which OpenRouter honors
+ # (verified live: full cache read on the second request).
+ return
msg["cache_control"] = cache_marker
return
if isinstance(content, str):
msg["content"] = [
{"type": "text", "text": content, "cache_control": cache_marker}
]
return
if isinstance(content, list) and content:
last = content[-1]
if isinstance(last, dict):
last["cache_control"] = cache_marker
+def _can_carry_marker(msg: dict, native_anthropic: bool) -> bool:
+ """True if a marker on this message is actually honored by the provider.
+
+ On the native Anthropic layout every message works (top-level markers are
+ relocated by the adapter). On the envelope layout (OpenRouter et al.) only
+ markers inside content parts are honored: empty-content messages (e.g.
+ assistant turns that are pure tool_calls) and empty tool messages would
+ receive a top-level marker the provider ignores — wasting one of the four
+ breakpoints. Skip those so the breakpoints land on messages that count.
+ """
+ if native_anthropic:
+ return True
+ content = msg.get("content")
+ if content is None or content == "":
+ return False
+ if isinstance(content, list):
+ return any(isinstance(part, dict) for part in content)
+ return isinstance(content, str)
+
+
def _build_marker(ttl: str) -> Dict[str, str]:
@@ -71,9 +80,14 @@ def apply_anthropic_cache_control(
if messages[0].get("role") == "system":
_apply_cache_marker(messages[0], marker, native_anthropic=native_anthropic)
breakpoints_used += 1
remaining = 4 - breakpoints_used
- non_sys = [i for i in range(len(messages)) if messages[i].get("role") != "system"]
+ non_sys = [
+ i
+ for i in range(len(messages))
+ if messages[i].get("role") != "system"
+ and _can_carry_marker(messages[i], native_anthropic)
+ ]
for idx in non_sys[-remaining:]:
_apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)
return messages
Test changes: test_tool_message_skips_marker_on_openrouter → asserts content-part wrapping (and that tool_call_id and top-level are untouched); new tests for empty-tool skip, envelope breakpoint selection skipping empty assistant turns, and native-layout behavior unchanged.
One consideration for maintainers: the qwen/OpenCode envelope paths share this function. The policy docstring in agent_runtime_helpers.py already describes the envelope layout as "markers on inner content parts", so this change aligns the tool-message behavior with that contract, but we only live-tested the OpenRouter→Anthropic route.
Happy to open a PR with the patch + tests if useful.
Affected Component
Agent core (agent/prompt_caching.py)
Version
Hermes Agent v0.18.0 (2026.7.1), upstream 30e947e0, macOS (darwin arm64), provider OpenRouter, model anthropic/claude-sonnet-5.
Bug Description
On the OpenRouter/envelope cache layout (
anthropic_prompt_cache_policy()→(True, False)), thesystem_and_3strategy inagent/prompt_caching.pyplaces its "last 3 non-system messages" breakpoints on messages that cannot carry an effective marker during agentic tool loops:role: "tool"messages are skipped entirely._apply_cache_marker()returns without doing anything for tool messages whennative_anthropic=False. The existing test documents why: a top-levelcache_controlonrole: "tool"is invalid on OpenRouter and causes a silent hang. But a marker inside a content part of a tool message is accepted and honored — verified live, details below.tool_callsturns) getmsg["cache_control"]at the message top level, which OpenRouter ignores on the OpenAI-wire path.In an agentic loop, the trailing messages are almost always tool results and empty assistant tool-call turns — so all three conversation breakpoints silently no-op, and only the system-prompt breakpoint is effective. The entire conversation (including large tool results) is re-billed at full input price on every API call until an assistant text message happens to land in the last-3 window.
Impact: roughly 2× input-token cost on every tool-heavy session via OpenRouter + Claude. In the session where we found this, ~150K tokens (~60% of session cost) were re-billed at full price across 7 consecutive calls.
Related: #20957 reports "caching not applied at all" on OpenRouter + Claude
chat_completions. Our data partially contradicts that: markers in content parts (system prompt, user messages, assistant text messages — the existing string-content wrapping path) do produce real cache hits through OpenRouter. The gap is specifically tool messages and empty assistant messages. Possibly also related: #56776.Steps to Reproduce
provider: openrouter,model: anthropic/claude-sonnet-5,chat_completions).terminal/search_filescalls).cache=X/Yfigures inagent.log.Observed pattern from a real 14-call session (Sonnet-5 via OpenRouter):
Expected Behavior
Conversation breakpoints should land on messages the provider actually honors, so the growing prefix (including tool results) is cache-read on subsequent calls.
Actual Behavior
During tool loops, only the system-prompt breakpoint is effective; the conversation is re-sent uncached on every call.
Verification that the fix direction is safe (live against OpenRouter)
Two experiments against
openrouter.ai/api/v1/chat/completionswithanthropic/claude-haiku-4.5, marker placed inside a content part of arole: "tool"message ("content": [{"type": "text", "text": ..., "cache_control": {"type": "ephemeral"}}]):cached=0, cache_write=40771; identical request 2cached=40771, cost $0.051 → $0.004. No hang, no 400.apply_anthropic_cache_control(): request 2cached=28572/28578, cost $0.036 → $0.003.So the historical hang applies only to top-level
cache_controlon tool messages; the content-part form is safe and effective.Proposed Fix
Two changes in
agent/prompt_caching.py(patch below, tests included — 17/17 pass):_apply_cache_marker(): on the envelope layout, wrap a tool message's string content as[{"type": "text", "text": ..., "cache_control": ...}](same as user messages) instead of skipping. Never set top-levelcache_controlon tool messages; skip only when the tool message has no content to carry the marker._can_carry_marker()used byapply_anthropic_cache_control(): on the envelope layout, exclude messages that can only take a top-level marker (empty-content assistant/tool messages) from last-3 selection, so breakpoints land on messages that count. Native layout behavior is unchanged.Test changes:
test_tool_message_skips_marker_on_openrouter→ asserts content-part wrapping (and thattool_call_idand top-level are untouched); new tests for empty-tool skip, envelope breakpoint selection skipping empty assistant turns, and native-layout behavior unchanged.One consideration for maintainers: the qwen/OpenCode envelope paths share this function. The policy docstring in
agent_runtime_helpers.pyalready describes the envelope layout as "markers on inner content parts", so this change aligns the tool-message behavior with that contract, but we only live-tested the OpenRouter→Anthropic route.Happy to open a PR with the patch + tests if useful.
Affected Component
Agent core (
agent/prompt_caching.py)Version
Hermes Agent v0.18.0 (2026.7.1), upstream
30e947e0, macOS (darwin arm64), provider OpenRouter, modelanthropic/claude-sonnet-5.