Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 63 additions & 6 deletions agent/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

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 unconditionally removes a supported schema-cache marker. Current main deliberately forwards it at agent/anthropic_adapter.py:1690-1695 to cache the tool schema cross-session. Please enforce the four-marker budget without disabling schema caching for requests that do not exceed it.

# 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

Expand Down Expand Up @@ -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(

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 counts any dict-valued key named cache_control, including a valid JSON Schema field such as input_schema.properties.cache_control. If the budget is exceeded, _strip_first_cache_control() removes that schema property. Restrict detection to valid Anthropic cache-marker locations and add a schema-property regression test.

_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],
Expand Down Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions tests/agent/test_prompt_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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