From d6f3199584e7ad91fe793ee54f0b43ca52025f67 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:01:44 -0700 Subject: [PATCH] fix: Bedrock Converse reasoning survives a reload from state.db Signed thinking lived only in the live bedrock_content_blocks sidecar, which is never persisted, so a reloaded Claude or gpt-oss turn replayed no reasoning. Converse reasoning is now stored in reasoning_details in Anthropic shape and the converter reads it back: signed for Claude, readable text for other models. --- agent/bedrock_adapter.py | 39 +++++++++++++++---- .../test_bedrock_converse_reasoning_replay.py | 22 +++++++++++ 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 087b7cac45..1d4864ce81 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -810,16 +810,25 @@ def _anthropic_ordered_blocks(msg: dict) -> list[dict]: 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.""" + """Thinking from the persisted ``reasoning_details`` (Anthropic shape, as Converse captures it), in stored + order: Claude gets signed thinking + redacted blobs, other models readable text only (falling back to + ``reasoning_content``). This is the carrier a turn reloaded from state.db replays from, since the + ordered ``bedrock_content_blocks`` sidecar is live-only. Converse wants reasoning ahead of toolUse.""" blocks = [] for d in msg.get("reasoning_details") or []: if not isinstance(d, dict): continue - 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"))): + if d.get("type") == "thinking" and isinstance(d.get("thinking"), str) and d["thinking"].strip(): + if claude and d.get("signature"): + blocks.append(_reasoning_text_block(d["thinking"], d["signature"])) + elif not claude: + blocks.append(_reasoning_text_block(d["thinking"], None)) + elif d.get("type") == "redacted_thinking" and ( + block := _redacted_reasoning_block(d.get("data") or d.get("redactedContentBase64"))): blocks.append(block) + text = msg.get("reasoning_content") + if not blocks and not claude and isinstance(text, str) and text.strip(): + blocks.append(_reasoning_text_block(text, None)) return blocks @@ -960,6 +969,20 @@ def _tool_call_ns(tool_use_id: str, name: str, input_dict) -> SimpleNamespace: ) +def _persisted_reasoning_details(ordered_blocks: list[dict[str, Any]]) -> list[dict[str, Any]]: + details: list[dict[str, Any]] = [] + for block in ordered_blocks: + reasoning = block.get("reasoningContent") if isinstance(block, dict) else None + if not isinstance(reasoning, dict): + continue + if isinstance(reasoning.get("text"), str) and reasoning["text"]: + details.append({"type": "thinking", "thinking": reasoning["text"], + **({"signature": reasoning["signature"]} if reasoning.get("signature") else {})}) + if reasoning.get("redactedContentBase64"): + details.append({"type": "redacted_thinking", "data": reasoning["redactedContentBase64"]}) + return details + + class _ResponseParts: """Accumulator shared by the sync and streaming normalizers.""" @@ -992,10 +1015,12 @@ class _ResponseParts: def build(self, ordered_blocks: list[dict[str, Any]], usage_data: dict[str, int], stop_reason: str, model: str) -> SimpleNamespace: """Assemble the OpenAI-shaped response. Converse's inputTokens EXCLUDES cache read/write tokens - (OpenAI's prompt_tokens includes them), so they are added back.""" + (OpenAI's prompt_tokens includes them), so they are added back. ``reasoning_details`` carries every + reasoning block in Anthropic shape (signed thinking included) because it is the persisted column.""" msg = SimpleNamespace( role="assistant", content="\n".join(self.text_parts) if self.text_parts else None, - tool_calls=self.tool_calls or None, reasoning_details=self.reasoning_details or None, + tool_calls=self.tool_calls or None, + reasoning_details=_persisted_reasoning_details(ordered_blocks) or self.reasoning_details or None, reasoning_content="\n\n".join(self.reasoning_parts) if self.reasoning_parts else None, bedrock_content_blocks=ordered_blocks or None, ) diff --git a/tests/agent/test_bedrock_converse_reasoning_replay.py b/tests/agent/test_bedrock_converse_reasoning_replay.py index 5947ada6d8..40d8173442 100644 --- a/tests/agent/test_bedrock_converse_reasoning_replay.py +++ b/tests/agent/test_bedrock_converse_reasoning_replay.py @@ -106,3 +106,25 @@ def test_claude_on_converse_requests_thinking(): assert "additionalModelRequestFields" not in transport.build_kwargs( model="meta.llama3-70b-instruct-v1:0", messages=msgs, reasoning_config=cfg) assert "additionalModelRequestFields" not in transport.build_kwargs(model=CLAUDE, messages=msgs) + + +@pytest.mark.parametrize("model, expect", [ + (CLAUDE, {"text": "plan", "signature": "SIG"}), + ("openai.gpt-oss-120b-1:0", {"text": "plan"}), +]) +def test_converse_reasoning_survives_a_reload_from_state_db(model, expect): + """``bedrock_content_blocks`` is live-only; the persisted ``reasoning_details`` must carry the turn.""" + from agent.bedrock_adapter import convert_messages_to_converse, normalize_converse_response + + live = normalize_converse_response({"output": {"message": {"content": [ + {"reasoningContent": {"reasoningText": {"text": "plan", "signature": "SIG"}}}, + {"toolUse": {"toolUseId": "t1", "name": "read_file", "input": {"path": "a"}}}, + ]}}, "stopReason": "tool_use", "usage": {}}).choices[0].message + reloaded = {"role": "assistant", "content": "", "reasoning_content": live.reasoning_content, + "reasoning_details": live.reasoning_details, + "tool_calls": [{"id": "t1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]} + history = [{"role": "user", "content": "Q1"}, reloaded, {"role": "tool", "tool_call_id": "t1", "content": "ok"}, + {"role": "assistant", "content": "A1"}, {"role": "user", "content": "Q2"}] + _, msgs = convert_messages_to_converse(history, model=model) + reasoning = [b["reasoningContent"]["reasoningText"] for b in msgs[1]["content"] if "reasoningContent" in b] + assert reasoning == [expect]