Skip to content

Commit 667d464

Browse files
committed
Enable prompt caching on AnthropicAgent (#23627)
* Add cache breakpoints to anthropic_client * Fix little bugs * Move imports only used for type checking to if TYPE_CHECKING
1 parent 0915d4a commit 667d464

2 files changed

Lines changed: 208 additions & 8 deletions

File tree

ddev/src/ddev/ai/agent/anthropic_client.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,35 @@
22
# All rights reserved
33
# Licensed under a 3-clause BSD style license (see LICENSE)
44

5-
from typing import Final
5+
from __future__ import annotations
6+
7+
from typing import TYPE_CHECKING, Final, overload
68

79
import anthropic
8-
from anthropic.types import MessageParam, ToolParam, ToolResultBlockParam
10+
from anthropic.types import MessageParam
911

1012
from ddev.ai.agent.base import BaseAgent
1113
from ddev.ai.agent.exceptions import AgentAPIError, AgentConnectionError, AgentError, AgentRateLimitError
1214
from ddev.ai.agent.types import AgentResponse, ContextUsage, StopReason, TokenUsage, ToolCall, ToolResultMessage
1315
from ddev.ai.tools.registry import ToolRegistry
1416

17+
if TYPE_CHECKING:
18+
from anthropic.types import (
19+
CacheControlEphemeralParam,
20+
TextBlockParam,
21+
ToolParam,
22+
ToolResultBlockParam,
23+
)
24+
1525
DEFAULT_MODEL: Final[str] = "claude-sonnet-4-6"
1626
DEFAULT_MAX_TOKENS: Final[int] = 8192 # max tokens per response
1727

28+
# 1h TTL for the static prefix (system + tools): paid once, read for the whole session.
29+
STATIC_CACHE_CONTROL: Final[CacheControlEphemeralParam] = {"type": "ephemeral", "ttl": "1h"}
30+
# Default TTL (currently 5 min, but Anthropic may change it) for the sliding breakpoint
31+
# on the last user message: re-written each turn, so a longer TTL would be wasted.
32+
SLIDING_CACHE_CONTROL: Final[CacheControlEphemeralParam] = {"type": "ephemeral"}
33+
1834

1935
class AnthropicAgent(BaseAgent[MessageParam]):
2036
"""A wrapper around the Anthropic API that provides a simple interface for interacting with agents."""
@@ -102,6 +118,32 @@ def _to_tool_result_params(self, messages: list[ToolResultMessage]) -> list[Tool
102118
for msg in messages
103119
]
104120

121+
@overload
122+
@staticmethod
123+
def _with_user_cache_breakpoint(content: str) -> list[TextBlockParam]: ...
124+
125+
@overload
126+
@staticmethod
127+
def _with_user_cache_breakpoint(content: list[ToolResultBlockParam]) -> list[ToolResultBlockParam]: ...
128+
129+
@staticmethod
130+
def _with_user_cache_breakpoint(
131+
content: str | list[ToolResultBlockParam],
132+
) -> list[TextBlockParam] | list[ToolResultBlockParam]:
133+
"""Return a block list with a sliding cache breakpoint on the last block."""
134+
if isinstance(content, str):
135+
return [{"type": "text", "text": content, "cache_control": SLIDING_CACHE_CONTROL}]
136+
if not content:
137+
return []
138+
return [*content[:-1], {**content[-1], "cache_control": SLIDING_CACHE_CONTROL}]
139+
140+
@staticmethod
141+
def _with_tools_cache_breakpoint(tool_defs: list[ToolParam]) -> list[ToolParam]:
142+
"""Return a tool list with a static cache breakpoint on the last tool."""
143+
if not tool_defs:
144+
return tool_defs
145+
return [*tool_defs[:-1], {**tool_defs[-1], "cache_control": STATIC_CACHE_CONTROL}]
146+
105147
async def send(
106148
self,
107149
content: str | list[ToolResultMessage],
@@ -114,19 +156,27 @@ async def send(
114156
Returns:
115157
An AgentResponse object containing the response from the agent.
116158
"""
117-
tool_defs = self._get_tool_definitions(allowed_tools)
159+
tool_defs = self._with_tools_cache_breakpoint(self._get_tool_definitions(allowed_tools))
118160

119161
api_content: str | list[ToolResultBlockParam] = (
120162
self._to_tool_result_params(content) if isinstance(content, list) else content
121163
)
122-
user_msg: MessageParam = {"role": "user", "content": api_content}
123-
messages = [*self._history, user_msg]
164+
user_msg_for_history: MessageParam = {"role": "user", "content": api_content}
165+
user_msg_for_request: MessageParam = {
166+
"role": "user",
167+
"content": self._with_user_cache_breakpoint(api_content),
168+
}
169+
messages = [*self._history, user_msg_for_request]
170+
171+
system_param: list[TextBlockParam] = [
172+
{"type": "text", "text": self._system_prompt, "cache_control": STATIC_CACHE_CONTROL}
173+
]
124174

125175
try:
126176
response = await self._client.messages.create(
127177
model=self._model,
128178
max_tokens=self._max_tokens,
129-
system=self._system_prompt,
179+
system=system_param,
130180
messages=messages,
131181
tools=tool_defs if tool_defs else anthropic.NOT_GIVEN,
132182
)
@@ -174,7 +224,9 @@ async def send(
174224
usage=usage,
175225
)
176226

177-
# Save to history only after a successful response.
178-
self._history.extend([user_msg, {"role": "assistant", "content": response.content}])
227+
# Save to history only after a successful response. Use the unmarked form so the
228+
# cache_control breakpoint is only ever on the latest user message — this keeps the
229+
# request below the 4-marker limit regardless of conversation length.
230+
self._history.extend([user_msg_for_history, {"role": "assistant", "content": response.content}])
179231

180232
return agent_response

ddev/tests/ai/agent/test_anthropic_client.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,3 +485,151 @@ async def test_error_mid_conversation_leaves_history_unchanged() -> None:
485485
await agent.send("Second message")
486486

487487
assert agent.history == history_after_first
488+
489+
490+
# ---------------------------------------------------------------------------
491+
# Prompt caching: static breakpoints (system + last tool, 1h TTL)
492+
# ---------------------------------------------------------------------------
493+
494+
495+
async def test_system_prompt_sent_as_block_with_static_cache_control() -> None:
496+
resp = make_response("end_turn", [make_text_block("ok")])
497+
agent, create_mock = make_agent(mock_response=resp)
498+
499+
await agent.send("Hi")
500+
501+
assert create_mock.call_args.kwargs["system"] == [
502+
{
503+
"type": "text",
504+
"text": "You are helpful.",
505+
"cache_control": {"type": "ephemeral", "ttl": "1h"},
506+
}
507+
]
508+
509+
510+
@pytest.mark.parametrize(
511+
"tool_names",
512+
[["only"], ["a", "b"], ["a", "b", "c", "d"]],
513+
ids=["single_tool", "two_tools", "four_tools"],
514+
)
515+
async def test_only_last_tool_carries_static_cache_control(tool_names: list[str]) -> None:
516+
registry = ToolRegistry([FakeTool(n) for n in tool_names])
517+
resp = make_response("end_turn", [make_text_block("ok")])
518+
agent, create_mock = make_agent(tools=registry, mock_response=resp)
519+
520+
await agent.send("Hi")
521+
522+
sent_tools = create_mock.call_args.kwargs["tools"]
523+
assert all("cache_control" not in t for t in sent_tools[:-1])
524+
assert sent_tools[-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
525+
526+
527+
async def test_allowed_tools_subset_places_cache_control_on_last_of_subset() -> None:
528+
registry = ToolRegistry([FakeTool(n) for n in ["a", "b", "c"]])
529+
resp = make_response("end_turn", [make_text_block("ok")])
530+
agent, create_mock = make_agent(tools=registry, mock_response=resp)
531+
532+
await agent.send("Hi", allowed_tools=["a", "b"])
533+
534+
sent_tools = create_mock.call_args.kwargs["tools"]
535+
assert [t["name"] for t in sent_tools] == ["a", "b"]
536+
assert "cache_control" not in sent_tools[0]
537+
assert sent_tools[-1]["cache_control"] == {"type": "ephemeral", "ttl": "1h"}
538+
539+
540+
# ---------------------------------------------------------------------------
541+
# Prompt caching: sliding breakpoint on the last user message block (default TTL)
542+
# ---------------------------------------------------------------------------
543+
544+
545+
@pytest.mark.parametrize(
546+
"content",
547+
[
548+
pytest.param("Hi there", id="str"),
549+
pytest.param(
550+
[ToolResultMessage(tool_call_id="t1", result=ToolResult(success=True, data="r1"))],
551+
id="single_tool_result",
552+
),
553+
pytest.param(
554+
[ToolResultMessage(tool_call_id=f"t{i}", result=ToolResult(success=True, data=f"r{i}")) for i in range(3)],
555+
id="multiple_tool_results",
556+
),
557+
],
558+
)
559+
async def test_sliding_cache_control_on_last_user_block_only(
560+
content: str | list[ToolResultMessage],
561+
) -> None:
562+
resp = make_response("end_turn", [make_text_block("ok")])
563+
agent, create_mock = make_agent(mock_response=resp)
564+
565+
await agent.send(content)
566+
567+
blocks = create_mock.call_args.kwargs["messages"][-1]["content"]
568+
assert isinstance(blocks, list) and blocks
569+
assert all("cache_control" not in b for b in blocks[:-1])
570+
assert blocks[-1]["cache_control"] == {"type": "ephemeral"}
571+
assert "ttl" not in blocks[-1]["cache_control"]
572+
573+
574+
async def test_empty_tool_results_produces_empty_content_block() -> None:
575+
resp = make_response("end_turn", [make_text_block("ok")])
576+
agent, create_mock = make_agent(mock_response=resp)
577+
578+
await agent.send([])
579+
580+
blocks = create_mock.call_args.kwargs["messages"][-1]["content"]
581+
assert blocks == []
582+
583+
584+
# ---------------------------------------------------------------------------
585+
# Prompt caching: history must not retain cache_control markers
586+
# (otherwise multi-turn requests would exceed the 4-marker limit)
587+
# ---------------------------------------------------------------------------
588+
589+
590+
async def test_history_str_message_keeps_raw_str_form() -> None:
591+
resp = make_response("end_turn", [make_text_block("ok")])
592+
agent, _ = make_agent(mock_response=resp)
593+
594+
await agent.send("Hi")
595+
596+
assert agent.history[0] == {"role": "user", "content": "Hi"}
597+
598+
599+
async def test_history_tool_result_blocks_have_no_cache_control() -> None:
600+
resp = make_response("end_turn", [make_text_block("ok")])
601+
agent, _ = make_agent(mock_response=resp)
602+
603+
tool_results = [
604+
ToolResultMessage(tool_call_id=f"t{i}", result=ToolResult(success=True, data=f"r{i}")) for i in range(2)
605+
]
606+
await agent.send(tool_results)
607+
608+
blocks = agent.history[0]["content"]
609+
assert all("cache_control" not in b for b in blocks)
610+
611+
612+
async def test_multi_turn_only_latest_user_message_in_request_has_cache_control() -> None:
613+
first_resp = make_response("tool_use", [make_tool_use_block(id="t1")])
614+
second_resp = make_response("end_turn", [make_text_block("done")])
615+
616+
client = MagicMock(spec=anthropic.AsyncAnthropic)
617+
client.messages = MagicMock()
618+
client.messages.create = AsyncMock(side_effect=[first_resp, second_resp])
619+
client.models = MagicMock()
620+
client.models.retrieve = AsyncMock(return_value=SimpleNamespace(max_input_tokens=FAKE_CONTEXT_WINDOW))
621+
agent = AnthropicAgent(client=client, tools=ToolRegistry([]), system_prompt="sp", name="t")
622+
623+
await agent.send("First")
624+
await agent.send([ToolResultMessage(tool_call_id="t1", result=ToolResult(success=True, data="r"))])
625+
626+
first_call_messages = client.messages.create.call_args_list[0].kwargs["messages"]
627+
assert first_call_messages[-1]["content"] == [
628+
{"type": "text", "text": "First", "cache_control": {"type": "ephemeral"}}
629+
]
630+
631+
second_call_messages = client.messages.create.call_args_list[1].kwargs["messages"]
632+
assert second_call_messages[0] == {"role": "user", "content": "First"}
633+
latest_blocks = second_call_messages[-1]["content"]
634+
assert all("cache_control" not in b for b in latest_blocks[:-1])
635+
assert latest_blocks[-1]["cache_control"] == {"type": "ephemeral"}

0 commit comments

Comments
 (0)