From 9b8620b4e2a527bd538c7f6f67baa2448f038b25 Mon Sep 17 00:00:00 2001 From: atian8179 Date: Sat, 7 Mar 2026 00:04:59 +0800 Subject: [PATCH 1/2] fix: override end_turn stop reason when streaming response contains toolUse Some models (e.g., Sonnet 4.x on Bedrock) return end_turn as the stop reason even when the response contains toolUse content blocks. In streaming mode, the stop_reason from messageStop is used directly without checking for toolUse blocks, causing the event loop to skip tool execution. Add a post-stream check: if stop_reason is end_turn but the assembled message contains toolUse blocks, override to tool_use. This matches the existing non-streaming behavior in event_loop.py. Fixes #1810 --- src/strands/event_loop/streaming.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/strands/event_loop/streaming.py b/src/strands/event_loop/streaming.py index b157f740e..7644a2f56 100644 --- a/src/strands/event_loop/streaming.py +++ b/src/strands/event_loop/streaming.py @@ -419,6 +419,17 @@ async def process_stream( elif "redactContent" in chunk: handle_redact_content(chunk["redactContent"], state) + # Override stop_reason when the model returns "end_turn" but the response + # contains toolUse blocks. Some models (e.g., Sonnet 4.x) can return + # end_turn as stop_reason even when tool calls are present, which prevents + # the event loop from processing those tool calls. + # See: https://github.com/strands-agents/sdk-python/issues/1810 + if stop_reason == "end_turn": + content = state["message"].get("content", []) + if any("toolUse" in item for item in content): + logger.debug("stop_reason override: end_turn -> tool_use (response contains toolUse blocks)") + stop_reason = "tool_use" + yield ModelStopReason(stop_reason=stop_reason, message=state["message"], usage=usage, metrics=metrics) From 91bcf4fb32da2861686c876dad5a482a8410fdcf Mon Sep 17 00:00:00 2001 From: atian8179 Date: Sun, 8 Mar 2026 14:43:11 +0800 Subject: [PATCH 2/2] refactor: address review feedback - move override earlier, warn level, remove bedrock override - Move stop_reason override before yield so ModelStreamChunkEvent has correct stop reason - Change log level from debug to warning - Remove duplicate override logic in bedrock.py (now handled centrally) - Remove ticket reference from code comments (belongs in PR) --- src/strands/event_loop/streaming.py | 21 ++++++++++----------- src/strands/models/bedrock.py | 24 ++++-------------------- 2 files changed, 14 insertions(+), 31 deletions(-) diff --git a/src/strands/event_loop/streaming.py b/src/strands/event_loop/streaming.py index 7644a2f56..4a4201be4 100644 --- a/src/strands/event_loop/streaming.py +++ b/src/strands/event_loop/streaming.py @@ -411,6 +411,16 @@ async def process_stream( state = handle_content_block_stop(state) elif "messageStop" in chunk: stop_reason = handle_message_stop(chunk["messageStop"]) + # Some models return "end_turn" even when tool calls are present, + # which prevents the event loop from processing those tool calls. + # Override to "tool_use" so tool execution proceeds correctly. + if stop_reason == "end_turn": + content = state["message"].get("content", []) + if any("toolUse" in item for item in content): + logger.warning( + "stop_reason override: end_turn -> tool_use (response contains toolUse blocks)" + ) + stop_reason = "tool_use" elif "metadata" in chunk: time_to_first_byte_ms = ( int(1000 * (first_byte_time - start_time)) if (start_time and first_byte_time) else None @@ -419,17 +429,6 @@ async def process_stream( elif "redactContent" in chunk: handle_redact_content(chunk["redactContent"], state) - # Override stop_reason when the model returns "end_turn" but the response - # contains toolUse blocks. Some models (e.g., Sonnet 4.x) can return - # end_turn as stop_reason even when tool calls are present, which prevents - # the event loop from processing those tool calls. - # See: https://github.com/strands-agents/sdk-python/issues/1810 - if stop_reason == "end_turn": - content = state["message"].get("content", []) - if any("toolUse" in item for item in content): - logger.debug("stop_reason override: end_turn -> tool_use (response contains toolUse blocks)") - stop_reason = "tool_use" - yield ModelStopReason(stop_reason=stop_reason, message=state["message"], usage=usage, metrics=metrics) diff --git a/src/strands/models/bedrock.py b/src/strands/models/bedrock.py index 3fa907995..fc9a353b6 100644 --- a/src/strands/models/bedrock.py +++ b/src/strands/models/bedrock.py @@ -823,8 +823,6 @@ def _stream( logger.debug("got response from model") if streaming: response = self.client.converse_stream(**request) - # Track tool use events to fix stopReason for streaming responses - has_tool_use = False for chunk in response["stream"]: if ( "metadata" in chunk @@ -836,24 +834,10 @@ def _stream( for event in self._generate_redaction_events(): callback(event) - # Track if we see tool use events - if "contentBlockStart" in chunk and chunk["contentBlockStart"].get("start", {}).get("toolUse"): - has_tool_use = True - - # Fix stopReason for streaming responses that contain tool use - if ( - has_tool_use - and "messageStop" in chunk - and (message_stop := chunk["messageStop"]).get("stopReason") == "end_turn" - ): - # Create corrected chunk with tool_use stopReason - modified_chunk = chunk.copy() - modified_chunk["messageStop"] = message_stop.copy() - modified_chunk["messageStop"]["stopReason"] = "tool_use" - logger.warning("Override stop reason from end_turn to tool_use") - callback(modified_chunk) - else: - callback(chunk) + # The stop_reason override for end_turn -> tool_use is now + # handled centrally in streaming.py so no model-specific + # fixup is needed here. + callback(chunk) else: response = self.client.converse(**request)