{"repo":"NousResearch/hermes-agent","public_id":"2362","display_label":"OpenRouter prompt caching cache_control placement bugs","summary":"Multiple bug reports and fixes addressing invalid or misplaced top-level cache_control fields on tool and empty assistant messages when using OpenRouter with Claude, which caused hangs and ineffective cache breakpoints during tool loops.","subsystem":"agent","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2391},"resolution":{"state":"resolved","by":"pr-2391,pr-58129"},"freshness":{"state":"verified","last_verified_at":"2026-07-04T08:24:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20444444444444446},"fix_edges":[{"issue_number":2362,"pr_number":2391,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":20957,"pr_number":58129,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57845,"pr_number":58129,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":2362,"title":"[Bug]: OpenRouter + Claude — hangs on second tool call due to cache_control injected at top level of role:tool messages","url":null,"state":"closed"},{"kind":"issue","number":57845,"title":"[Bug]: Envelope-layout cache breakpoints silently no-op during tool loops (tool messages skipped, empty-assistant markers ignored) — ~2x input cost on OpenRouter + Claude","url":null,"state":"closed"},{"kind":"pr","number":2370,"title":"fix:  OpenRouter + Claude — hangs on second tool call due to cac","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2390","number":2390},{"kind":"pr","value":"#2362","number":2362}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":2373,"title":"fix(prompt-caching): skip top-level cache_control on role:tool for OpenRouter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2391","number":2391}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":2391,"title":"fix(prompt-caching): skip top-level cache_control on role:tool for OpenRouter","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_caching.py","run_agent.py","tests/agent/test_prompt_caching.py","tests/test_anthropic_adapter.py"]},{"kind":"pr","number":57876,"title":"fix(prompt-caching): skip invalid top-level cache_control on empty/None assistant and tool messages on OpenRouter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58129","number":58129},{"kind":"pr","value":"#57893","number":57893}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":57893,"title":"fix(prompt-caching): skip invalid top-level cache_control on empty assistant/tool messages on OpenRouter","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8b797f7a7","number":0},{"kind":"commit","value":"52cf9dbad","number":0},{"kind":"commit","value":"8229d7765","number":0},{"kind":"pr","value":"#58129","number":58129},{"kind":"pr","value":"#58129","number":58129}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58129,"title":"fix(prompt-caching): land envelope cache breakpoints on honored carriers during tool loops (#57845)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/prompt_caching.py","scripts/release.py","tests/agent/test_prompt_caching.py"],"fix_hunks":[{"file":"agent/prompt_caching.py","hunks":["@@ -17,12 +17,23 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool =\n     role = msg.get(\"role\", \"\")\n     content = msg.get(\"content\")\n \n-    if role == \"tool\":\n-        if native_anthropic:\n-            msg[\"cache_control\"] = cache_marker\n+    if role == \"tool\" and native_anthropic:\n+        # Native Anthropic layout: top-level marker; the adapter moves it\n+        # inside the tool_result block.\n+        msg[\"cache_control\"] = cache_marker\n         return\n \n     if content is None or content == \"\":\n+        if role == \"tool\" and not native_anthropic:\n+            # OpenRouter rejects top-level cache_control on role:tool (silent\n+            # hang) and an empty message has no content part to carry the\n+            # marker — skip. Non-empty tool content falls through below and\n+            # gets the marker on a content part, which OpenRouter honors.\n+            return\n+        if role == \"assistant\" and not native_anthropic:\n+            # Empty assistant turns are pure tool_calls. A top-level marker\n+            # here is ignored on the envelope layout, so skip.\n+            return\n         msg[\"cache_control\"] = cache_marker\n         return\n ","@@ -38,6 +49,30 @@ def _apply_cache_marker(msg: dict, cache_marker: dict, native_anthropic: bool =\n             last[\"cache_control\"] = cache_marker\n \n \n+def _can_carry_marker(msg: dict, native_anthropic: bool) -> bool:\n+    \"\"\"True if a marker on this message is actually honored by the provider.\n+\n+    On the native Anthropic layout every message works (top-level markers are\n+    relocated by the adapter). On the envelope layout (OpenRouter et al.) only\n+    markers inside content parts are honored: empty-content messages (e.g.\n+    assistant turns that are pure tool_calls) and empty tool messages would\n+    receive a top-level marker the provider ignores — wasting one of the four\n+    breakpoints. Skip those so the breakpoints land on messages that count.\n+    \"\"\"\n+    if native_anthropic:\n+        return True\n+    content = msg.get(\"content\")\n+    if content is None or content == \"\":\n+        return False\n+    if isinstance(content, list):\n+        # _apply_cache_marker only marks the LAST content part, so the carrier\n+        # predicate must agree: a list whose last element isn't a dict cannot\n+        # actually receive a marker and would waste a breakpoint. Mirror the\n+        # `content` truthiness + last-element-dict check in _apply_cache_marker.\n+        return bool(content) and isinstance(content[-1], dict)\n+    return isinstance(content, str)\n+\n+\n def _build_marker(ttl: str) -> Dict[str, str]:\n     \"\"\"Build a cache_control marker dict for the given TTL ('5m' or '1h').\"\"\"\n     marker: Dict[str, str] = {\"type\": \"ephemeral\"}","@@ -72,7 +107,12 @@ def apply_anthropic_cache_control(\n         breakpoints_used += 1\n \n     remaining = 4 - breakpoints_used\n-    non_sys = [i for i in range(len(messages)) if messages[i].get(\"role\") != \"system\"]\n+    non_sys = [\n+        i\n+        for i in range(len(messages))\n+        if messages[i].get(\"role\") != \"system\"\n+        and _can_carry_marker(messages[i], native_anthropic=native_anthropic)\n+    ]\n     for idx in non_sys[-remaining:]:\n         _apply_cache_marker(messages[idx], marker, native_anthropic=native_anthropic)\n "]},{"file":"scripts/release.py","hunks":["@@ -1857,6 +1857,7 @@\n     \"[redacted:email]\": \"wnuuee1\",  # commit 9fd2b2cb PR author\n     \"[redacted:email]\": \"Eji4h\",  # direct email match\n     \"[redacted:email]\": \"designnotdrum\",  # PR #56480 salvage (toolset static-inference fix)\n+    \"[redacted:email]\": \"LavyaTandel\",  # PR #57893 salvage local git identity (envelope-layout cache markers on tool/empty-assistant messages; #57845)\n }\n \n "]},{"file":"tests/agent/test_prompt_caching.py","hunks":["@@ -3,6 +3,7 @@\n \n from agent.prompt_caching import (\n     _apply_cache_marker,\n+    _can_carry_marker,\n     apply_anthropic_cache_control,\n )\n ","@@ -23,18 +24,37 @@ def test_tool_message_skips_marker_on_openrouter(self):\n         _apply_cache_marker(msg, MARKER, native_anthropic=False)\n         assert \"cache_control\" not in msg\n \n-    def test_none_content_gets_top_level_marker(self):\n-        msg = {\"role\": \"assistant\", \"content\": None}\n-        _apply_cache_marker(msg, MARKER)\n-        assert msg[\"cache_control\"] == MARKER\n+    def test_tool_message_wraps_non_empty_content_on_openrouter(self):\n+        \"\"\"Non-empty tool content should be wrapped so the marker lands on a content part.\"\"\"\n+        msg = {\"role\": \"tool\", \"content\": \"tool result bytes\"}\n+        _apply_cache_marker(msg, MARKER, native_anthropic=False)\n+        assert \"cache_control\" not in msg\n+        assert isinstance(msg[\"content\"], list)\n+        assert msg[\"content\"][0][\"cache_control\"] == MARKER\n \n-    def test_empty_string_content_gets_top_level_marker(self):\n-        \"\"\"Empty text blocks cannot have cache_control (Anthropic rejects them).\"\"\"\n+    def test_empty_assistant_message_skips_marker_on_openrouter(self):\n+        \"\"\"OpenRouter path: empty assistant turns are pure tool_calls, marker would be ignored.\"\"\"\n         msg = {\"role\": \"assistant\", \"content\": \"\"}\n-        _apply_cache_marker(msg, MARKER)\n+        _apply_cache_marker(msg, MARKER, native_anthropic=False)\n+        assert \"cache_control\" not in msg\n+\n+    def test_native_anthropic_empty_assistant_gets_top_level_marker(self):\n+        \"\"\"Native Anthropic layout can still carry top-level marker on empty content.\"\"\"\n+        msg = {\"role\": \"assistant\", \"content\": \"\"}\n+        _apply_cache_marker(msg, MARKER, native_anthropic=True)\n+        assert msg[\"cache_control\"] == MARKER\n+\n+    def test_none_content_skips_marker_on_openrouter(self):\n+        \"\"\"OpenRouter path: None-content assistant turns are ignored.\"\"\"\n+        msg = {\"role\": \"assistant\", \"content\": None}\n+        _apply_cache_marker(msg, MARKER, native_anthropic=False)\n+        assert \"cache_control\" not in msg\n+\n+    def test_none_content_gets_top_level_marker_on_native_anthropic(self):\n+        \"\"\"Native Anthropic path: None content still gets top-level marker.\"\"\"\n+        msg = {\"role\": \"assistant\", \"content\": None}\n+        _apply_cache_marker(msg, MARKER, native_anthropic=True)\n         assert msg[\"cache_control\"] == MARKER\n-        # Must NOT wrap into [{\"type\": \"text\", \"text\": \"\", \"cache_control\": ...}]\n-        assert msg[\"content\"] == \"\"\n \n     def test_string_content_wrapped_in_list(self):\n         msg = {\"role\": \"user\", \"content\": \"Hello\"}","@@ -63,6 +83,41 @@ def test_empty_list_content_no_crash(self):\n         _apply_cache_marker(msg, MARKER)\n \n \n+class TestCanCarryMarker:\n+    def test_native_anthropic_always_true(self):\n+        assert _can_carry_marker({\"role\": \"assistant\", \"content\": \"\"}, native_anthropic=True) is True\n+        assert _can_carry_marker({\"role\": \"tool\", \"content\": \"\"}, native_anthropic=True) is True\n+\n+    def test_openrouter_content_parts_carry_marker(self):\n+        assert _can_carry_marker({\"role\": \"user\", \"content\": \"text\"}, native_anthropic=False) is True\n+        assert _can_carry_marker({\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"a\"}]}, native_anthropic=False) is True\n+\n+    def test_openrouter_empty_or_none_does_not_carry_marker(self):\n+        assert _can_carry_marker({\"role\": \"assistant\", \"content\": \"\"}, native_anthropic=False) is False\n+        assert _can_carry_marker({\"role\": \"assistant\", \"content\": None}, native_anthropic=False) is False\n+        assert _can_carry_marker({\"role\": \"tool\", \"content\": \"result\"}, native_anthropic=False) is True\n+        assert _can_carry_marker({\"role\": \"tool\", \"content\": \"\"}, native_anthropic=False) is False\n+\n+    def test_openrouter_list_carrier_requires_last_part_dict(self):\n+        \"\"\"Carrier predicate must agree with _apply_cache_marker, which only marks\n+        the LAST content part. A list whose last element isn't a dict cannot carry\n+        a marker and must not consume a breakpoint.\"\"\"\n+        # Last part is a dict -> carrier.\n+        assert _can_carry_marker(\n+            {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"a\"}]},\n+            native_anthropic=False,\n+        ) is True\n+        # Last part is a non-dict (stray raw string) -> NOT a carrier, even though\n+        # an earlier part is a dict. Previously this passed the gate but got no\n+        # marker, wasting a breakpoint.\n+        assert _can_carry_marker(\n+            {\"role\": \"user\", \"content\": [{\"type\": \"text\", \"text\": \"a\"}, \"trailing raw\"]},\n+            native_anthropic=False,\n+        ) is False\n+        # Empty list -> not a carrier.\n+        assert _can_carry_marker({\"role\": \"user\", \"content\": []}, native_anthropic=False) is False\n+\n+\n class TestApplyAnthropicCacheControl:\n     def test_empty_messages(self):\n         result = apply_anthropic_cache_control([])","@@ -139,3 +194,32 @@ def test_max_4_breakpoints(self):\n             elif \"cache_control\" in msg:\n                 count += 1\n         assert count <= 4\n+\n+    def test_tool_loop_empty_assistant_and_tool_messages_do_not_consume_breakpoints(self):\n+        \"\"\"Tool loops should keep breakpoints on messages that can carry markers.\"\"\"\n+        msgs = [\n+            {\"role\": \"system\", \"content\": \"You are helpful\"},\n+            {\"role\": \"user\", \"content\": \"run tool 1\", \"cache_control\": MARKER},\n+            {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"type\": \"function\"}]},\n+            {\"role\": \"tool\", \"content\": \"tool result 1\"},\n+            {\"role\": \"user\", \"content\": \"run tool 2\", \"cache_control\": MARKER},\n+            {\"role\": \"assistant\", \"content\": \"\", \"tool_calls\": [{\"type\": \"function\"}]},\n+            {\"role\": \"tool\", \"content\": \"tool result 2\"},\n+        ]\n+        result = apply_anthropic_cache_control(msgs, native_anthropic=False)\n+        # Empty assistant/tool turns should not get broken markers\n+        assert \"cache_control\" not in result[2]\n+        assert \"cache_control\" not in result[3]\n+        assert \"cache_control\" not in result[5]\n+        assert \"cache_control\" not in result[6]\n+\n+    def test_tool_message_marker_lands_on_content_part_on_openrouter(self):\n+        \"\"\"Non-empty tool content should be wrapped so the marker lands on a content part.\"\"\"\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"tool\", \"content\": \"tool output\"},\n+        ]\n+        result = apply_anthropic_cache_control(msgs, native_anthropic=False)\n+        assert isinstance(result[1][\"content\"], list)\n+        assert result[1][\"content\"][0][\"cache_control\"] == {\"type\": \"ephemeral\"}\n+        assert \"cache_control\" not in result[1]\n"]}]},{"kind":"pr","number":58131,"title":"fix: place envelope cache breakpoints on messages providers actually honor","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58129","number":58129},{"kind":"pr","value":"#57845","number":57845},{"kind":"pr","value":"#58129","number":58129}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}