Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/strands/models/bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,12 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:
filtered_unknown_members = False
dropped_deepseek_reasoning_content = False

# Check if extended thinking is enabled — Bedrock requires every assistant message
# to start with a reasoningContent block when thinking is active.
additional_fields = self.config.get("additional_request_fields") or {}
thinking_config = additional_fields.get("thinking", {})
thinking_enabled = thinking_config.get("type") == "enabled"

# Pre-compute the index of the last user message containing text or image content.
# This ensures guardContent wrapping is maintained across tool execution cycles, where
# the final message in the list is a toolResult (role=user) rather than text/image content.
Expand Down Expand Up @@ -446,6 +452,22 @@ def _format_bedrock_messages(self, messages: Messages) -> list[dict[str, Any]]:

cleaned_content.append(formatted_content)

# When thinking is enabled, ensure assistant messages start with a reasoningContent block.
# Bedrock requires every assistant message to begin with a thinking block when thinking
# is enabled. Custom session managers or external history may strip or reorder these
# blocks, causing ValidationException. We defensively fix ordering and inject a minimal
# redactedContent placeholder when thinking blocks are missing entirely.
if message["role"] == "assistant" and thinking_enabled and cleaned_content:
has_reasoning = any("reasoningContent" in cb for cb in cleaned_content)
if has_reasoning:
# Ensure reasoningContent blocks come first (Bedrock requirement)
reasoning_blocks = [cb for cb in cleaned_content if "reasoningContent" in cb]
other_blocks = [cb for cb in cleaned_content if "reasoningContent" not in cb]
cleaned_content = reasoning_blocks + other_blocks
else:
# Inject a minimal redactedContent placeholder at the start
cleaned_content.insert(0, {"reasoningContent": {"redactedContent": b"redacted"}})

# Create new message with cleaned content (skip if empty)
if cleaned_content:
cleaned_messages.append({"content": cleaned_content, "role": message["role"]})
Expand Down
173 changes: 173 additions & 0 deletions tests/strands/models/test_bedrock.py
Original file line number Diff line number Diff line change
Expand Up @@ -2856,3 +2856,176 @@ def test_guardrail_latest_message_disabled_does_not_wrap(model):

assert "text" in formatted
assert "guardContent" not in formatted


# --- Tests for thinking-enabled multi-turn reasoningContent handling ---


def test_format_request_injects_reasoning_when_thinking_enabled_and_missing(bedrock_client, model_id):
"""When thinking is enabled, assistant messages without reasoningContent get a redactedContent placeholder."""
model = BedrockModel(
model_id=model_id,
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
)

messages = [
{"role": "user", "content": [{"text": "Hello"}]},
{
"role": "assistant",
"content": [
{"text": "I'll check."},
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}},
],
},
{"role": "user", "content": [{"toolResult": {"toolUseId": "t1", "content": [{"text": "result"}]}}]},
]

request = model._format_request(messages)
assistant_msg = request["messages"][1]
assert assistant_msg["role"] == "assistant"

# The first content block must be a reasoningContent placeholder
first_block = assistant_msg["content"][0]
assert "reasoningContent" in first_block
assert "redactedContent" in first_block["reasoningContent"]

# Original content blocks should follow
assert "text" in assistant_msg["content"][1]
assert "toolUse" in assistant_msg["content"][2]


def test_format_request_no_injection_when_thinking_disabled(bedrock_client, model_id):
"""When thinking is not enabled, assistant messages are not modified."""
model = BedrockModel(model_id=model_id)

messages = [
{"role": "user", "content": [{"text": "Hello"}]},
{
"role": "assistant",
"content": [
{"text": "Response."},
{"toolUse": {"toolUseId": "t1", "name": "lookup", "input": {}}},
],
},
]

request = model._format_request(messages)
assistant_msg = request["messages"][1]

# No reasoningContent should be injected
assert not any("reasoningContent" in cb for cb in assistant_msg["content"])
assert "text" in assistant_msg["content"][0]
assert "toolUse" in assistant_msg["content"][1]


def test_format_request_preserves_existing_reasoning_when_thinking_enabled(bedrock_client, model_id):
"""When thinking is enabled and reasoningContent already exists, it should be preserved (not duplicated)."""
model = BedrockModel(
model_id=model_id,
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
)

messages = [
{"role": "user", "content": [{"text": "Hello"}]},
{
"role": "assistant",
"content": [
{"reasoningContent": {"reasoningText": {"text": "Let me think...", "signature": "sig123"}}},
{"text": "Here's my response."},
],
},
]

request = model._format_request(messages)
assistant_msg = request["messages"][1]

# reasoningContent should be first and preserved (not duplicated)
assert len(assistant_msg["content"]) == 2
assert "reasoningContent" in assistant_msg["content"][0]
assert assistant_msg["content"][0]["reasoningContent"]["reasoningText"]["text"] == "Let me think..."
assert "text" in assistant_msg["content"][1]


def test_format_request_reorders_reasoning_to_first_when_thinking_enabled(bedrock_client, model_id):
"""When thinking is enabled and reasoningContent is not the first block, it should be moved to front."""
model = BedrockModel(
model_id=model_id,
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
)

messages = [
{"role": "user", "content": [{"text": "Hello"}]},
{
"role": "assistant",
"content": [
{"text": "Response first."},
{"reasoningContent": {"reasoningText": {"text": "Thinking after text", "signature": "s1"}}},
{"toolUse": {"toolUseId": "t1", "name": "test", "input": {}}},
],
},
]

request = model._format_request(messages)
assistant_msg = request["messages"][1]

# reasoningContent must be first
assert "reasoningContent" in assistant_msg["content"][0]
# Followed by non-reasoning blocks
assert "text" in assistant_msg["content"][1]
assert "toolUse" in assistant_msg["content"][2]


def test_format_request_multi_turn_thinking_session_reload(bedrock_client, model_id):
"""Simulate a session reload where assistant messages lost reasoningContent blocks."""
model = BedrockModel(
model_id=model_id,
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
)

# Simulate messages loaded from a session manager that stripped reasoningContent
messages = [
{"role": "user", "content": [{"text": "What is the status of zone X?"}]},
{
"role": "assistant",
"content": [
{"text": "I'll check the status."},
{"toolUse": {"toolUseId": "tool_123", "name": "zoneStatus", "input": {"zone": "X"}}},
],
},
{
"role": "user",
"content": [{"toolResult": {"toolUseId": "tool_123", "content": [{"text": "Zone X is active"}]}}],
},
{
"role": "assistant",
"content": [{"text": "Zone X is currently active."}],
},
{"role": "user", "content": [{"text": "Now tell me about zone Y"}]},
]

request = model._format_request(messages)

# Both assistant messages should now have reasoningContent as first block
for msg in request["messages"]:
if msg["role"] == "assistant":
first_block = msg["content"][0]
assert "reasoningContent" in first_block, (
f"Assistant message missing reasoningContent: {msg['content']}"
)


def test_format_request_does_not_inject_reasoning_for_user_messages(bedrock_client, model_id):
"""User messages should never get reasoningContent injected."""
model = BedrockModel(
model_id=model_id,
additional_request_fields={"thinking": {"type": "enabled", "budget_tokens": 4096}},
)

messages = [
{"role": "user", "content": [{"text": "Hello"}]},
]

request = model._format_request(messages)
user_msg = request["messages"][0]

assert not any("reasoningContent" in cb for cb in user_msg["content"])