Skip to content

Commit ec03b12

Browse files
author
Danilo Poccia
committed
feat(bedrock): Add support for server-side tools (system tools)
Add support for server-side tools that are handled within the model invocation. When Bedrock returns tool use and tool response together with a specific (server_tool_use) type, the SDK now correctly: - Distinguishes server-side tools (like nova_grounding) from client-side tools - Does NOT override stopReason to 'tool_use' for server-side tools - Tracks tool results to determine if tools have already been executed This enables features like Nova Web Grounding to work correctly without triggering infinite loops or unnecessary tool execution attempts. Changes: - Updated streaming handler to track tool types and results - Added _has_client_side_tools_to_execute() helper method - Updated non-streaming handler to use the new helper - Added comprehensive tests for server-side tool handling
1 parent a64a851 commit ec03b12

File tree

2 files changed

+242
-14
lines changed

2 files changed

+242
-14
lines changed

src/strands/models/bedrock.py

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -681,8 +681,12 @@ def _stream(
681681
logger.debug("got response from model")
682682
if streaming:
683683
response = self.client.converse_stream(**request)
684-
# Track tool use events to fix stopReason for streaming responses
685-
has_tool_use = False
684+
# Track tool use/result events to fix stopReason for streaming responses
685+
# We need to distinguish server-side tools (already executed) from client-side tools
686+
tool_use_info: dict[str, str] = {} # toolUseId -> type (e.g., "server_tool_use")
687+
tool_result_ids: set[str] = set() # IDs of tools with results
688+
has_client_tools = False
689+
686690
for chunk in response["stream"]:
687691
if (
688692
"metadata" in chunk
@@ -694,22 +698,40 @@ def _stream(
694698
for event in self._generate_redaction_events():
695699
callback(event)
696700

697-
# Track if we see tool use events
698-
if "contentBlockStart" in chunk and chunk["contentBlockStart"].get("start", {}).get("toolUse"):
699-
has_tool_use = True
701+
# Track tool use events with their types
702+
if "contentBlockStart" in chunk:
703+
tool_use_start = chunk["contentBlockStart"].get("start", {}).get("toolUse")
704+
if tool_use_start:
705+
tool_use_id = tool_use_start.get("toolUseId", "")
706+
tool_type = tool_use_start.get("type", "")
707+
tool_use_info[tool_use_id] = tool_type
708+
# Check if it's a client-side tool (not server_tool_use)
709+
if tool_type != "server_tool_use":
710+
has_client_tools = True
711+
712+
# Track tool result events (for server-side tools that were already executed)
713+
tool_result_start = chunk["contentBlockStart"].get("start", {}).get("toolResult")
714+
if tool_result_start:
715+
tool_result_ids.add(tool_result_start.get("toolUseId", ""))
700716

701717
# Fix stopReason for streaming responses that contain tool use
718+
# BUT: Only override if there are client-side tools without results
702719
if (
703-
has_tool_use
704-
and "messageStop" in chunk
720+
"messageStop" in chunk
705721
and (message_stop := chunk["messageStop"]).get("stopReason") == "end_turn"
706722
):
707-
# Create corrected chunk with tool_use stopReason
708-
modified_chunk = chunk.copy()
709-
modified_chunk["messageStop"] = message_stop.copy()
710-
modified_chunk["messageStop"]["stopReason"] = "tool_use"
711-
logger.warning("Override stop reason from end_turn to tool_use")
712-
callback(modified_chunk)
723+
# Check if we have client-side tools that need execution
724+
needs_execution = has_client_tools and not set(tool_use_info.keys()).issubset(tool_result_ids)
725+
726+
if needs_execution:
727+
# Create corrected chunk with tool_use stopReason
728+
modified_chunk = chunk.copy()
729+
modified_chunk["messageStop"] = message_stop.copy()
730+
modified_chunk["messageStop"]["stopReason"] = "tool_use"
731+
logger.warning("Override stop reason from end_turn to tool_use")
732+
callback(modified_chunk)
733+
else:
734+
callback(chunk)
713735
else:
714736
callback(chunk)
715737

@@ -771,6 +793,43 @@ def _stream(
771793
callback()
772794
logger.debug("finished streaming response from model")
773795

796+
def _has_client_side_tools_to_execute(self, message_content: list[dict[str, Any]]) -> bool:
797+
"""Check if message contains client-side tools that need execution.
798+
799+
Server-side tools (like nova_grounding) are executed by Bedrock and include
800+
toolResult blocks in the response. We should NOT override stopReason to
801+
"tool_use" for these tools.
802+
803+
Args:
804+
message_content: The content array from Bedrock response.
805+
806+
Returns:
807+
True if there are client-side tools without results, False otherwise.
808+
"""
809+
tool_use_ids = set()
810+
tool_result_ids = set()
811+
has_client_tools = False
812+
813+
for content in message_content:
814+
if "toolUse" in content:
815+
tool_use = content["toolUse"]
816+
tool_use_ids.add(tool_use["toolUseId"])
817+
818+
# Check if it's a server-side tool (Bedrock executes these)
819+
if tool_use.get("type") != "server_tool_use":
820+
has_client_tools = True
821+
822+
elif "toolResult" in content:
823+
# Track which tools already have results
824+
tool_result_ids.add(content["toolResult"]["toolUseId"])
825+
826+
# Only return True if there are client-side tools without results
827+
if not has_client_tools:
828+
return False
829+
830+
# Check if all tool uses have corresponding results
831+
return not tool_use_ids.issubset(tool_result_ids)
832+
774833
def _convert_non_streaming_to_streaming(self, response: dict[str, Any]) -> Iterable[StreamEvent]:
775834
"""Convert a non-streaming response to the streaming format.
776835
@@ -851,10 +910,12 @@ def _convert_non_streaming_to_streaming(self, response: dict[str, Any]) -> Itera
851910

852911
# Yield messageStop event
853912
# Fix stopReason for models that return end_turn when they should return tool_use on non-streaming side
913+
# BUT: Don't override for server-side tools (like nova_grounding) that are already executed
854914
current_stop_reason = response["stopReason"]
855915
if current_stop_reason == "end_turn":
856916
message_content = response["output"]["message"]["content"]
857-
if any("toolUse" in content for content in message_content):
917+
# Only override if there are client-side tools that need execution
918+
if self._has_client_side_tools_to_execute(message_content):
858919
current_stop_reason = "tool_use"
859920
logger.warning("Override stop reason from end_turn to tool_use")
860921

tests/strands/models/test_bedrock.py

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2070,3 +2070,170 @@ async def test_stream_backward_compatibility_system_prompt(bedrock_client, model
20702070
"system": [{"text": system_prompt}],
20712071
}
20722072
bedrock_client.converse_stream.assert_called_once_with(**expected_request)
2073+
2074+
2075+
def test_has_client_side_tools_to_execute_with_client_tools(model):
2076+
"""Test that client-side tools are correctly identified as needing execution."""
2077+
message_content = [
2078+
{
2079+
"toolUse": {
2080+
"toolUseId": "tool-123",
2081+
"name": "my_tool",
2082+
"input": {"param": "value"},
2083+
}
2084+
}
2085+
]
2086+
2087+
assert model._has_client_side_tools_to_execute(message_content) is True
2088+
2089+
2090+
def test_has_client_side_tools_to_execute_with_server_tools(model):
2091+
"""Test that server-side tools (like nova_grounding) are NOT identified as needing execution."""
2092+
message_content = [
2093+
{
2094+
"toolUse": {
2095+
"toolUseId": "tool-123",
2096+
"name": "nova_grounding",
2097+
"type": "server_tool_use",
2098+
"input": {},
2099+
}
2100+
},
2101+
{
2102+
"toolResult": {
2103+
"toolUseId": "tool-123",
2104+
"content": [{"text": "Grounding result"}],
2105+
}
2106+
},
2107+
]
2108+
2109+
assert model._has_client_side_tools_to_execute(message_content) is False
2110+
2111+
2112+
def test_has_client_side_tools_to_execute_with_mixed_tools(model):
2113+
"""Test mixed server and client tools - should return True if client tools need execution."""
2114+
message_content = [
2115+
# Server-side tool with result
2116+
{
2117+
"toolUse": {
2118+
"toolUseId": "server-tool-123",
2119+
"name": "nova_grounding",
2120+
"type": "server_tool_use",
2121+
"input": {},
2122+
}
2123+
},
2124+
{
2125+
"toolResult": {
2126+
"toolUseId": "server-tool-123",
2127+
"content": [{"text": "Grounding result"}],
2128+
}
2129+
},
2130+
# Client-side tool without result
2131+
{
2132+
"toolUse": {
2133+
"toolUseId": "client-tool-456",
2134+
"name": "my_tool",
2135+
"input": {"param": "value"},
2136+
}
2137+
},
2138+
]
2139+
2140+
assert model._has_client_side_tools_to_execute(message_content) is True
2141+
2142+
2143+
def test_has_client_side_tools_to_execute_with_no_tools(model):
2144+
"""Test that no tools returns False."""
2145+
message_content = [{"text": "Just some text"}]
2146+
2147+
assert model._has_client_side_tools_to_execute(message_content) is False
2148+
2149+
2150+
@pytest.mark.asyncio
2151+
async def test_stream_server_tool_use_does_not_override_stop_reason(bedrock_client, alist, messages):
2152+
"""Test that stopReason is NOT overridden for server-side tools like nova_grounding."""
2153+
model = BedrockModel(model_id="amazon.nova-premier-v1:0")
2154+
model.client = bedrock_client
2155+
2156+
# Simulate streaming response with server-side tool use and result
2157+
bedrock_client.converse_stream.return_value = {
2158+
"stream": [
2159+
{"messageStart": {"role": "assistant"}},
2160+
{
2161+
"contentBlockStart": {
2162+
"start": {
2163+
"toolUse": {
2164+
"toolUseId": "tool-123",
2165+
"name": "nova_grounding",
2166+
"type": "server_tool_use",
2167+
}
2168+
}
2169+
}
2170+
},
2171+
{"contentBlockDelta": {"delta": {"toolUse": {"input": "{}"}}}},
2172+
{"contentBlockStop": {}},
2173+
{
2174+
"contentBlockStart": {
2175+
"start": {
2176+
"toolResult": {
2177+
"toolUseId": "tool-123",
2178+
}
2179+
}
2180+
}
2181+
},
2182+
{"contentBlockDelta": {"delta": {"text": "Grounding result"}}},
2183+
{"contentBlockStop": {}},
2184+
{"contentBlockStart": {"start": {}}},
2185+
{"contentBlockDelta": {"delta": {"text": "Final response"}}},
2186+
{"contentBlockStop": {}},
2187+
{"messageStop": {"stopReason": "end_turn"}},
2188+
]
2189+
}
2190+
2191+
events = await alist(model.stream(messages))
2192+
2193+
# Find the messageStop event
2194+
message_stop_event = next(e for e in events if "messageStop" in e)
2195+
2196+
# Verify stopReason was NOT overridden (should remain end_turn for server-side tools)
2197+
assert message_stop_event["messageStop"]["stopReason"] == "end_turn"
2198+
2199+
2200+
@pytest.mark.asyncio
2201+
async def test_stream_non_streaming_server_tool_use_does_not_override_stop_reason(bedrock_client, alist, messages):
2202+
"""Test that stopReason is NOT overridden for server-side tools in non-streaming mode."""
2203+
model = BedrockModel(model_id="amazon.nova-premier-v1:0", streaming=False)
2204+
model.client = bedrock_client
2205+
2206+
bedrock_client.converse.return_value = {
2207+
"output": {
2208+
"message": {
2209+
"role": "assistant",
2210+
"content": [
2211+
{
2212+
"toolUse": {
2213+
"toolUseId": "tool-123",
2214+
"name": "nova_grounding",
2215+
"type": "server_tool_use",
2216+
"input": {},
2217+
}
2218+
},
2219+
{
2220+
"toolResult": {
2221+
"toolUseId": "tool-123",
2222+
"content": [{"text": "Grounding result"}],
2223+
}
2224+
},
2225+
{"text": "Final response based on grounding"},
2226+
],
2227+
}
2228+
},
2229+
"stopReason": "end_turn",
2230+
"usage": {"inputTokens": 10, "outputTokens": 20},
2231+
}
2232+
2233+
events = await alist(model.stream(messages))
2234+
2235+
# Find the messageStop event
2236+
message_stop_event = next(e for e in events if "messageStop" in e)
2237+
2238+
# Verify stopReason was NOT overridden (should remain end_turn for server-side tools)
2239+
assert message_stop_event["messageStop"]["stopReason"] == "end_turn"

0 commit comments

Comments
 (0)