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.
This commit is contained in:
Teknium
2026-10-10 17:01:44 -07:00
parent f691e7b7fb
commit d6f3199584
2 changed files with 54 additions and 7 deletions

View File

@@ -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,
)

View File

@@ -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]