diff --git a/src/strands/tools/executors/_executor.py b/src/strands/tools/executors/_executor.py index 5825b3cdb..2c602a560 100644 --- a/src/strands/tools/executors/_executor.py +++ b/src/strands/tools/executors/_executor.py @@ -171,9 +171,15 @@ async def _stream( } after_event, _ = await ToolExecutor._invoke_after_tool_call_hook( - agent, None, tool_use, invocation_state, cancel_result, cancel_message=cancel_message + agent, + None, + tool_use, + invocation_state, + cancel_result, + exception=Exception(cancel_message), + cancel_message=cancel_message, ) - yield ToolResultEvent(after_event.result) + yield ToolResultEvent(after_event.result, exception=after_event.exception) tool_results.append(after_event.result) return @@ -202,15 +208,16 @@ async def _stream( "content": [{"text": f"Unknown tool: {tool_name}"}], } + unknown_tool_error = Exception(f"Unknown tool: {tool_name}") after_event, _ = await ToolExecutor._invoke_after_tool_call_hook( - agent, selected_tool, tool_use, invocation_state, result + agent, selected_tool, tool_use, invocation_state, result, exception=unknown_tool_error ) # Check if retry requested for unknown tool error # Use getattr because BidiAfterToolCallEvent doesn't have retry attribute if getattr(after_event, "retry", False): logger.debug("tool_name=<%s> | retry requested, retrying tool call", tool_name) continue - yield ToolResultEvent(after_event.result) + yield ToolResultEvent(after_event.result, exception=after_event.exception) tool_results.append(after_event.result) return if structured_output_context.is_enabled: @@ -258,7 +265,7 @@ async def _stream( logger.debug("tool_name=<%s> | retry requested, retrying tool call", tool_name) continue - yield ToolResultEvent(after_event.result) + yield ToolResultEvent(after_event.result, exception=after_event.exception) tool_results.append(after_event.result) return @@ -277,7 +284,7 @@ async def _stream( if getattr(after_event, "retry", False): logger.debug("tool_name=<%s> | retry requested after exception, retrying tool call", tool_name) continue - yield ToolResultEvent(after_event.result) + yield ToolResultEvent(after_event.result, exception=after_event.exception) tool_results.append(after_event.result) return @@ -338,7 +345,7 @@ async def _stream_with_trace( agent.event_loop_metrics.add_tool_usage(tool_use, tool_duration, tool_trace, tool_success, message) cycle_trace.add_child(tool_trace) - tracer.end_tool_call_span(tool_call_span, result) + tracer.end_tool_call_span(tool_call_span, result, error=result_event.exception) @abc.abstractmethod # pragma: no cover diff --git a/tests/strands/telemetry/test_tracer.py b/tests/strands/telemetry/test_tracer.py index bcd42b610..2d91b6216 100644 --- a/tests/strands/telemetry/test_tracer.py +++ b/tests/strands/telemetry/test_tracer.py @@ -707,6 +707,20 @@ def test_end_tool_call_span_latest_conventions(mock_span, monkeypatch): mock_span.end.assert_called_once() +def test_end_tool_call_span_with_error(mock_span): + """Test ending a tool call span with an explicit error sets StatusCode.ERROR.""" + tracer = Tracer() + error = ValueError("tool exploded") + tool_result = {"status": "error", "content": [{"text": "Error: tool exploded"}]} + + tracer.end_tool_call_span(mock_span, tool_result, error=error) + + mock_span.set_attributes.assert_called_once_with({"gen_ai.tool.status": "error"}) + mock_span.set_status.assert_called_once_with(StatusCode.ERROR, "tool exploded") + mock_span.record_exception.assert_called_once_with(error) + mock_span.end.assert_called_once() + + def test_start_event_loop_cycle_span(mock_tracer): """Test starting an event loop cycle span.""" with mock.patch("strands.telemetry.tracer.trace_api.get_tracer", return_value=mock_tracer): diff --git a/tests/strands/tools/executors/test_executor.py b/tests/strands/tools/executors/test_executor.py index 297aa66f3..34b37dab0 100644 --- a/tests/strands/tools/executors/test_executor.py +++ b/tests/strands/tools/executors/test_executor.py @@ -189,6 +189,7 @@ async def test_executor_stream_yields_unknown_tool(executor, agent, tool_results tool_use=tool_use, invocation_state=invocation_state, result=exp_results[0], + exception=unittest.mock.ANY, ) assert tru_hook_after_event == exp_hook_after_event @@ -216,6 +217,7 @@ async def test_executor_stream_with_trace( tracer.end_tool_call_span.assert_called_once_with( tracer.start_tool_call_span.return_value, {"content": [{"text": "sunny"}], "status": "success", "toolUseId": "1"}, + error=None, ) cycle_trace.add_child.assert_called_once() @@ -901,3 +903,71 @@ def retry_once_on_unknown(event): assert len(tru_events) == 1 assert tru_events[0].tool_result["status"] == "error" assert "Unknown tool" in tru_events[0].tool_result["content"][0]["text"] + + +@pytest.mark.asyncio +async def test_executor_stream_with_trace_error( + executor, tracer, agent, tool_results, cycle_trace, cycle_span, invocation_state, alist +): + """Test that _stream_with_trace passes the exception to end_tool_call_span when a tool fails.""" + tool_use: ToolUse = {"name": "exception_tool", "toolUseId": "1", "input": {}} + stream = executor._stream_with_trace(agent, tool_use, tool_results, cycle_trace, cycle_span, invocation_state) + + await alist(stream) + + tracer.end_tool_call_span.assert_called_once() + call_args = tracer.end_tool_call_span.call_args + assert call_args[0][1]["status"] == "error" + error_arg = call_args[1].get("error") + assert error_arg is not None + assert isinstance(error_arg, RuntimeError) + assert "Tool error" in str(error_arg) + + +@pytest.mark.asyncio +async def test_executor_stream_error_preserves_exception(executor, agent, tool_results, invocation_state, alist): + """Test that _stream yields a ToolResultEvent with the exception preserved.""" + tool_use: ToolUse = {"name": "exception_tool", "toolUseId": "1", "input": {}} + stream = executor._stream(agent, tool_use, tool_results, invocation_state) + + events = await alist(stream) + result_event = events[-1] + assert isinstance(result_event, ToolResultEvent) + assert result_event.tool_result["status"] == "error" + assert result_event.exception is not None + assert isinstance(result_event.exception, RuntimeError) + assert "Tool error" in str(result_event.exception) + + +@pytest.mark.asyncio +async def test_executor_stream_unknown_tool_has_exception(executor, agent, tool_results, invocation_state, alist): + """Test that _stream yields a ToolResultEvent with exception for unknown tools.""" + tool_use: ToolUse = {"name": "nonexistent_tool", "toolUseId": "1", "input": {}} + stream = executor._stream(agent, tool_use, tool_results, invocation_state) + + events = await alist(stream) + result_event = events[-1] + assert isinstance(result_event, ToolResultEvent) + assert result_event.tool_result["status"] == "error" + assert result_event.exception is not None + assert "Unknown tool" in str(result_event.exception) + + +@pytest.mark.asyncio +async def test_executor_stream_cancel_has_exception(executor, agent, tool_results, invocation_state, alist): + """Test that _stream yields a ToolResultEvent with exception for cancelled tools.""" + + def cancel_callback(event): + event.cancel_tool = True + return event + + agent.hooks.add_callback(BeforeToolCallEvent, cancel_callback) + tool_use: ToolUse = {"name": "weather_tool", "toolUseId": "1", "input": {}} + stream = executor._stream(agent, tool_use, tool_results, invocation_state) + + events = await alist(stream) + result_event = events[-1] + assert isinstance(result_event, ToolResultEvent) + assert result_event.tool_result["status"] == "error" + assert result_event.exception is not None + assert "cancelled" in str(result_event.exception)