From 3ff0ce89326a5c7bf723aedff9e5578db1a6d44c Mon Sep 17 00:00:00 2001 From: Jiaaqiliu Date: Wed, 19 Aug 2026 10:57:25 +0800 Subject: [PATCH] fix(bedrock): read reasoningText on non-streaming Converse responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS Converse has two different shapes for reasoning. The non-streaming `ReasoningContentBlock` is a union whose text member is `{"reasoningText": {"text", "signature"}}`; only the streaming `ReasoningContentBlockDelta` puts `text` at the top level. `normalize_converse_response` used the streaming shape, so `reasoning.get("text", "")` was always empty on a non-streaming call and the model's reasoning was dropped entirely: the reasoning box never rendered, reasoning-token accounting saw nothing, and nothing was stored for replay. `reasoningText` appeared nowhere in the tree. The streaming handler a few hundred lines below is correct for its own shape — this is a mismatch between the two functions, not a systematic omission. Reproduces with any model that emits reasoningContent on a non-streaming converse() call (for example us.deepseek.r1-v1:0, or a Claude model with extended thinking enabled via additionalModelRequestFields). The flat `{"text": ...}` form is still accepted as a fallback so a caller that hands the function an already-flattened block keeps working. A block carrying only `redactedContent` correctly yields no text. --- agent/bedrock_adapter.py | 14 +++++- tests/agent/test_bedrock_adapter.py | 67 +++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/agent/bedrock_adapter.py b/agent/bedrock_adapter.py index 8d63323fd299..da52bce66896 100644 --- a/agent/bedrock_adapter.py +++ b/agent/bedrock_adapter.py @@ -765,7 +765,19 @@ def normalize_converse_response(response: Dict) -> SimpleNamespace: elif "reasoningContent" in block: reasoning = block["reasoningContent"] if isinstance(reasoning, dict): - thinking_text = reasoning.get("text", "") + # Converse has two different shapes for this field. The + # NON-streaming ReasoningContentBlock is a union whose text + # member is {"reasoningText": {"text", "signature"}}; only the + # streaming ReasoningContentBlockDelta puts "text" at the top + # level (see the contentBlockDelta handler below). Reading the + # streaming shape here made reasoning_content always empty. + reasoning_text = reasoning.get("reasoningText") + if isinstance(reasoning_text, dict): + thinking_text = reasoning_text.get("text", "") + else: + # Delta shape, tolerated so a caller that hands us an + # already-flattened block still works. + thinking_text = reasoning.get("text", "") if thinking_text: reasoning_parts.append(str(thinking_text)) elif "toolUse" in block: diff --git a/tests/agent/test_bedrock_adapter.py b/tests/agent/test_bedrock_adapter.py index e6c2c3c3c488..4df46169db4f 100644 --- a/tests/agent/test_bedrock_adapter.py +++ b/tests/agent/test_bedrock_adapter.py @@ -1212,3 +1212,70 @@ def test_sigv4_claude_still_uses_anthropic_bedrock_sdk(self, monkeypatch): runtime = self._resolve(monkeypatch, bearer=False) assert runtime["api_mode"] == "anthropic_messages" assert runtime.get("bedrock_anthropic") is True + + +class TestNonStreamingReasoningContent: + """Non-streaming Converse responses use a different reasoningContent shape. + + The AWS `ReasoningContentBlock` (non-streaming) is a union whose text member + is `{"reasoningText": {"text", "signature"}}`. Only the streaming + `ReasoningContentBlockDelta` puts `"text"` at the top level. + `normalize_converse_response` read the streaming shape, so + `reasoning_content` was always None on non-streaming calls and the model's + chain of thought was dropped from display, accounting and replay. + """ + + @staticmethod + def _normalize(reasoning_block): + from agent.bedrock_adapter import normalize_converse_response + + return normalize_converse_response( + { + "output": { + "message": { + "role": "assistant", + "content": [reasoning_block, {"text": "done"}], + } + }, + "stopReason": "end_turn", + "usage": {"inputTokens": 10, "outputTokens": 5}, + } + ) + + def test_reasoning_text_union_member_is_preserved(self): + resp = self._normalize( + { + "reasoningContent": { + "reasoningText": { + "text": "The user wants the file. I should call read_file.", + "signature": "sig-abc", + } + } + } + ) + assert ( + resp.choices[0].message.reasoning_content + == "The user wants the file. I should call read_file." + ) + assert resp.choices[0].message.content == "done" + + def test_flat_delta_shape_still_accepted(self): + """A caller handing us an already-flattened block keeps working.""" + resp = self._normalize({"reasoningContent": {"text": "flattened"}}) + assert resp.choices[0].message.reasoning_content == "flattened" + + def test_redacted_only_block_yields_no_text(self): + resp = self._normalize({"reasoningContent": {"redactedContent": b"\x00\x01"}}) + assert resp.choices[0].message.reasoning_content is None + + def test_absent_reasoning_is_none(self): + from agent.bedrock_adapter import normalize_converse_response + + resp = normalize_converse_response( + { + "output": {"message": {"role": "assistant", "content": [{"text": "hi"}]}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 1, "outputTokens": 1}, + } + ) + assert resp.choices[0].message.reasoning_content is None