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
21 changes: 14 additions & 7 deletions src/strands/tools/executors/_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/strands/telemetry/test_tracer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions tests/strands/tools/executors/test_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)