diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 7fa3ba391fd..e7c02e9db69 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -2060,6 +2060,28 @@ def _convert_assistant_message(m: Dict[str, Any]) -> Dict[str, Any]: _apply_assistant_cache_control_to_last_cacheable_block( replayed, m.get("cache_control") ) + # apply_anthropic_cache_control marks an assistant turn with + # non-empty text by writing cache_control INTO ``content`` (see + # _apply_cache_marker's list branch), not at the top level. This + # branch rebuilds the message from ordered_blocks and never reads + # ``content``, so that marker would be dropped -- and because + # _can_carry_marker already counted this message as a carrier, the + # breakpoint is burned rather than relocated. #56195 covered the + # complementary shape (blank content -> top-level marker); this is + # the interleaved thinking + preamble-text + tool_use shape. + _inline_cc = None + _msg_content = m.get("content") + if isinstance(_msg_content, list): + for _blk in _msg_content: + if isinstance(_blk, dict) and isinstance( + _blk.get("cache_control"), dict + ): + _inline_cc = _blk["cache_control"] + break + if _inline_cc is not None: + _apply_assistant_cache_control_to_last_cacheable_block( + replayed, _inline_cc + ) return {"role": "assistant", "content": replayed} blocks = _extract_preserved_thinking_blocks(m) diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 9a0de290400..2782411466f 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1190,6 +1190,54 @@ class TestConvertMessages: assert tool_use["id"] == "tc_1" assert tool_use["cache_control"] == {"type": "ephemeral"} + def test_ordered_replay_keeps_cache_control_from_nonempty_content(self): + """An assistant turn that interleaves signed thinking with a tool_use + AND has preamble text carries its cache_control INSIDE ``content`` + (apply_anthropic_cache_control marks the last content block, not the + top level). The ordered-replay branch rebuilds the message from + ``anthropic_content_blocks`` alone, so without harvesting that marker + the breakpoint is dropped -- and it is *burned*, because + _can_carry_marker already spent a budget slot on this message. + + #56195 covers the blank-content shape; this is the non-empty one, which + is what a Claude thinking+tools turn normally looks like. + """ + preamble = "I will read a.py now." + messages = apply_anthropic_cache_control([ + {"role": "system", "content": "System prompt"}, + {"role": "user", "content": "Read a.py"}, + { + "role": "assistant", + "content": preamble, + "anthropic_content_blocks": [ + {"type": "thinking", "thinking": "Need a tool.", "signature": "sig_1"}, + {"type": "text", "text": preamble}, + {"type": "tool_use", "id": "tc_1", "name": "test_tool", "input": {}}, + ], + "tool_calls": [ + { + "id": "tc_1", + "type": "function", + "function": {"name": "test_tool", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "tc_1", "content": "contents"}, + ]) + + _system, converted = convert_messages_to_anthropic(messages) + assistant = next(m for m in converted if m.get("role") == "assistant") + marked = [ + b for b in assistant["content"] + if isinstance(b, dict) and b.get("cache_control") + ] + assert marked, ( + "the assistant cache breakpoint was dropped by the ordered-replay " + "path and the budget slot is burned" + ) + # The signed thinking block must still lead the replayed message. + assert assistant["content"][0]["type"] == "thinking" + def test_ordered_replay_tool_use_cache_control_is_preserved(self): messages = apply_anthropic_cache_control([ {"role": "system", "content": "System prompt"},