Skip to content

Commit 3d1231e

Browse files
authored
fix: redact function tool trace span errors (#3111)
1 parent 601ecf5 commit 3d1231e

4 files changed

Lines changed: 180 additions & 17 deletions

File tree

src/agents/run_internal/tool_execution.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
from ..tracing import Span, SpanError, function_span, get_current_trace
8888
from ..util import _coro, _error_tracing
8989
from ..util._approvals import evaluate_needs_approval_setting
90+
from ..util._tool_errors import get_trace_tool_error
9091
from ..util._types import MaybeAwaitable
9192
from ._asyncio_progress import get_function_tool_task_progress_deadline
9293
from .agent_bindings import AgentBindings, bind_public_agent
@@ -152,7 +153,6 @@
152153
"execute_approved_tools",
153154
]
154155

155-
REDACTED_TOOL_ERROR_MESSAGE = "Tool execution failed. Error details are redacted."
156156
TToolSpanResult = TypeVar("TToolSpanResult")
157157
_FUNCTION_TOOL_CANCELLED_DRAIN_SECONDS = 0.25
158158
_FUNCTION_TOOL_CANCELLED_IMMEDIATE_STEP_LIMIT = 64
@@ -1013,11 +1013,6 @@ def format_shell_error(error: Exception | BaseException | Any) -> str:
10131013
return repr(error)
10141014

10151015

1016-
def get_trace_tool_error(*, trace_include_sensitive_data: bool, error_message: str) -> str:
1017-
"""Return a trace-safe tool error string based on the sensitive-data setting."""
1018-
return error_message if trace_include_sensitive_data else REDACTED_TOOL_ERROR_MESSAGE
1019-
1020-
10211016
async def with_tool_function_span(
10221017
*,
10231018
config: RunConfig,
@@ -1585,10 +1580,14 @@ async def _run_single_tool(
15851580
agent_hooks=agent_hooks,
15861581
)
15871582
except Exception as e:
1583+
trace_error = get_trace_tool_error(
1584+
trace_include_sensitive_data=self.config.trace_include_sensitive_data,
1585+
error_message=str(e),
1586+
)
15881587
_error_tracing.attach_error_to_current_span(
15891588
SpanError(
15901589
message="Error running tool",
1591-
data={"tool_name": func_tool.name, "error": str(e)},
1590+
data={"tool_name": func_tool.name, "error": trace_error},
15921591
)
15931592
)
15941593
if isinstance(e, AgentsException):
@@ -1747,10 +1746,14 @@ async def _invoke_tool_and_run_post_invoke(
17471746
if result is None:
17481747
raise
17491748

1749+
trace_error = get_trace_tool_error(
1750+
trace_include_sensitive_data=self.config.trace_include_sensitive_data,
1751+
error_message=str(e),
1752+
)
17501753
_error_tracing.attach_error_to_current_span(
17511754
SpanError(
17521755
message="Tool execution cancelled",
1753-
data={"tool_name": func_tool.name, "error": str(e)},
1756+
data={"tool_name": func_tool.name, "error": trace_error},
17541757
)
17551758
)
17561759
real_result = result

src/agents/tool.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .tool_guardrails import ToolInputGuardrail, ToolOutputGuardrail
6161
from .tracing import SpanError
6262
from .util import _error_tracing
63+
from .util._tool_errors import get_trace_tool_error
6364
from .util._types import MaybeAwaitable
6465

6566
if TYPE_CHECKING:
@@ -422,7 +423,7 @@ class _FailureHandlingFunctionToolInvoker:
422423
def __init__(
423424
self,
424425
invoke_tool_impl: Callable[[ToolContext[Any], str], Awaitable[Any]],
425-
on_handled_error: Callable[[FunctionTool, Exception, str], None],
426+
on_handled_error: Callable[[FunctionTool, Exception, str, ToolContext[Any]], None],
426427
*,
427428
function_tool: FunctionTool | None = None,
428429
) -> None:
@@ -457,7 +458,7 @@ async def __call__(self, ctx: ToolContext[Any], input: str) -> Any:
457458
if result is None:
458459
raise
459460

460-
self._on_handled_error(self._function_tool, e, input)
461+
self._on_handled_error(self._function_tool, e, input, ctx)
461462
return result
462463

463464

@@ -466,6 +467,26 @@ def with_function_tool_failure_error_handler(
466467
on_handled_error: Callable[[FunctionTool, Exception, str], None],
467468
) -> Callable[[ToolContext[Any], str], Awaitable[Any]]:
468469
"""Wrap a tool invoker so copied FunctionTools resolve failure policy against themselves."""
470+
471+
def _on_handled_error_with_context(
472+
function_tool: FunctionTool,
473+
error: Exception,
474+
input_json: str,
475+
_context: ToolContext[Any],
476+
) -> None:
477+
on_handled_error(function_tool, error, input_json)
478+
479+
return _with_context_function_tool_failure_error_handler(
480+
invoke_tool_impl,
481+
_on_handled_error_with_context,
482+
)
483+
484+
485+
def _with_context_function_tool_failure_error_handler(
486+
invoke_tool_impl: Callable[[ToolContext[Any], str], Awaitable[Any]],
487+
on_handled_error: Callable[[FunctionTool, Exception, str, ToolContext[Any]], None],
488+
) -> Callable[[ToolContext[Any], str], Awaitable[Any]]:
489+
"""Wrap a tool invoker with context-aware handled-error reporting."""
469490
return _FailureHandlingFunctionToolInvoker(invoke_tool_impl, on_handled_error)
470491

471492

@@ -475,7 +496,7 @@ def _build_wrapped_function_tool(
475496
description: str,
476497
params_json_schema: dict[str, Any],
477498
invoke_tool_impl: Callable[[ToolContext[Any], str], Awaitable[Any]],
478-
on_handled_error: Callable[[FunctionTool, Exception, str], None],
499+
on_handled_error: Callable[[FunctionTool, Exception, str, ToolContext[Any]], None],
479500
failure_error_function: ToolErrorFunction | None | object = _UNSET_FAILURE_ERROR_FUNCTION,
480501
strict_json_schema: bool = True,
481502
is_enabled: bool | Callable[[RunContextWrapper[Any], AgentBase], MaybeAwaitable[bool]] = True,
@@ -493,7 +514,7 @@ def _build_wrapped_function_tool(
493514
tool_origin: ToolOrigin | None = None,
494515
) -> FunctionTool:
495516
"""Create a FunctionTool with copied-tool-aware failure handling bound in one place."""
496-
on_invoke_tool = with_function_tool_failure_error_handler(
517+
on_invoke_tool = _with_context_function_tool_failure_error_handler(
497518
invoke_tool_impl,
498519
on_handled_error,
499520
)
@@ -1377,24 +1398,36 @@ def _build_handled_function_tool_error_handler(
13771398
span_message_for_json_decode_error: str | None = None,
13781399
include_input_json_in_logs: bool = True,
13791400
include_tool_name_in_log_messages: bool = True,
1380-
) -> Callable[[FunctionTool, Exception, str], None]:
1401+
) -> Callable[[FunctionTool, Exception, str, ToolContext[Any]], None]:
13811402
"""Create a consistent handled-error reporter for wrapped FunctionTools."""
13821403

1383-
def _on_handled_error(function_tool: FunctionTool, error: Exception, input_json: str) -> None:
1404+
def _on_handled_error(
1405+
function_tool: FunctionTool,
1406+
error: Exception,
1407+
input_json: str,
1408+
context: ToolContext[Any],
1409+
) -> None:
13841410
json_decode_error = _extract_tool_argument_json_error(error)
13851411
if json_decode_error is not None and span_message_for_json_decode_error is not None:
13861412
resolved_span_message = span_message_for_json_decode_error
13871413
span_error_detail = str(json_decode_error)
13881414
else:
13891415
resolved_span_message = span_message
13901416
span_error_detail = str(error)
1417+
trace_include_sensitive_data = (
1418+
context.run_config is None or context.run_config.trace_include_sensitive_data
1419+
)
1420+
trace_error = get_trace_tool_error(
1421+
trace_include_sensitive_data=trace_include_sensitive_data,
1422+
error_message=span_error_detail,
1423+
)
13911424

13921425
_error_tracing.attach_error_to_current_span(
13931426
SpanError(
13941427
message=resolved_span_message,
13951428
data={
13961429
"tool_name": function_tool.name,
1397-
"error": span_error_detail,
1430+
"error": trace_error,
13981431
},
13991432
)
14001433
)

src/agents/util/_tool_errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""Helpers for rendering tool errors in trace-safe form."""
2+
3+
REDACTED_TOOL_ERROR_MESSAGE = "Tool execution failed. Error details are redacted."
4+
5+
6+
def get_trace_tool_error(*, trace_include_sensitive_data: bool, error_message: str) -> str:
7+
"""Return a trace-safe tool error string based on the sensitive-data setting."""
8+
return error_message if trace_include_sensitive_data else REDACTED_TOOL_ERROR_MESSAGE

tests/test_run_step_execution.py

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@
9494
)
9595

9696

97-
def _function_span_names() -> list[str]:
98-
names: list[str] = []
97+
def _function_spans() -> list[dict[str, Any]]:
98+
function_spans: list[dict[str, Any]] = []
9999
for span in SPAN_PROCESSOR_TESTING.get_ordered_spans(including_empty=True):
100100
exported = span.export()
101101
if not exported:
@@ -105,6 +105,16 @@ def _function_span_names() -> list[str]:
105105
continue
106106
if span_data.get("type") != "function":
107107
continue
108+
function_spans.append(exported)
109+
return function_spans
110+
111+
112+
def _function_span_names() -> list[str]:
113+
names: list[str] = []
114+
for exported in _function_spans():
115+
span_data = exported.get("span_data")
116+
if not isinstance(span_data, dict):
117+
continue
108118
name = span_data.get("name")
109119
if isinstance(name, str):
110120
names.append(name)
@@ -510,6 +520,78 @@ async def _error_tool() -> str:
510520
await get_execute_result(agent, response)
511521

512522

523+
@pytest.mark.asyncio
524+
async def test_function_tool_error_trace_respects_sensitive_data_setting():
525+
async def _error_tool() -> str:
526+
raise ValueError("secret-token-123")
527+
528+
error_tool = function_tool(
529+
_error_tool,
530+
name_override="error_tool",
531+
failure_error_function=None,
532+
)
533+
agent = Agent(name="test", tools=[error_tool])
534+
response = ModelResponse(
535+
output=[get_function_tool_call("error_tool", "{}", call_id="1")],
536+
usage=Usage(),
537+
response_id=None,
538+
)
539+
540+
with trace("test"):
541+
with pytest.raises(UserError, match="Error running tool error_tool: secret-token-123"):
542+
await get_execute_result(
543+
agent,
544+
response,
545+
run_config=RunConfig(trace_include_sensitive_data=False),
546+
)
547+
548+
function_spans = _function_spans()
549+
550+
assert len(function_spans) == 1
551+
error = function_spans[0]["error"]
552+
assert error["message"] == "Error running tool"
553+
assert error["data"]["tool_name"] == "error_tool"
554+
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
555+
assert "secret-token-123" not in str(error)
556+
557+
558+
@pytest.mark.asyncio
559+
async def test_default_function_tool_error_trace_respects_sensitive_data_setting():
560+
async def _error_tool() -> str:
561+
raise ValueError("secret-token-123")
562+
563+
error_tool = function_tool(_error_tool, name_override="error_tool")
564+
agent = Agent(name="test", tools=[error_tool])
565+
response = ModelResponse(
566+
output=[get_function_tool_call("error_tool", "{}", call_id="1")],
567+
usage=Usage(),
568+
response_id=None,
569+
)
570+
571+
with trace("test"):
572+
result = await get_execute_result(
573+
agent,
574+
response,
575+
run_config=RunConfig(trace_include_sensitive_data=False),
576+
)
577+
578+
assert len(result.generated_items) == 2
579+
assert isinstance(result.next_step, NextStepRunAgain)
580+
assert_item_is_function_tool_call_output(
581+
result.generated_items[1],
582+
"An error occurred while running the tool. Please try again. Error: secret-token-123",
583+
)
584+
585+
function_spans = _function_spans()
586+
587+
assert len(function_spans) == 1
588+
error = function_spans[0]["error"]
589+
assert error["message"] == "Error running tool (non-fatal)"
590+
assert error["data"]["tool_name"] == "error_tool"
591+
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
592+
assert "secret-token-123" not in str(error)
593+
594+
513595
@pytest.mark.asyncio
514596
async def test_multiple_tool_calls_still_raise_when_sibling_cancelled():
515597
async def _ok_tool() -> str:
@@ -771,6 +853,43 @@ async def _cancel_tool() -> str:
771853
)
772854

773855

856+
@pytest.mark.asyncio
857+
async def test_cancelled_function_tool_error_trace_respects_sensitive_data_setting():
858+
async def _cancel_tool() -> str:
859+
raise asyncio.CancelledError("secret-token-123")
860+
861+
cancel_tool = function_tool(_cancel_tool, name_override="cancel_tool")
862+
agent = Agent(name="test", tools=[cancel_tool])
863+
response = ModelResponse(
864+
output=[get_function_tool_call("cancel_tool", "{}", call_id="1")],
865+
usage=Usage(),
866+
response_id=None,
867+
)
868+
869+
with trace("test"):
870+
result = await get_execute_result(
871+
agent,
872+
response,
873+
run_config=RunConfig(trace_include_sensitive_data=False),
874+
)
875+
876+
assert len(result.generated_items) == 2
877+
assert isinstance(result.next_step, NextStepRunAgain)
878+
assert_item_is_function_tool_call_output(
879+
result.generated_items[1],
880+
"An error occurred while running the tool. Please try again. Error: secret-token-123",
881+
)
882+
883+
function_spans = _function_spans()
884+
885+
assert len(function_spans) == 1
886+
error = function_spans[0]["error"]
887+
assert error["message"] == "Tool execution cancelled"
888+
assert error["data"]["tool_name"] == "cancel_tool"
889+
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
890+
assert "secret-token-123" not in str(error)
891+
892+
774893
@pytest.mark.asyncio
775894
async def test_multiple_tool_calls_surface_hook_failure_over_sibling_cancellation():
776895
hook_started = asyncio.Event()

0 commit comments

Comments
 (0)