Skip to content

Commit 6b1094e

Browse files
giulio-leoneCopilot
andcommitted
fix: ensure assistant messages have reasoningContent when thinking is enabled
When extended thinking is enabled, Bedrock requires every assistant message to start with a reasoningContent block. Custom session managers (e.g., AgentCoreMemorySessionManager) or external history providers may strip these blocks during serialization, causing ValidationException on subsequent API calls. This fix adds defensive handling in _format_bedrock_messages(): - Injects a minimal redactedContent placeholder when reasoningContent is missing from assistant messages with thinking enabled - Reorders content blocks to ensure reasoningContent comes first when it exists but is not at position 0 Closes #1698 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent fca208b commit 6b1094e

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

src/strands/models/bedrock.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,12 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:
410410
filtered_unknown_members = False
411411
dropped_deepseek_reasoning_content = False
412412

413+
# Check if extended thinking is enabled — Bedrock requires every assistant message
414+
# to start with a reasoningContent block when thinking is active.
415+
additional_fields = self.config.get("additional_request_fields") or {}
416+
thinking_config = additional_fields.get("thinking", {})
417+
thinking_enabled = thinking_config.get("type") == "enabled"
418+
413419
# Pre-compute the index of the last user message containing text or image content.
414420
# This ensures guardContent wrapping is maintained across tool execution cycles, where
415421
# the final message in the list is a toolResult (role=user) rather than text/image content.
@@ -446,6 +452,22 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:
446452

447453
cleaned_content.append(formatted_content)
448454

455+
# When thinking is enabled, ensure assistant messages start with a reasoningContent block.
456+
# Bedrock requires every assistant message to begin with a thinking block when thinking
457+
# is enabled. Custom session managers or external history may strip or reorder these
458+
# blocks, causing ValidationException. We defensively fix ordering and inject a minimal
459+
# redactedContent placeholder when thinking blocks are missing entirely.
460+
if message["role"] == "assistant" and thinking_enabled and cleaned_content:
461+
has_reasoning = any("reasoningContent" in cb for cb in cleaned_content)
462+
if has_reasoning:
463+
# Ensure reasoningContent blocks come first (Bedrock requirement)
464+
reasoning_blocks = [cb for cb in cleaned_content if "reasoningContent" in cb]
465+
other_blocks = [cb for cb in cleaned_content if "reasoningContent" not in cb]
466+
cleaned_content = reasoning_blocks + other_blocks
467+
else:
468+
# Inject a minimal redactedContent placeholder at the start
469+
cleaned_content.insert(0, {"reasoningContent": {"redactedContent": b"redacted"}})
470+
449471
# Create new message with cleaned content (skip if empty)
450472
if cleaned_content:
451473
cleaned_messages.append({"content": cleaned_content, "role": message["role"]})

tests/strands/models/test_bedrock.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2856,3 +2856,176 @@ def test_guardrail_latest_message_disabled_does_not_wrap(model):
28562856

28572857
assert "text" in formatted
28582858
assert "guardContent" not in formatted
2859+
2860+
2861+
# --- Tests for thinking-enabled multi-turn reasoningContent handling ---
2862+
2863+
2864+
def test_format_request_injects_reasoning_when_thinking_enabled_and_missing(bedrock_client, model_id):
2865+
"""When thinking is enabled, assistant messages without reasoningContent get a redactedContent placeholder."""
2866+
model = BedrockModel(
2867+
model_id=model_id,
2868+
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
2869+
)
2870+
2871+
messages = [
2872+
{"role": "user", "content": [{"text": "Hello"}]},
2873+
{
2874+
"role": "assistant",
2875+
"content": [
2876+
{"text": "I'll check."},
2877+
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}},
2878+
],
2879+
},
2880+
{"role": "user", "content": [{"toolResult": {"toolUseId": "t1", "content": [{"text": "result"}]}}]},
2881+
]
2882+
2883+
request = model._format_request(messages)
2884+
assistant_msg = request["messages"][1]
2885+
assert assistant_msg["role"] == "assistant"
2886+
2887+
# The first content block must be a reasoningContent placeholder
2888+
first_block = assistant_msg["content"][0]
2889+
assert "reasoningContent" in first_block
2890+
assert "redactedContent" in first_block["reasoningContent"]
2891+
2892+
# Original content blocks should follow
2893+
assert "text" in assistant_msg["content"][1]
2894+
assert "toolUse" in assistant_msg["content"][2]
2895+
2896+
2897+
def test_format_request_no_injection_when_thinking_disabled(bedrock_client, model_id):
2898+
"""When thinking is not enabled, assistant messages are not modified."""
2899+
model = BedrockModel(model_id=model_id)
2900+
2901+
messages = [
2902+
{"role": "user", "content": [{"text": "Hello"}]},
2903+
{
2904+
"role": "assistant",
2905+
"content": [
2906+
{"text": "Response."},
2907+
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}},
2908+
],
2909+
},
2910+
]
2911+
2912+
request = model._format_request(messages)
2913+
assistant_msg = request["messages"][1]
2914+
2915+
# No reasoningContent should be injected
2916+
assert not any("reasoningContent" in cb for cb in assistant_msg["content"])
2917+
assert "text" in assistant_msg["content"][0]
2918+
assert "toolUse" in assistant_msg["content"][1]
2919+
2920+
2921+
def test_format_request_preserves_existing_reasoning_when_thinking_enabled(bedrock_client, model_id):
2922+
"""When thinking is enabled and reasoningContent already exists, it should be preserved (not duplicated)."""
2923+
model = BedrockModel(
2924+
model_id=model_id,
2925+
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
2926+
)
2927+
2928+
messages = [
2929+
{"role": "user", "content": [{"text": "Hello"}]},
2930+
{
2931+
"role": "assistant",
2932+
"content": [
2933+
{"reasoningContent": {"reasoningText": {"text": "Let me think...", "signature": "sig123"}}},
2934+
{"text": "Here's my response."},
2935+
],
2936+
},
2937+
]
2938+
2939+
request = model._format_request(messages)
2940+
assistant_msg = request["messages"][1]
2941+
2942+
# reasoningContent should be first and preserved (not duplicated)
2943+
assert len(assistant_msg["content"]) == 2
2944+
assert "reasoningContent" in assistant_msg["content"][0]
2945+
assert assistant_msg["content"][0]["reasoningContent"]["reasoningText"]["text"] == "Let me think..."
2946+
assert "text" in assistant_msg["content"][1]
2947+
2948+
2949+
def test_format_request_reorders_reasoning_to_first_when_thinking_enabled(bedrock_client, model_id):
2950+
"""When thinking is enabled and reasoningContent is not the first block, it should be moved to front."""
2951+
model = BedrockModel(
2952+
model_id=model_id,
2953+
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
2954+
)
2955+
2956+
messages = [
2957+
{"role": "user", "content": [{"text": "Hello"}]},
2958+
{
2959+
"role": "assistant",
2960+
"content": [
2961+
{"text": "Response first."},
2962+
{"reasoningContent": {"reasoningText": {"text": "Thinking after text", "signature": "s1"}}},
2963+
{"toolUse": {"toolUseId": "t1", "name": "test", "input": {}}},
2964+
],
2965+
},
2966+
]
2967+
2968+
request = model._format_request(messages)
2969+
assistant_msg = request["messages"][1]
2970+
2971+
# reasoningContent must be first
2972+
assert "reasoningContent" in assistant_msg["content"][0]
2973+
# Followed by non-reasoning blocks
2974+
assert "text" in assistant_msg["content"][1]
2975+
assert "toolUse" in assistant_msg["content"][2]
2976+
2977+
2978+
def test_format_request_multi_turn_thinking_session_reload(bedrock_client, model_id):
2979+
"""Simulate a session reload where assistant messages lost reasoningContent blocks."""
2980+
model = BedrockModel(
2981+
model_id=model_id,
2982+
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
2983+
)
2984+
2985+
# Simulate messages loaded from a session manager that stripped reasoningContent
2986+
messages = [
2987+
{"role": "user", "content": [{"text": "What is the status of zone X?"}]},
2988+
{
2989+
"role": "assistant",
2990+
"content": [
2991+
{"text": "I'll check the status."},
2992+
{"toolUse": {"toolUseId": "tool_123", "name": "zoneStatus", "input": {"zone": "X"}}},
2993+
],
2994+
},
2995+
{
2996+
"role": "user",
2997+
"content": [{"toolResult": {"toolUseId": "tool_123", "content": [{"text": "Zone X is active"}]}}],
2998+
},
2999+
{
3000+
"role": "assistant",
3001+
"content": [{"text": "Zone X is currently active."}],
3002+
},
3003+
{"role": "user", "content": [{"text": "Now tell me about zone Y"}]},
3004+
]
3005+
3006+
request = model._format_request(messages)
3007+
3008+
# Both assistant messages should now have reasoningContent as first block
3009+
for msg in request["messages"]:
3010+
if msg["role"] == "assistant":
3011+
first_block = msg["content"][0]
3012+
assert "reasoningContent" in first_block, (
3013+
f"Assistant message missing reasoningContent: {msg['content']}"
3014+
)
3015+
3016+
3017+
def test_format_request_does_not_inject_reasoning_for_user_messages(bedrock_client, model_id):
3018+
"""User messages should never get reasoningContent injected."""
3019+
model = BedrockModel(
3020+
model_id=model_id,
3021+
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
3022+
)
3023+
3024+
messages = [
3025+
{"role": "user", "content": [{"text": "Hello"}]},
3026+
]
3027+
3028+
request = model._format_request(messages)
3029+
user_msg = request["messages"][0]
3030+
3031+
assert not any("reasoningContent" in cb for cb in user_msg["content"])

0 commit comments

Comments
 (0)