diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 0b5b7ac881..087b7cac45 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -809,30 +809,31 @@ def _anthropic_ordered_blocks(msg: dict) -> list[dict]: return [block for block in converted if block is not None] -def _reasoning_details_blocks(msg: dict) -> list[dict]: - """Signed and redacted thinking from ``reasoning_details`` (Anthropic shape, or Converse's own redacted - capture), in stored order. Converse wants reasoning ahead of toolUse.""" +def _reasoning_details_blocks(msg: dict, claude: bool) -> list[dict]: + """Redacted thinking from ``reasoning_details`` (Converse's own capture) and, for Claude, signed thinking + in Anthropic shape, in stored order. Converse wants reasoning ahead of toolUse.""" blocks = [] for d in msg.get("reasoning_details") or []: if not isinstance(d, dict): continue - if d.get("type") == "thinking" and d.get("signature"): + if claude and d.get("type") == "thinking" and d.get("signature"): blocks.append(_reasoning_text_block(d.get("thinking"), d["signature"])) elif d.get("type") == "redacted_thinking" and (block := _redacted_reasoning_block(d.get("data") or d.get("redactedContentBase64"))): blocks.append(block) return blocks -def _assistant_blocks(msg: dict, content) -> list[dict]: +def _assistant_blocks(msg: dict, content, claude: bool = True) -> list[dict]: """Assistant message → Converse blocks, from the first carrier that yields any: the ordered - ``bedrock_content_blocks`` sidecar, the ordered ``anthropic_content_blocks`` sidecar, else thinking - from ``reasoning_details`` followed by text, then tool calls.""" + ``bedrock_content_blocks`` sidecar, (Claude only) the ordered ``anthropic_content_blocks`` sidecar, + else thinking from ``reasoning_details`` followed by text, then tool calls. Anthropic-shaped carriers + hold Claude signatures, so other models never read them.""" ordered_blocks = msg.get("bedrock_content_blocks") if isinstance(ordered_blocks, list) and (content_blocks := _replay_ordered_blocks(ordered_blocks)): return content_blocks - if content_blocks := _anthropic_ordered_blocks(msg): + if claude and (content_blocks := _anthropic_ordered_blocks(msg)): return content_blocks - content_blocks = _reasoning_details_blocks(msg) + content_blocks = _reasoning_details_blocks(msg, claude) if isinstance(content, str) and content.strip(): content_blocks.append({"text": content}) elif isinstance(content, list): @@ -901,7 +902,8 @@ def convert_messages_to_converse( user blocks. Converse needs strict user/assistant alternation with a user turn first and last: same-role neighbours merge, placeholder user turns pad the ends. ``model`` selects the reasoning replay policy for turns before the in-flight tool loop (None replays every carrier verbatim).""" - prior_filter, loop_filter = _REASONING_FILTERS[_converse_replay_policy(model)] + policy = _converse_replay_policy(model) + prior_filter, loop_filter = _REASONING_FILTERS[policy] # Tool results are role "tool" here, so the last role "user" message opens the in-flight tool loop. loop_start = next((i for i in range(len(messages) - 1, -1, -1) if messages[i].get("role") == "user"), -1) system_blocks: list[dict] = [] @@ -923,7 +925,9 @@ def convert_messages_to_converse( append_turn("user", [{"toolResult": { "toolUseId": msg.get("tool_call_id", ""), "content": [{"text": _safe_text(result_content)}]}}]) elif role == "assistant": - blocks = _filter_reasoning(_assistant_blocks(msg, content), prior_filter if idx < loop_start else loop_filter) + blocks = _filter_reasoning( + _assistant_blocks(msg, content, claude=policy == "signed"), prior_filter if idx < loop_start else loop_filter + ) append_turn("assistant", blocks or [dict(_PLACEHOLDER_BLOCK)]) elif role == "user": append_turn("user", _convert_content_to_converse(content)) diff --git a/tests/agent/test_bedrock_converse_reasoning_replay.py b/tests/agent/test_bedrock_converse_reasoning_replay.py index b85c187e2c..5947ada6d8 100644 --- a/tests/agent/test_bedrock_converse_reasoning_replay.py +++ b/tests/agent/test_bedrock_converse_reasoning_replay.py @@ -30,6 +30,24 @@ def _anthropic_bedrock_turn(question, sig, tool_id): ] +def _converse_turn(question, sig, tool_id): + """A tool turn captured by the Converse normalizer for a non-Claude model (ordered sidecar).""" + call = {"id": tool_id, "type": "function", "function": {"name": "read_file", "arguments": '{"path": "a"}'}} + return [ + {"role": "user", "content": question}, + {"role": "assistant", "content": "", "tool_calls": [call], "reasoning_content": f"own {sig}", + "bedrock_content_blocks": [{"reasoningContent": {"text": f"own {sig}"}}, + {"toolUse": {"toolUseId": tool_id, "name": "read_file", "input": {"path": "a"}}}]}, + {"role": "tool", "tool_call_id": tool_id, "content": "ok"}, + ] + + +def _converse_history(): + turn = _converse_turn("Q1", "sig_prior", "t1") + turn[1]["bedrock_content_blocks"][0]["reasoningContent"]["signature"] = "foreign" # e.g. minted by Claude + return turn + [{"role": "assistant", "content": "A1"}] + _converse_turn("Q2", "sig_loop", "t2") + + def _history(): return _anthropic_bedrock_turn("Q1", "sig_prior", "t1") + [{"role": "assistant", "content": "A1"}] + _anthropic_bedrock_turn("Q2", "sig_loop", "t2") @@ -42,16 +60,18 @@ def _reasoning(blocks): "model, prior, loop", [ (CLAUDE, [{"text": "plan sig_prior", "signature": "sig_prior"}], [{"text": "plan sig_loop", "signature": "sig_loop"}]), - ("us.deepseek.r1-v1:0", [], [{"text": "plan sig_loop", "signature": "sig_loop"}]), - ("global.moonshotai.kimi-k3", [], [{"text": "plan sig_loop", "signature": "sig_loop"}]), - # Earlier turns may hold another model's signatures: a non-Claude model gets their text only. - ("openai.gpt-oss-120b-1:0", [{"text": "plan sig_prior"}], [{"text": "plan sig_loop", "signature": "sig_loop"}]), + # The model's own Converse capture: prior turns follow its contract, the in-flight loop replays as captured. + ("us.deepseek.r1-v1:0", [], [{"text": "own sig_loop"}]), + ("global.moonshotai.kimi-k3", [], [{"text": "own sig_loop"}]), + # gpt-oss rejects reasoningText.signature: prior turns go back as readable text only. + ("openai.gpt-oss-120b-1:0", [{"text": "own sig_prior"}], [{"text": "own sig_loop"}]), ], ) def test_converse_replays_each_models_reasoning_contract(model, prior, loop): """Invariant: the in-flight tool loop replays its reasoning ahead of its toolUse with redacted tool - input; earlier turns and signatures follow the model's documented contract; history is untouched.""" - history = _history() + input; earlier turns follow the model's documented contract; a Claude signature never reaches a + non-Claude model; history is untouched.""" + history = _history() if "claude" in model else _converse_history() before = copy.deepcopy(history) kwargs = BedrockTransport().build_kwargs(model=model, messages=history, reasoning_config={"enabled": True, "effort": "high"}) @@ -61,6 +81,9 @@ def test_converse_replays_each_models_reasoning_contract(model, prior, loop): assert [next(iter(b)) for b in assistants[-1] if "cachePoint" not in b] == ["reasoningContent", "toolUse"] assert assistants[-1][1]["toolUse"]["input"] == {"path": "a"} assert history == before + if "claude" not in model: # Claude-shaped carriers from before a /model switch stay Claude's + switched = BedrockTransport().build_kwargs(model=model, messages=_history()) + assert "signature" not in repr(switched["messages"]) def test_claude_on_converse_requests_thinking():