diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index e7e1a8acb6d5..4f2c4723cfa8 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1311,12 +1311,12 @@ def convert_tools_to_anthropic(tools: List[Dict]) -> List[Dict]: fn.get("parameters", {"type": "object", "properties": {}}) ), } - # Forward cache_control marker when present on the OpenAI-format - # tool dict. Anthropic's tools array supports cache_control on the - # last tool to cache the entire schema cross-session. - cache_control = t.get("cache_control") - if isinstance(cache_control, dict): - anthropic_tool["cache_control"] = dict(cache_control) + # Do not forward cache_control from OpenAI-format tool dicts here. + # Anthropic enforces a hard request-wide maximum of 4 cache_control + # blocks across system, messages, and tools. Hermes' prompt-caching + # strategy already spends that budget on system + recent messages; a + # tool-schema marker smuggled in through a tool/plugin path becomes the + # classic fifth marker and triggers HTTP 400. result.append(anthropic_tool) return result @@ -1865,6 +1865,61 @@ def convert_messages_to_anthropic( return system, result +def _strip_first_cache_control(value: Any) -> bool: + """Remove the first cache_control marker found in a nested request object. + + Mutates ``value`` in place. Returns True if a marker was removed, + False if none was found. + """ + if isinstance(value, dict): + if isinstance(value.get("cache_control"), dict): + value.pop("cache_control", None) + return True + for child in value.values(): + if _strip_first_cache_control(child): + return True + elif isinstance(value, list): + for child in value: + if _strip_first_cache_control(child): + return True + return False + + +def _count_cache_control(value: Any) -> int: + """Count cache_control markers recursively in an Anthropic request object.""" + if isinstance(value, dict): + return (1 if isinstance(value.get("cache_control"), dict) else 0) + sum( + _count_cache_control(v) for k, v in value.items() if k != "cache_control" + ) + if isinstance(value, list): + return sum(_count_cache_control(v) for v in value) + return 0 + + +def _enforce_anthropic_cache_control_budget(kwargs: Dict[str, Any], *, budget: int = 4) -> None: + """Ensure native Anthropic requests never exceed cache_control's hard cap. + + Anthropic counts cache_control blocks request-wide, across system, + messages, and tools. Prompt caching normally creates exactly four markers + (system + three recent messages). If a plugin, tool schema, or replayed + message smuggles in extras, the API rejects the whole request with: + + HTTP 400: A maximum of 4 blocks with cache_control may be provided. + + Prefer stripping tools first because tool-schema caching is currently not + part of Hermes' budgeted strategy. If the request is still over budget, + strip from older messages before touching the system prompt. + """ + if _count_cache_control(kwargs) <= budget: + return + + for section in ("tools", "messages", "system"): + while _count_cache_control(kwargs) > budget: + target = kwargs.get(section) + if target is None or not _strip_first_cache_control(target): + break + + def build_anthropic_kwargs( model: str, messages: List[Dict], @@ -2058,6 +2113,8 @@ def build_anthropic_kwargs( for _sampling_key in ("temperature", "top_p", "top_k"): kwargs.pop(_sampling_key, None) + _enforce_anthropic_cache_control_budget(kwargs) + # ── Fast mode (Opus 4.6 only) ──────────────────────────────────── # Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x # output speed. Per Anthropic docs, fast mode is only supported on diff --git a/tests/agent/test_prompt_caching.py b/tests/agent/test_prompt_caching.py index f6f3e9f0a388..a4d92ad4e491 100644 --- a/tests/agent/test_prompt_caching.py +++ b/tests/agent/test_prompt_caching.py @@ -3,6 +3,11 @@ import copy import pytest +from agent.anthropic_adapter import ( + _count_cache_control, + build_anthropic_kwargs, + convert_tools_to_anthropic, +) from agent.prompt_caching import ( _apply_cache_marker, apply_anthropic_cache_control, @@ -141,3 +146,73 @@ def test_max_4_breakpoints(self): elif "cache_control" in msg: count += 1 assert count <= 4 + + +class TestAnthropicAdapterCacheBudget: + def test_tool_cache_control_is_not_forwarded(self): + tools = [ + { + "type": "function", + "function": { + "name": "demo_tool", + "description": "demo", + "parameters": {"type": "object", "properties": {}}, + }, + "cache_control": {"type": "ephemeral"}, + } + ] + + converted = convert_tools_to_anthropic(tools) + + assert converted[0]["name"] == "demo_tool" + assert "cache_control" not in converted[0] + + def test_build_kwargs_enforces_request_wide_cache_control_cap(self): + marker = {"type": "ephemeral"} + messages = [ + {"role": "system", "content": [{"type": "text", "text": "system", "cache_control": marker}]}, + {"role": "user", "content": [{"type": "text", "text": "one", "cache_control": marker}]}, + {"role": "assistant", "content": [{"type": "text", "text": "two", "cache_control": marker}]}, + {"role": "user", "content": [{"type": "text", "text": "three", "cache_control": marker}]}, + {"role": "assistant", "content": [{"type": "text", "text": "four", "cache_control": marker}]}, + ] + + kwargs = build_anthropic_kwargs( + model="claude-sonnet-4-6", + messages=messages, + tools=[ + { + "type": "function", + "function": { + "name": "demo_tool", + "description": "demo", + "parameters": {"type": "object", "properties": {}}, + }, + "cache_control": marker, + } + ], + max_tokens=1024, + reasoning_config=None, + ) + + # Request-wide budget is respected. + assert _count_cache_control(kwargs) <= 4 + + # Tools are stripped first: tool-schema caching is not part of the + # budgeted placement strategy, so the tool's marker must be gone. + assert all( + "cache_control" not in tool for tool in kwargs.get("tools", []) + ) + + # Stripping prefers older messages over newer ones: the most recent + # message's marker must survive so prefix-cache hits on the live tail + # of the conversation are preserved. + last_message = kwargs["messages"][-1] + last_block = last_message["content"][-1] + assert last_block.get("cache_control") == marker + + # The system prompt is the last thing touched, so it should still + # carry its marker when tools + older messages absorbed the overflow. + system = kwargs.get("system") + if isinstance(system, list) and system: + assert system[0].get("cache_control") == marker