Skip to content
Merged
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
16 changes: 13 additions & 3 deletions src/agents/run_internal/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -1852,7 +1852,12 @@ async def _run_single_tool(
raise UserError(f"Error running tool {func_tool.name}: {e}") from e

if self.config.trace_include_sensitive_data:
span_fn.span_data.output = result
# Approval short-circuits return the FunctionToolResult wrapper rather than the
# tool's own output, so read the output off it the way every other consumer of
# this value does (see `_build_function_tool_results`).
span_fn.span_data.output = (
result.output if isinstance(result, FunctionToolResult) else result
)
Comment thread
dfedoryshchev marked this conversation as resolved.
return result

async def _maybe_execute_tool_approval(
Expand Down Expand Up @@ -1966,7 +1971,13 @@ async def _maybe_execute_tool_approval(
)
span_fn.set_error(
SpanError(
message=rejection_message,
# The rejection message is app-supplied text, so it reaches the exported span
# under the same sensitive-data gate as the tool output above.
message=_error_tracing.get_trace_error(
trace_include_sensitive_data=self.config.trace_include_sensitive_data,
error_message=rejection_message,
redacted_message="Tool execution rejected",
),
data={
"tool_name": func_tool.name,
"error": (
Expand All @@ -1975,7 +1986,6 @@ async def _maybe_execute_tool_approval(
},
)
)
span_fn.span_data.output = rejection_message
return FunctionToolResult(
tool=func_tool,
output=rejection_message,
Expand Down
101 changes: 101 additions & 0 deletions tests/test_run_step_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,107 @@ async def _error_tool() -> str:
assert "secret-token-123" not in str(error)


def _make_approval_function_tool() -> FunctionTool:
async def _approval_tool() -> str:
return "ok"

return function_tool(_approval_tool, name_override="approval_tool", needs_approval=True)


@pytest.mark.asyncio
async def test_pending_approval_function_span_output_excludes_internal_result_object():
agent = Agent(
name="test",
instructions="system-prompt-abc",
tools=[_make_approval_function_tool()],
)
response = ModelResponse(
output=[get_function_tool_call("approval_tool", "{}", call_id="1")],
usage=Usage(),
response_id=None,
)

with trace("test"):
await get_execute_result(
agent,
response,
run_config=RunConfig(trace_include_sensitive_data=True),
)

function_spans = _function_spans()

assert len(function_spans) == 1
output = function_spans[0]["span_data"]["output"]
assert output is None
assert "system-prompt-abc" not in str(function_spans[0])


@pytest.mark.asyncio
async def test_rejected_tool_function_span_output_respects_sensitive_data_setting():
agent = Agent(name="test", tools=[_make_approval_function_tool()])
tool_call = get_function_tool_call("approval_tool", "{}", call_id="1")
response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)

context_wrapper: RunContextWrapper[Any] = RunContextWrapper(None)
reject_tool_call(
context_wrapper,
agent,
tool_call,
tool_name="approval_tool",
rejection_message="secret-denial-456",
)

with trace("test"):
await get_execute_result(
agent,
response,
context_wrapper=context_wrapper,
run_config=RunConfig(trace_include_sensitive_data=False),
)

function_spans = _function_spans()

assert len(function_spans) == 1
exported = function_spans[0]
assert exported["span_data"]["output"] is None
error = exported["error"]
assert error["message"] == "Tool execution rejected"
assert error["data"]["tool_name"] == "approval_tool"
assert error["data"]["error"] == "Tool execution for 1 was manually rejected by user."
assert "secret-denial-456" not in json.dumps(exported, default=str)


@pytest.mark.asyncio
async def test_rejected_tool_function_span_keeps_rejection_message_when_sensitive_data_included():
agent = Agent(name="test", tools=[_make_approval_function_tool()])
tool_call = get_function_tool_call("approval_tool", "{}", call_id="1")
response = ModelResponse(output=[tool_call], usage=Usage(), response_id=None)

context_wrapper: RunContextWrapper[Any] = RunContextWrapper(None)
reject_tool_call(
context_wrapper,
agent,
tool_call,
tool_name="approval_tool",
rejection_message="denied-by-policy",
)

with trace("test"):
await get_execute_result(
agent,
response,
context_wrapper=context_wrapper,
run_config=RunConfig(trace_include_sensitive_data=True),
)

function_spans = _function_spans()

assert len(function_spans) == 1
exported = function_spans[0]
assert exported["span_data"]["output"] == "denied-by-policy"
assert exported["error"]["message"] == "denied-by-policy"


@pytest.mark.asyncio
async def test_multiple_tool_calls_still_raise_when_sibling_cancelled():
async def _ok_tool() -> str:
Expand Down