3561 lines
115 KiB
Python
3561 lines
115 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import dataclasses
|
|
import gc
|
|
import json
|
|
from collections.abc import Callable
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass
|
|
from typing import Any, cast
|
|
|
|
import pytest
|
|
from openai.types.responses import ResponseFunctionToolCall
|
|
from openai.types.responses.response_output_item import McpApprovalRequest
|
|
from openai.types.responses.response_output_message import ResponseOutputMessage
|
|
from openai.types.responses.response_output_refusal import ResponseOutputRefusal
|
|
from pydantic import BaseModel
|
|
|
|
from agents import (
|
|
Agent,
|
|
AgentBase,
|
|
ApplyPatchTool,
|
|
FunctionTool,
|
|
HostedMCPTool,
|
|
MCPApprovalRequestItem,
|
|
MCPApprovalResponseItem,
|
|
MessageOutputItem,
|
|
ModelBehaviorError,
|
|
ModelRefusalError,
|
|
ModelResponse,
|
|
RunConfig,
|
|
RunContextWrapper,
|
|
RunHooks,
|
|
RunItem,
|
|
ShellTool,
|
|
ToolApprovalItem,
|
|
ToolCallItem,
|
|
ToolCallOutputItem,
|
|
ToolExecutionConfig,
|
|
ToolGuardrailFunctionOutput,
|
|
ToolInputGuardrail,
|
|
ToolOutputGuardrailData,
|
|
ToolOutputGuardrailTripwireTriggered,
|
|
ToolTimeoutError,
|
|
TResponseInputItem,
|
|
Usage,
|
|
UserError,
|
|
_debug,
|
|
tool_namespace,
|
|
tool_output_guardrail,
|
|
trace,
|
|
)
|
|
from agents._public_agent import set_public_agent
|
|
from agents.run_internal import run_loop, turn_resolution
|
|
from agents.run_internal.agent_bindings import bind_execution_agent, bind_public_agent
|
|
from agents.run_internal.run_loop import (
|
|
NextStepFinalOutput,
|
|
NextStepHandoff,
|
|
NextStepInterruption,
|
|
NextStepRunAgain,
|
|
ProcessedResponse,
|
|
SingleStepResult,
|
|
ToolRunApplyPatchCall,
|
|
ToolRunComputerAction,
|
|
ToolRunFunction,
|
|
ToolRunHandoff,
|
|
ToolRunLocalShellCall,
|
|
ToolRunMCPApprovalRequest,
|
|
ToolRunShellCall,
|
|
get_handoffs,
|
|
get_output_schema,
|
|
)
|
|
from agents.run_internal.tool_execution import execute_function_tool_calls
|
|
from agents.tool import function_tool
|
|
from agents.tool_context import ToolContext
|
|
|
|
from .test_responses import (
|
|
get_final_output_message,
|
|
get_function_tool,
|
|
get_function_tool_call,
|
|
get_handoff_tool_call,
|
|
get_text_input_item,
|
|
get_text_message,
|
|
)
|
|
from .testing_processor import SPAN_PROCESSOR_TESTING
|
|
from .utils.hitl import (
|
|
RecordingEditor,
|
|
assert_single_approval_interruption,
|
|
make_agent,
|
|
make_apply_patch_dict,
|
|
make_context_wrapper,
|
|
make_function_tool_call,
|
|
make_shell_call,
|
|
reject_tool_call,
|
|
)
|
|
|
|
|
|
def _function_spans() -> list[dict[str, Any]]:
|
|
function_spans: list[dict[str, Any]] = []
|
|
for span in SPAN_PROCESSOR_TESTING.get_ordered_spans(including_empty=True):
|
|
exported = span.export()
|
|
if not exported:
|
|
continue
|
|
span_data = exported.get("span_data")
|
|
if not isinstance(span_data, dict):
|
|
continue
|
|
if span_data.get("type") != "function":
|
|
continue
|
|
function_spans.append(exported)
|
|
return function_spans
|
|
|
|
|
|
def _function_span_names() -> list[str]:
|
|
names: list[str] = []
|
|
for exported in _function_spans():
|
|
span_data = exported.get("span_data")
|
|
if not isinstance(span_data, dict):
|
|
continue
|
|
name = span_data.get("name")
|
|
if isinstance(name, str):
|
|
names.append(name)
|
|
return names
|
|
|
|
|
|
def _bind_agent(agent: Agent[Any]):
|
|
public_agent = getattr(agent, "_agents_public_agent", None)
|
|
if isinstance(public_agent, Agent):
|
|
return bind_execution_agent(public_agent=public_agent, execution_agent=agent)
|
|
return bind_public_agent(agent)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_response_is_final_output():
|
|
agent = Agent[None](name="test")
|
|
response = ModelResponse(
|
|
output=[],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert result.original_input == "hello"
|
|
assert result.generated_items == []
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
assert result.next_step.output == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_no_tool_calls_is_final_output():
|
|
agent = Agent(name="test")
|
|
response = ModelResponse(
|
|
output=[get_text_message("hello_world")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert result.original_input == "hello"
|
|
assert len(result.generated_items) == 1
|
|
assert_item_is_message(result.generated_items[0], "hello_world")
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
assert result.next_step.output == "hello_world"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_no_tool_calls_multiple_messages_is_final_output():
|
|
agent = Agent(name="test")
|
|
response = ModelResponse(
|
|
output=[
|
|
get_text_message("hello_world"),
|
|
get_text_message("bye"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(
|
|
agent,
|
|
response,
|
|
original_input=[
|
|
get_text_input_item("test"),
|
|
get_text_input_item("test2"),
|
|
],
|
|
)
|
|
|
|
assert len(result.original_input) == 2
|
|
assert len(result.generated_items) == 2
|
|
assert_item_is_message(result.generated_items[0], "hello_world")
|
|
assert_item_is_message(result.generated_items[1], "bye")
|
|
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
assert result.next_step.output == "bye"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_allows_unhashable_tool_call_arguments():
|
|
agent = make_agent()
|
|
response = ModelResponse(output=[], usage=Usage(), response_id="resp")
|
|
raw_tool_call = {
|
|
"type": "function_call",
|
|
"call_id": "call-1",
|
|
"name": "tool",
|
|
"arguments": {"key": "value"},
|
|
}
|
|
pre_step_items: list[RunItem] = [ToolCallItem(agent=agent, raw_item=raw_tool_call)]
|
|
|
|
result = await get_execute_result(agent, response, generated_items=pre_step_items)
|
|
|
|
assert len(result.generated_items) == 1
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_with_tool_call_is_run_again():
|
|
agent = Agent(name="test", tools=[get_function_tool(name="test", return_value="123")])
|
|
response = ModelResponse(
|
|
output=[get_text_message("hello_world"), get_function_tool_call("test", "")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert result.original_input == "hello"
|
|
|
|
# 3 items: new message, tool call, tool result
|
|
assert len(result.generated_items) == 3
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
items = result.generated_items
|
|
assert_item_is_message(items[0], "hello_world")
|
|
assert_item_is_function_tool_call(items[1], "test", None)
|
|
assert_item_is_function_tool_call_output(items[2], "123")
|
|
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_concurrency_default_starts_all_calls():
|
|
active_count = 0
|
|
max_seen_count = 0
|
|
|
|
async def tracked_tool(value: int) -> str:
|
|
nonlocal active_count, max_seen_count
|
|
active_count += 1
|
|
max_seen_count = max(max_seen_count, active_count)
|
|
try:
|
|
await asyncio.sleep(0.01)
|
|
return f"ok-{value}"
|
|
finally:
|
|
active_count -= 1
|
|
|
|
tool = function_tool(tracked_tool, name_override="tracked_tool")
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 1}), call_id="call_1"),
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 2}), call_id="call_2"),
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 3}), call_id="call_3"),
|
|
],
|
|
usage=Usage(),
|
|
response_id="resp",
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert active_count == 0
|
|
assert max_seen_count == 3
|
|
assert_item_is_function_tool_call_output(result.generated_items[3], "ok-1")
|
|
assert_item_is_function_tool_call_output(result.generated_items[4], "ok-2")
|
|
assert_item_is_function_tool_call_output(result.generated_items[5], "ok-3")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_concurrency_cap_limits_calls_and_preserves_output_order():
|
|
active_count = 0
|
|
max_seen_count = 0
|
|
|
|
async def tracked_tool(value: int) -> str:
|
|
nonlocal active_count, max_seen_count
|
|
active_count += 1
|
|
max_seen_count = max(max_seen_count, active_count)
|
|
try:
|
|
await asyncio.sleep(0.03 if value == 1 else 0.001)
|
|
return f"ok-{value}"
|
|
finally:
|
|
active_count -= 1
|
|
|
|
tool = function_tool(tracked_tool, name_override="tracked_tool")
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 1}), call_id="call_1"),
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 2}), call_id="call_2"),
|
|
get_function_tool_call("tracked_tool", json.dumps({"value": 3}), call_id="call_3"),
|
|
],
|
|
usage=Usage(),
|
|
response_id="resp",
|
|
)
|
|
|
|
result = await get_execute_result(
|
|
agent,
|
|
response,
|
|
run_config=RunConfig(tool_execution=ToolExecutionConfig(max_function_tool_concurrency=2)),
|
|
)
|
|
|
|
assert active_count == 0
|
|
assert max_seen_count == 2
|
|
assert_item_is_function_tool_call_output(result.generated_items[3], "ok-1")
|
|
assert_item_is_function_tool_call_output(result.generated_items[4], "ok-2")
|
|
assert_item_is_function_tool_call_output(result.generated_items[5], "ok-3")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_concurrency_cap_leaves_queued_calls_unstarted_after_failure():
|
|
started_tools: list[str] = []
|
|
|
|
async def failing_tool() -> str:
|
|
started_tools.append("failing_tool")
|
|
raise RuntimeError("boom")
|
|
|
|
async def queued_tool() -> str:
|
|
started_tools.append("queued_tool")
|
|
return "should-not-run"
|
|
|
|
failing = function_tool(
|
|
failing_tool,
|
|
name_override="failing_tool",
|
|
failure_error_function=None,
|
|
)
|
|
queued = function_tool(queued_tool, name_override="queued_tool")
|
|
agent = Agent(name="test", tools=[failing, queued])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("failing_tool", "{}", call_id="call_1"),
|
|
get_function_tool_call("queued_tool", "{}", call_id="call_2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id="resp",
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool failing_tool: boom"):
|
|
await get_execute_result(
|
|
agent,
|
|
response,
|
|
run_config=RunConfig(
|
|
tool_execution=ToolExecutionConfig(max_function_tool_concurrency=1)
|
|
),
|
|
)
|
|
|
|
assert started_tools == ["failing_tool"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_hosted_shell_items_without_message_runs_again():
|
|
shell_tool = ShellTool(environment={"type": "container_auto"})
|
|
agent = Agent(name="test", tools=[shell_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
make_shell_call(
|
|
"call_shell_hosted", id_value="shell_call_hosted", commands=["echo hi"]
|
|
),
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "shell_call_output",
|
|
"id": "sh_out_hosted",
|
|
"call_id": "call_shell_hosted",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"stdout": "hi\n",
|
|
"stderr": "",
|
|
"outcome": {"type": "exit", "exit_code": 0},
|
|
}
|
|
],
|
|
},
|
|
),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert isinstance(result.generated_items[0], ToolCallItem)
|
|
assert isinstance(result.generated_items[1], ToolCallOutputItem)
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_shell_output_only_without_message_runs_again():
|
|
agent = Agent(name="test")
|
|
response = ModelResponse(
|
|
output=[
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "shell_call_output",
|
|
"id": "sh_out_only",
|
|
"call_id": "call_shell_only",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"stdout": "hi\n",
|
|
"stderr": "",
|
|
"outcome": {"type": "exit", "exit_code": 0},
|
|
}
|
|
],
|
|
},
|
|
),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 1
|
|
assert isinstance(result.generated_items[0], ToolCallOutputItem)
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_tool_search_only_without_message_runs_again():
|
|
agent = Agent(name="test")
|
|
response = ModelResponse(output=[], usage=Usage(), response_id=None)
|
|
response.output = cast(
|
|
Any,
|
|
[
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tsc_step",
|
|
"arguments": {"paths": ["crm"], "query": "profile"},
|
|
"execution": "server",
|
|
"status": "completed",
|
|
},
|
|
{
|
|
"type": "tool_search_output",
|
|
"id": "tso_step",
|
|
"execution": "server",
|
|
"status": "completed",
|
|
"tools": [
|
|
{
|
|
"type": "function",
|
|
"name": "lookup_account",
|
|
"description": "Look up a CRM account.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"account_id": {
|
|
"type": "string",
|
|
}
|
|
},
|
|
"required": ["account_id"],
|
|
},
|
|
"defer_loading": True,
|
|
}
|
|
],
|
|
},
|
|
],
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert getattr(result.generated_items[0].raw_item, "type", None) == "tool_search_call"
|
|
raw_output = result.generated_items[1].raw_item
|
|
assert getattr(raw_output, "type", None) == "tool_search_output"
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_client_tool_search_requires_manual_handling() -> None:
|
|
agent = Agent(name="test")
|
|
response = ModelResponse(output=[], usage=Usage(), response_id=None)
|
|
response.output = cast(
|
|
Any,
|
|
[
|
|
{
|
|
"type": "tool_search_call",
|
|
"id": "tsc_client_step",
|
|
"call_id": "call_tool_search_client",
|
|
"arguments": {"paths": ["crm"], "query": "profile"},
|
|
"execution": "client",
|
|
"status": "completed",
|
|
}
|
|
],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="Client-executed tool_search calls"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plaintext_agent_hosted_shell_with_refusal_message_raises_refusal_error():
|
|
shell_tool = ShellTool(environment={"type": "container_auto"})
|
|
agent = Agent(name="test", tools=[shell_tool])
|
|
refusal_message = ResponseOutputMessage(
|
|
id="msg_refusal",
|
|
type="message",
|
|
role="assistant",
|
|
content=[ResponseOutputRefusal(type="refusal", refusal="I cannot help with that.")],
|
|
status="completed",
|
|
)
|
|
response = ModelResponse(
|
|
output=[
|
|
make_shell_call(
|
|
"call_shell_hosted_refusal",
|
|
id_value="shell_call_hosted_refusal",
|
|
commands=["echo hi"],
|
|
),
|
|
cast(
|
|
Any,
|
|
{
|
|
"type": "shell_call_output",
|
|
"id": "sh_out_hosted_refusal",
|
|
"call_id": "call_shell_hosted_refusal",
|
|
"status": "completed",
|
|
"output": [
|
|
{
|
|
"stdout": "hi\n",
|
|
"stderr": "",
|
|
"outcome": {"type": "exit", "exit_code": 0},
|
|
}
|
|
],
|
|
},
|
|
),
|
|
refusal_message,
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ModelRefusalError) as exc_info:
|
|
await get_execute_result(agent, response)
|
|
|
|
assert exc_info.value.refusal == "I cannot help with that."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls():
|
|
agent = Agent(
|
|
name="test",
|
|
tools=[
|
|
get_function_tool(name="test_1", return_value="123"),
|
|
get_function_tool(name="test_2", return_value="456"),
|
|
get_function_tool(name="test_3", return_value="789"),
|
|
],
|
|
)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_text_message("Hello, world!"),
|
|
get_function_tool_call("test_1"),
|
|
get_function_tool_call("test_2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
assert result.original_input == "hello"
|
|
|
|
# 5 items: new message, 2 tool calls, 2 tool call outputs
|
|
assert len(result.generated_items) == 5
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
items = result.generated_items
|
|
assert_item_is_message(items[0], "Hello, world!")
|
|
assert_item_is_function_tool_call(items[1], "test_1", None)
|
|
assert_item_is_function_tool_call(items[2], "test_2", None)
|
|
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_with_tool_context():
|
|
async def _fake_tool(context: ToolContext[str], value: str) -> str:
|
|
return f"{value}-{context.tool_call_id}"
|
|
|
|
tool = function_tool(_fake_tool, name_override="fake_tool", failure_error_function=None)
|
|
|
|
agent = Agent(
|
|
name="test",
|
|
tools=[tool],
|
|
)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("fake_tool", json.dumps({"value": "123"}), call_id="1"),
|
|
get_function_tool_call("fake_tool", json.dumps({"value": "456"}), call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
assert result.original_input == "hello"
|
|
|
|
# 4 items: new message, 2 tool calls, 2 tool call outputs
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
items = result.generated_items
|
|
assert_item_is_function_tool_call(items[0], "fake_tool", json.dumps({"value": "123"}))
|
|
assert_item_is_function_tool_call(items[1], "fake_tool", json.dumps({"value": "456"}))
|
|
assert_item_is_function_tool_call_output(items[2], "123-1")
|
|
assert_item_is_function_tool_call_output(items[3], "456-2")
|
|
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_still_raise_when_sibling_failure_error_function_none():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _error_tool() -> str:
|
|
raise ValueError("boom")
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_error_trace_respects_sensitive_data_setting():
|
|
async def _error_tool() -> str:
|
|
raise ValueError("secret-token-123")
|
|
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
agent = Agent(name="test", tools=[error_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("error_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with trace("test"):
|
|
with pytest.raises(UserError, match="Error running tool error_tool: secret-token-123"):
|
|
await get_execute_result(
|
|
agent,
|
|
response,
|
|
run_config=RunConfig(trace_include_sensitive_data=False),
|
|
)
|
|
|
|
function_spans = _function_spans()
|
|
|
|
assert len(function_spans) == 1
|
|
error = function_spans[0]["error"]
|
|
assert error["message"] == "Error running tool"
|
|
assert error["data"]["tool_name"] == "error_tool"
|
|
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
|
|
assert "secret-token-123" not in str(error)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_default_function_tool_error_trace_respects_sensitive_data_setting():
|
|
async def _error_tool() -> str:
|
|
raise ValueError("secret-token-123")
|
|
|
|
error_tool = function_tool(_error_tool, name_override="error_tool")
|
|
agent = Agent(name="test", tools=[error_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("error_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with trace("test"):
|
|
result = await get_execute_result(
|
|
agent,
|
|
response,
|
|
run_config=RunConfig(trace_include_sensitive_data=False),
|
|
)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[1],
|
|
"An error occurred while running the tool. Please try again. Error: secret-token-123",
|
|
)
|
|
|
|
function_spans = _function_spans()
|
|
|
|
assert len(function_spans) == 1
|
|
error = function_spans[0]["error"]
|
|
assert error["message"] == "Error running tool (non-fatal)"
|
|
assert error["data"]["tool_name"] == "error_tool"
|
|
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
|
|
assert "secret-token-123" not in str(error)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_still_raise_when_sibling_cancelled():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_cancel_sibling_when_tool_raises_cancelled_error():
|
|
started = asyncio.Event()
|
|
cancellation_started = asyncio.Event()
|
|
cancellation_finished = asyncio.Event()
|
|
allow_cancellation_exit = asyncio.Event()
|
|
|
|
async def _waiting_tool() -> str:
|
|
started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError:
|
|
cancellation_started.set()
|
|
await allow_cancellation_exit.wait()
|
|
cancellation_finished.set()
|
|
raise
|
|
|
|
async def _cancel_tool() -> str:
|
|
await started.wait()
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
waiting_tool = function_tool(
|
|
_waiting_tool,
|
|
name_override="waiting_tool",
|
|
failure_error_function=None,
|
|
)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[waiting_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("waiting_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response))
|
|
|
|
await asyncio.wait_for(started.wait(), timeout=0.2)
|
|
await asyncio.wait_for(cancellation_started.wait(), timeout=0.2)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(execution_task, timeout=0.2)
|
|
|
|
assert not cancellation_finished.is_set()
|
|
|
|
allow_cancellation_exit.set()
|
|
await asyncio.wait_for(cancellation_finished.wait(), timeout=0.2)
|
|
assert cancellation_finished.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_use_custom_failure_error_function_for_cancelled_tool():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
seen_error: Exception | None = None
|
|
|
|
def _custom_failure_error(_context: RunContextWrapper[Any], _error: Exception) -> str:
|
|
nonlocal seen_error
|
|
assert isinstance(_error, Exception)
|
|
assert not isinstance(_error, asyncio.CancelledError)
|
|
seen_error = _error
|
|
return "custom-cancel-msg"
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=_custom_failure_error,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[2], "ok")
|
|
assert_item_is_function_tool_call_output(result.generated_items[3], "custom-cancel-msg")
|
|
assert seen_error is not None
|
|
assert str(seen_error) == "tool-cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_use_custom_failure_error_function_for_replaced_cancelled_tool():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
def _custom_failure_error(_context: RunContextWrapper[Any], _error: Exception) -> str:
|
|
return "custom-cancel-msg"
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = dataclasses.replace(
|
|
function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=_custom_failure_error,
|
|
),
|
|
name="cancel_tool",
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[2], "ok")
|
|
assert_item_is_function_tool_call_output(result.generated_items[3], "custom-cancel-msg")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_use_default_failure_error_function_for_copied_cancelled_tool():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = copy.deepcopy(function_tool(_cancel_tool, name_override="cancel_tool"))
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[2], "ok")
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[3],
|
|
"An error occurred while running the tool. Please try again. Error: tool-cancelled",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_use_default_failure_error_function_for_manual_cancelled_tool():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _manual_on_invoke_tool(_ctx: ToolContext[Any], _args: str) -> str:
|
|
raise asyncio.CancelledError("manual-tool-cancelled")
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
manual_tool = FunctionTool(
|
|
name="manual_cancel_tool",
|
|
description="manual cancel",
|
|
params_json_schema={},
|
|
on_invoke_tool=_manual_on_invoke_tool,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, manual_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("manual_cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[2], "ok")
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[3],
|
|
"An error occurred while running the tool. Please try again. Error: manual-tool-cancelled",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_tool_call_uses_default_failure_error_function_for_cancelled_tool():
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
cancel_tool = function_tool(_cancel_tool, name_override="cancel_tool")
|
|
agent = Agent(name="test", tools=[cancel_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("cancel_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[1],
|
|
"An error occurred while running the tool. Please try again. Error: tool-cancelled",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_function_tool_error_trace_respects_sensitive_data_setting():
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("secret-token-123")
|
|
|
|
cancel_tool = function_tool(_cancel_tool, name_override="cancel_tool")
|
|
agent = Agent(name="test", tools=[cancel_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("cancel_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with trace("test"):
|
|
result = await get_execute_result(
|
|
agent,
|
|
response,
|
|
run_config=RunConfig(trace_include_sensitive_data=False),
|
|
)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[1],
|
|
"An error occurred while running the tool. Please try again. Error: secret-token-123",
|
|
)
|
|
|
|
function_spans = _function_spans()
|
|
|
|
assert len(function_spans) == 1
|
|
error = function_spans[0]["error"]
|
|
assert error["message"] == "Tool execution cancelled"
|
|
assert error["data"]["tool_name"] == "cancel_tool"
|
|
assert error["data"]["error"] == "Tool execution failed. Error details are redacted."
|
|
assert "secret-token-123" not in str(error)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_surface_hook_failure_over_sibling_cancellation():
|
|
hook_started = asyncio.Event()
|
|
|
|
class FailingHooks(RunHooks[Any]):
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
if tool.name != "ok_tool":
|
|
return
|
|
|
|
hook_started.set()
|
|
raise ValueError("hook boom")
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
await hook_started.wait()
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
hooks = FailingHooks()
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool ok_tool: hook boom"):
|
|
await get_execute_result(agent, response, hooks=hooks)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_surface_output_guardrail_failure_over_sibling_cancellation():
|
|
guardrail_started = asyncio.Event()
|
|
|
|
@tool_output_guardrail
|
|
async def tripwire_guardrail(
|
|
data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
guardrail_started.set()
|
|
return ToolGuardrailFunctionOutput.raise_exception(
|
|
output_info={"tool": data.context.tool_name}
|
|
)
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
await guardrail_started.wait()
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
ok_tool = function_tool(
|
|
_ok_tool,
|
|
name_override="ok_tool",
|
|
failure_error_function=None,
|
|
tool_output_guardrails=[tripwire_guardrail],
|
|
)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ToolOutputGuardrailTripwireTriggered):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_preserves_contextvar_from_tool_body_to_post_invoke_hooks():
|
|
tool_state: ContextVar[str] = ContextVar("tool_state", default="unset")
|
|
seen_values: list[tuple[str, str]] = []
|
|
|
|
@tool_output_guardrail
|
|
async def record_guardrail(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
|
|
seen_values.append(("guardrail", tool_state.get()))
|
|
return ToolGuardrailFunctionOutput.allow(output_info="checked")
|
|
|
|
class RecordingHooks(RunHooks[Any]):
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
seen_values.append(("hook", tool_state.get()))
|
|
|
|
async def _context_tool() -> str:
|
|
tool_state.set("from-tool")
|
|
return "ok"
|
|
|
|
hooks = RecordingHooks()
|
|
context_tool = function_tool(
|
|
_context_tool,
|
|
name_override="context_tool",
|
|
tool_output_guardrails=[record_guardrail],
|
|
)
|
|
agent = Agent(name="test", tools=[context_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("context_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response, hooks=hooks)
|
|
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[1], "ok")
|
|
assert seen_values == [("guardrail", "from-tool"), ("hook", "from-tool")]
|
|
assert tool_state.get() == "unset"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mixed_tool_calls_preserve_shell_output_when_function_tool_cancelled():
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
cancel_tool = function_tool(_cancel_tool, name_override="cancel_tool")
|
|
shell_tool = ShellTool(executor=lambda _request: "shell ok")
|
|
agent = Agent(name="test", tools=[cancel_tool, shell_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("cancel_tool", "{}", call_id="fn-1"),
|
|
make_shell_call("shell-1"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[2],
|
|
"An error occurred while running the tool. Please try again. Error: tool-cancelled",
|
|
)
|
|
shell_output = cast(ToolCallOutputItem, result.generated_items[3])
|
|
assert shell_output.output == "shell ok"
|
|
assert cast(dict[str, Any], shell_output.raw_item)["type"] == "shell_call_output"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_still_raise_tool_timeout_error():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _slow_tool() -> str:
|
|
await asyncio.sleep(0.2)
|
|
return "slow"
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
slow_tool = function_tool(
|
|
_slow_tool,
|
|
name_override="slow_tool",
|
|
timeout=0.01,
|
|
timeout_behavior="raise_exception",
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, slow_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("slow_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ToolTimeoutError, match="timed out"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_still_raise_model_behavior_error_when_failure_error_none():
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
def _echo(value: str) -> str:
|
|
return value
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
guarded_tool = function_tool(
|
|
_echo,
|
|
name_override="guarded_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, guarded_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("guarded_tool", "bad_json", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="Invalid JSON input for tool guarded_tool"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_do_not_run_on_tool_end_for_cancelled_tool():
|
|
ok_tool_end_called = asyncio.Event()
|
|
|
|
class RecordingHooks(RunHooks[Any]):
|
|
def __init__(self):
|
|
self.results: dict[str, object] = {}
|
|
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
self.results[tool.name] = result
|
|
if tool.name == "ok_tool":
|
|
ok_tool_end_called.set()
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _cancel_tool() -> str:
|
|
await ok_tool_end_called.wait()
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
hooks = RecordingHooks()
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, cancel_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("cancel_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await get_execute_result(agent, response, hooks=hooks)
|
|
|
|
assert hooks.results == {
|
|
"ok_tool": "ok",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_skip_post_invoke_work_for_cancelled_sibling_teardown():
|
|
waiting_tool_started = asyncio.Event()
|
|
failure_handler_called = asyncio.Event()
|
|
output_guardrail_called = asyncio.Event()
|
|
on_tool_end_called = asyncio.Event()
|
|
|
|
@tool_output_guardrail
|
|
async def allow_output_guardrail(
|
|
data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
output_guardrail_called.set()
|
|
return ToolGuardrailFunctionOutput.allow(output_info={"echo": data.output})
|
|
|
|
class RecordingHooks(RunHooks[Any]):
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
if tool.name == "waiting_tool":
|
|
on_tool_end_called.set()
|
|
|
|
async def _waiting_tool() -> str:
|
|
waiting_tool_started.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
|
|
async def _error_tool() -> str:
|
|
await waiting_tool_started.wait()
|
|
raise ValueError("boom")
|
|
|
|
def _failure_handler(_ctx: RunContextWrapper[Any], error: Exception) -> str:
|
|
failure_handler_called.set()
|
|
return f"handled:{error}"
|
|
|
|
waiting_tool = function_tool(
|
|
_waiting_tool,
|
|
name_override="waiting_tool",
|
|
failure_error_function=_failure_handler,
|
|
tool_output_guardrails=[allow_output_guardrail],
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[waiting_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("waiting_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await get_execute_result(agent, response, hooks=RecordingHooks())
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
assert not failure_handler_called.is_set()
|
|
assert not output_guardrail_called.is_set()
|
|
assert not on_tool_end_called.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_tool_calls_parent_cancellation_skips_post_invoke_work():
|
|
tool_started = asyncio.Event()
|
|
failure_handler_called = asyncio.Event()
|
|
output_guardrail_called = asyncio.Event()
|
|
on_tool_end_called = asyncio.Event()
|
|
|
|
@tool_output_guardrail
|
|
async def allow_output_guardrail(
|
|
data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
output_guardrail_called.set()
|
|
return ToolGuardrailFunctionOutput.allow(output_info={"echo": data.output})
|
|
|
|
class RecordingHooks(RunHooks[Any]):
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
on_tool_end_called.set()
|
|
|
|
async def _waiting_tool() -> str:
|
|
tool_started.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
|
|
def _failure_handler(_ctx: RunContextWrapper[Any], error: Exception) -> str:
|
|
failure_handler_called.set()
|
|
return f"handled:{error}"
|
|
|
|
tool = function_tool(
|
|
_waiting_tool,
|
|
name_override="waiting_tool",
|
|
failure_error_function=_failure_handler,
|
|
tool_output_guardrails=[allow_output_guardrail],
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
tool_runs = [
|
|
ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("waiting_tool", "{}", call_id="1"),
|
|
),
|
|
function_tool=tool,
|
|
)
|
|
]
|
|
|
|
execution_task = asyncio.create_task(
|
|
execute_function_tool_calls(
|
|
bindings=bind_public_agent(agent),
|
|
tool_runs=tool_runs,
|
|
hooks=RecordingHooks(),
|
|
context_wrapper=RunContextWrapper(None),
|
|
config=RunConfig(),
|
|
isolate_parallel_failures=True,
|
|
)
|
|
)
|
|
await asyncio.wait_for(tool_started.wait(), timeout=0.2)
|
|
|
|
execution_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(execution_task, timeout=0.1)
|
|
|
|
await asyncio.sleep(0)
|
|
|
|
assert not failure_handler_called.is_set()
|
|
assert not output_guardrail_called.is_set()
|
|
assert not on_tool_end_called.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.skipif(
|
|
not hasattr(asyncio, "eager_task_factory"),
|
|
reason="eager_task_factory requires Python 3.12+",
|
|
)
|
|
async def test_execute_function_tool_calls_eager_task_factory_tracks_state_safely():
|
|
async def _first_tool() -> str:
|
|
return "first"
|
|
|
|
async def _second_tool() -> str:
|
|
return "second"
|
|
|
|
first_tool = function_tool(_first_tool, name_override="first_tool")
|
|
second_tool = function_tool(_second_tool, name_override="second_tool")
|
|
tool_runs = [
|
|
ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("first_tool", "{}", call_id="call-1"),
|
|
),
|
|
function_tool=first_tool,
|
|
),
|
|
ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("second_tool", "{}", call_id="call-2"),
|
|
),
|
|
function_tool=second_tool,
|
|
),
|
|
]
|
|
loop = asyncio.get_running_loop()
|
|
previous_task_factory = loop.get_task_factory()
|
|
eager_task_factory = cast(Any, asyncio.eager_task_factory)
|
|
loop.set_task_factory(eager_task_factory)
|
|
|
|
try:
|
|
(
|
|
function_results,
|
|
input_guardrail_results,
|
|
output_guardrail_results,
|
|
) = await execute_function_tool_calls(
|
|
bindings=bind_public_agent(Agent(name="test", tools=[first_tool, second_tool])),
|
|
tool_runs=tool_runs,
|
|
hooks=RunHooks(),
|
|
context_wrapper=RunContextWrapper(None),
|
|
config=RunConfig(),
|
|
)
|
|
finally:
|
|
loop.set_task_factory(previous_task_factory)
|
|
|
|
assert [result.output for result in function_results] == ["first", "second"]
|
|
assert input_guardrail_results == []
|
|
assert output_guardrail_results == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_disabled_before_execution_fails_before_starting_siblings() -> None:
|
|
enabled_checks: list[bool] = []
|
|
disabled_tool_invocations = 0
|
|
sibling_tool_invocations = 0
|
|
|
|
def _is_lookup_enabled(_ctx: RunContextWrapper[Any], _agent: AgentBase[Any]) -> bool:
|
|
enabled = not enabled_checks
|
|
enabled_checks.append(enabled)
|
|
return enabled
|
|
|
|
@function_tool(name_override="lookup_secret", is_enabled=_is_lookup_enabled)
|
|
def lookup_secret() -> str:
|
|
nonlocal disabled_tool_invocations
|
|
disabled_tool_invocations += 1
|
|
return "secret"
|
|
|
|
@function_tool(name_override="record_side_effect")
|
|
def record_side_effect() -> str:
|
|
nonlocal sibling_tool_invocations
|
|
sibling_tool_invocations += 1
|
|
return "recorded"
|
|
|
|
agent = Agent(name="test", tools=[lookup_secret, record_side_effect])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("lookup_secret", "{}", call_id="call-1"),
|
|
get_function_tool_call("record_side_effect", "{}", call_id="call-2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="lookup_secret is currently disabled"):
|
|
await get_execute_result(agent, response)
|
|
|
|
assert enabled_checks == [True, False]
|
|
assert disabled_tool_invocations == 0
|
|
assert sibling_tool_invocations == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_tool_calls_allows_non_agent_function_tool() -> None:
|
|
@function_tool(name_override="synthetic_tool")
|
|
def synthetic_tool() -> str:
|
|
return "synthetic-result"
|
|
|
|
tool_run = ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("synthetic_tool", "{}", call_id="call-1"),
|
|
),
|
|
function_tool=synthetic_tool,
|
|
)
|
|
|
|
(
|
|
function_results,
|
|
input_guardrail_results,
|
|
output_guardrail_results,
|
|
) = await execute_function_tool_calls(
|
|
bindings=bind_public_agent(Agent(name="test", tools=[])),
|
|
tool_runs=[tool_run],
|
|
hooks=RunHooks(),
|
|
context_wrapper=RunContextWrapper(None),
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert [result.output for result in function_results] == ["synthetic-result"]
|
|
assert input_guardrail_results == []
|
|
assert output_guardrail_results == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_tool_calls_collapse_trace_name_for_top_level_deferred_tools():
|
|
async def _shipping_eta(tracking_number: str) -> str:
|
|
return f"eta:{tracking_number}"
|
|
|
|
tool = function_tool(
|
|
_shipping_eta,
|
|
name_override="get_shipping_eta",
|
|
defer_loading=True,
|
|
)
|
|
tool_run = ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call(
|
|
"get_shipping_eta",
|
|
'{"tracking_number":"ZX-123"}',
|
|
call_id="call-1",
|
|
namespace="get_shipping_eta",
|
|
),
|
|
),
|
|
function_tool=tool,
|
|
)
|
|
|
|
with trace("test_execute_function_tool_calls_collapse_trace_name_for_top_level_deferred_tools"):
|
|
await execute_function_tool_calls(
|
|
bindings=bind_public_agent(Agent(name="test", tools=[tool])),
|
|
tool_runs=[tool_run],
|
|
hooks=RunHooks(),
|
|
context_wrapper=RunContextWrapper(None),
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert "get_shipping_eta" in _function_span_names()
|
|
assert "get_shipping_eta.get_shipping_eta" not in _function_span_names()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_tool_calls_preserve_trace_name_for_explicit_namespace():
|
|
async def _shipping_eta(tracking_number: str) -> str:
|
|
return f"eta:{tracking_number}"
|
|
|
|
tool = tool_namespace(
|
|
name="shipping",
|
|
description="Shipping tools",
|
|
tools=[
|
|
function_tool(
|
|
_shipping_eta,
|
|
name_override="get_shipping_eta",
|
|
defer_loading=True,
|
|
)
|
|
],
|
|
)[0]
|
|
tool_run = ToolRunFunction(
|
|
tool_call=cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call(
|
|
"get_shipping_eta",
|
|
'{"tracking_number":"ZX-123"}',
|
|
call_id="call-1",
|
|
namespace="shipping",
|
|
),
|
|
),
|
|
function_tool=tool,
|
|
)
|
|
|
|
with trace("test_execute_function_tool_calls_preserve_trace_name_for_explicit_namespace"):
|
|
await execute_function_tool_calls(
|
|
bindings=bind_public_agent(Agent(name="test", tools=[tool])),
|
|
tool_runs=[tool_run],
|
|
hooks=RunHooks(),
|
|
context_wrapper=RunContextWrapper(None),
|
|
config=RunConfig(),
|
|
)
|
|
|
|
assert "shipping.get_shipping_eta" in _function_span_names()
|
|
assert "get_shipping_eta" not in _function_span_names()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_tool_calls_rejects_reserved_same_name_namespace_shape():
|
|
async def _lookup_account(customer_id: str) -> str:
|
|
return f"account:{customer_id}"
|
|
|
|
with pytest.raises(UserError, match="synthetic namespace `lookup_account.lookup_account`"):
|
|
tool_namespace(
|
|
name="lookup_account",
|
|
description="Same-name namespace",
|
|
tools=[
|
|
function_tool(
|
|
_lookup_account,
|
|
name_override="lookup_account",
|
|
defer_loading=True,
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_tool_call_still_raises_normal_exception():
|
|
async def _error_tool() -> str:
|
|
raise ValueError("boom")
|
|
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[error_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("error_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_tool_call_still_raises_cancelled_error():
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("solo-cancel")
|
|
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[cancel_tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("cancel_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_allow_exception_objects_as_tool_outputs():
|
|
async def _returns_exception() -> ValueError:
|
|
return ValueError("as data")
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
returning_tool = function_tool(
|
|
_returns_exception,
|
|
name_override="returns_exception",
|
|
failure_error_function=None,
|
|
)
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
|
|
agent = Agent(name="test", tools=[returning_tool, ok_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("returns_exception", "{}", call_id="1"),
|
|
get_function_tool_call("ok_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 4
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert_item_is_function_tool_call_output(result.generated_items[2], "as data")
|
|
assert_item_is_function_tool_call_output(result.generated_items[3], "ok")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_still_raise_non_cancellation_base_exceptions():
|
|
class ToolAborted(BaseException):
|
|
pass
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _aborting_tool() -> str:
|
|
raise ToolAborted()
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
aborting_tool = function_tool(
|
|
_aborting_tool,
|
|
name_override="aborting_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, aborting_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("aborting_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ToolAborted):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_prioritize_fatal_base_exception_over_user_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
class ToolAborted(BaseException):
|
|
pass
|
|
|
|
async def _user_error_tool() -> str:
|
|
raise UserError("non-fatal")
|
|
|
|
async def _fatal_tool() -> str:
|
|
raise ToolAborted("fatal")
|
|
|
|
user_error_tool = function_tool(
|
|
_user_error_tool,
|
|
name_override="user_error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
fatal_tool = function_tool(
|
|
_fatal_tool,
|
|
name_override="fatal_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
original_wait = asyncio.wait
|
|
|
|
async def _wait_with_non_fatal_task_first(*args: Any, **kwargs: Any) -> tuple[Any, Any]:
|
|
kwargs = dict(kwargs)
|
|
kwargs["return_when"] = asyncio.ALL_COMPLETED
|
|
done_tasks, pending_tasks = await original_wait(*args, **kwargs)
|
|
ordered_done_tasks = sorted(
|
|
done_tasks,
|
|
key=lambda task: 0 if isinstance(task.exception(), UserError) else 1,
|
|
)
|
|
return ordered_done_tasks, pending_tasks
|
|
|
|
monkeypatch.setattr(asyncio, "wait", _wait_with_non_fatal_task_first)
|
|
|
|
agent = Agent(name="test", tools=[user_error_tool, fatal_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("user_error_tool", "{}", call_id="1"),
|
|
get_function_tool_call("fatal_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ToolAborted, match="fatal"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_prioritize_tool_error_over_same_batch_cancelled_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
async def _cancel_tool() -> str:
|
|
raise asyncio.CancelledError("tool-cancelled")
|
|
|
|
async def _error_tool() -> str:
|
|
raise ValueError("boom")
|
|
|
|
cancel_tool = function_tool(
|
|
_cancel_tool,
|
|
name_override="cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
original_wait = asyncio.wait
|
|
|
|
async def _wait_with_cancelled_task_first(*args: Any, **kwargs: Any) -> tuple[Any, Any]:
|
|
kwargs = dict(kwargs)
|
|
kwargs["return_when"] = asyncio.ALL_COMPLETED
|
|
done_tasks, pending_tasks = await original_wait(*args, **kwargs)
|
|
ordered_done_tasks = sorted(
|
|
done_tasks,
|
|
key=lambda task: 0 if task.cancelled() else 1,
|
|
)
|
|
return ordered_done_tasks, pending_tasks
|
|
|
|
monkeypatch.setattr(asyncio, "wait", _wait_with_cancelled_task_first)
|
|
|
|
agent = Agent(name="test", tools=[cancel_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("cancel_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_preserve_tool_call_order_for_same_batch_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
async def _error_tool_1() -> str:
|
|
raise ValueError("boom-1")
|
|
|
|
async def _error_tool_2() -> str:
|
|
raise ValueError("boom-2")
|
|
|
|
tool_1 = function_tool(
|
|
_error_tool_1,
|
|
name_override="error_tool_1",
|
|
failure_error_function=None,
|
|
)
|
|
tool_2 = function_tool(
|
|
_error_tool_2,
|
|
name_override="error_tool_2",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
original_wait = asyncio.wait
|
|
|
|
async def _wait_with_reversed_done_order(*args: Any, **kwargs: Any) -> tuple[Any, Any]:
|
|
kwargs = dict(kwargs)
|
|
kwargs["return_when"] = asyncio.ALL_COMPLETED
|
|
done_tasks, pending_tasks = await original_wait(*args, **kwargs)
|
|
return list(reversed(list(done_tasks))), pending_tasks
|
|
|
|
monkeypatch.setattr(asyncio, "wait", _wait_with_reversed_done_order)
|
|
|
|
agent = Agent(name="test", tools=[tool_1, tool_2])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("error_tool_1", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool_2", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool_1: boom-1"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_allow_successful_sibling_on_tool_end_to_finish():
|
|
cleanup_started = asyncio.Event()
|
|
cleanup_finished = asyncio.Event()
|
|
cleanup_release = asyncio.Event()
|
|
|
|
class RecordingHooks(RunHooks[Any]):
|
|
async def on_tool_end(
|
|
self,
|
|
context: RunContextWrapper[Any],
|
|
agent: Agent[Any],
|
|
tool,
|
|
result: object,
|
|
) -> None:
|
|
if tool.name != "ok_tool":
|
|
return
|
|
|
|
cleanup_started.set()
|
|
await cleanup_release.wait()
|
|
cleanup_finished.set()
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _error_tool() -> str:
|
|
await cleanup_started.wait()
|
|
raise ValueError("boom")
|
|
|
|
hooks = RecordingHooks()
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response, hooks=hooks))
|
|
await asyncio.wait_for(cleanup_started.wait(), timeout=0.2)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(execution_task, timeout=0.2)
|
|
|
|
assert not cleanup_finished.is_set()
|
|
cleanup_release.set()
|
|
await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_surface_post_invoke_failure_unblocked_during_settle_turns():
|
|
loop = asyncio.get_running_loop()
|
|
original_handler = loop.get_exception_handler()
|
|
unhandled_contexts: list[dict[str, Any]] = []
|
|
guardrail_started = asyncio.Event()
|
|
release_guardrail = asyncio.Event()
|
|
|
|
def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
unhandled_contexts.append(context)
|
|
|
|
@tool_output_guardrail
|
|
async def externally_released_tripwire_guardrail(
|
|
_data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
guardrail_started.set()
|
|
await release_guardrail.wait()
|
|
return ToolGuardrailFunctionOutput.raise_exception(output_info={"status": "late-tripwire"})
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _error_tool() -> str:
|
|
await guardrail_started.wait()
|
|
|
|
async def _release_guardrail_later() -> None:
|
|
await asyncio.sleep(0)
|
|
release_guardrail.set()
|
|
|
|
asyncio.create_task(_release_guardrail_later())
|
|
raise ValueError("boom")
|
|
|
|
ok_tool = function_tool(
|
|
_ok_tool,
|
|
name_override="ok_tool",
|
|
failure_error_function=None,
|
|
tool_output_guardrails=[externally_released_tripwire_guardrail],
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
loop.set_exception_handler(_exception_handler)
|
|
try:
|
|
with pytest.raises(ToolOutputGuardrailTripwireTriggered):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
gc.collect()
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
loop.set_exception_handler(original_handler)
|
|
|
|
assert not any(
|
|
context.get("message")
|
|
== "Background function tool post-invoke task raised after failure propagation."
|
|
for context in unhandled_contexts
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_surface_sleeping_post_invoke_failure_before_sibling_error():
|
|
loop = asyncio.get_running_loop()
|
|
original_handler = loop.get_exception_handler()
|
|
unhandled_contexts: list[dict[str, Any]] = []
|
|
|
|
@tool_output_guardrail
|
|
async def sleeping_tripwire_guardrail(
|
|
_data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
await asyncio.sleep(0.05)
|
|
return ToolGuardrailFunctionOutput.raise_exception(output_info={"status": "sleep-tripwire"})
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _error_tool() -> str:
|
|
raise ValueError("boom")
|
|
|
|
ok_tool = function_tool(
|
|
_ok_tool,
|
|
name_override="ok_tool",
|
|
failure_error_function=None,
|
|
tool_output_guardrails=[sleeping_tripwire_guardrail],
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
unhandled_contexts.append(context)
|
|
|
|
loop.set_exception_handler(_exception_handler)
|
|
try:
|
|
with pytest.raises(ToolOutputGuardrailTripwireTriggered):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
gc.collect()
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
loop.set_exception_handler(original_handler)
|
|
|
|
assert not any(
|
|
context.get("message")
|
|
== "Background function tool post-invoke task raised after failure propagation."
|
|
for context in unhandled_contexts
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_do_not_wait_indefinitely_for_sleeping_post_invoke_sibling():
|
|
guardrail_finished = asyncio.Event()
|
|
|
|
@tool_output_guardrail
|
|
async def long_sleeping_guardrail(
|
|
_data: ToolOutputGuardrailData,
|
|
) -> ToolGuardrailFunctionOutput:
|
|
await asyncio.sleep(0.3)
|
|
guardrail_finished.set()
|
|
return ToolGuardrailFunctionOutput.allow(output_info="done")
|
|
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
async def _error_tool() -> str:
|
|
raise ValueError("boom")
|
|
|
|
ok_tool = function_tool(
|
|
_ok_tool,
|
|
name_override="ok_tool",
|
|
failure_error_function=None,
|
|
tool_output_guardrails=[long_sleeping_guardrail],
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
|
|
await asyncio.wait_for(guardrail_finished.wait(), timeout=0.5)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_do_not_wait_for_cancelled_sibling_tool_before_raising():
|
|
started = asyncio.Event()
|
|
cancellation_started = asyncio.Event()
|
|
cancellation_finished = asyncio.Event()
|
|
allow_cancellation_exit = asyncio.Event()
|
|
|
|
async def _ok_tool() -> str:
|
|
started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError:
|
|
cancellation_started.set()
|
|
await allow_cancellation_exit.wait()
|
|
cancellation_finished.set()
|
|
raise
|
|
|
|
async def _error_tool() -> str:
|
|
await started.wait()
|
|
raise ValueError("boom")
|
|
|
|
ok_tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[ok_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("ok_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response))
|
|
await asyncio.wait_for(started.wait(), timeout=0.2)
|
|
await asyncio.wait_for(cancellation_started.wait(), timeout=0.2)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(execution_task, timeout=0.2)
|
|
|
|
assert not cancellation_finished.is_set()
|
|
|
|
allow_cancellation_exit.set()
|
|
await asyncio.wait_for(cancellation_finished.wait(), timeout=0.2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_bound_cancelled_sibling_self_rescheduling_cleanup():
|
|
sibling_ready = asyncio.Event()
|
|
cleanup_started = asyncio.Event()
|
|
cleanup_finished = asyncio.Event()
|
|
stop_cleanup = asyncio.Event()
|
|
|
|
async def _looping_cleanup_tool() -> str:
|
|
try:
|
|
sibling_ready.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError:
|
|
cleanup_started.set()
|
|
while not stop_cleanup.is_set():
|
|
await asyncio.sleep(0)
|
|
cleanup_finished.set()
|
|
raise
|
|
|
|
async def _error_tool() -> str:
|
|
await sibling_ready.wait()
|
|
raise ValueError("boom")
|
|
|
|
looping_cleanup_tool = function_tool(
|
|
_looping_cleanup_tool,
|
|
name_override="looping_cleanup_tool",
|
|
failure_error_function=None,
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[looping_cleanup_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("looping_cleanup_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
|
|
assert cleanup_started.is_set()
|
|
|
|
stop_cleanup.set()
|
|
await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_drain_completed_fatal_failures_before_raising():
|
|
class ToolAborted(BaseException):
|
|
pass
|
|
|
|
loop = asyncio.get_running_loop()
|
|
original_handler = loop.get_exception_handler()
|
|
unhandled_contexts: list[dict[str, Any]] = []
|
|
|
|
def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
unhandled_contexts.append(context)
|
|
|
|
async def _error_tool_1() -> str:
|
|
raise ToolAborted("boom-1")
|
|
|
|
async def _error_tool_2() -> str:
|
|
raise ToolAborted("boom-2")
|
|
|
|
tool_1 = function_tool(
|
|
_error_tool_1,
|
|
name_override="error_tool_1",
|
|
failure_error_function=None,
|
|
)
|
|
tool_2 = function_tool(
|
|
_error_tool_2,
|
|
name_override="error_tool_2",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[tool_1, tool_2])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("error_tool_1", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool_2", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
loop.set_exception_handler(_exception_handler)
|
|
try:
|
|
with pytest.raises(ToolAborted):
|
|
await get_execute_result(agent, response)
|
|
gc.collect()
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
loop.set_exception_handler(original_handler)
|
|
|
|
assert not any(
|
|
context.get("message") == "Task exception was never retrieved"
|
|
for context in unhandled_contexts
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("delay_ticks", [1, 6, 20])
|
|
async def test_multiple_tool_calls_raise_late_fatal_sibling_exception_after_cancellation(
|
|
delay_ticks: int,
|
|
):
|
|
class ToolAborted(BaseException):
|
|
pass
|
|
|
|
sibling_ready = asyncio.Event()
|
|
sibling_cancelled = asyncio.Event()
|
|
|
|
async def _error_tool_1() -> str:
|
|
await sibling_ready.wait()
|
|
raise ValueError("boom-1")
|
|
|
|
async def _error_tool_2() -> str:
|
|
try:
|
|
sibling_ready.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError as cancel_exc:
|
|
sibling_cancelled.set()
|
|
for _ in range(delay_ticks):
|
|
await asyncio.sleep(0)
|
|
raise ToolAborted(f"boom-{delay_ticks}") from cancel_exc
|
|
|
|
tool_1 = function_tool(
|
|
_error_tool_1,
|
|
name_override="error_tool_1",
|
|
failure_error_function=None,
|
|
)
|
|
tool_2 = function_tool(
|
|
_error_tool_2,
|
|
name_override="error_tool_2",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[tool_1, tool_2])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("error_tool_1", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool_2", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ToolAborted, match=f"boom-{delay_ticks}"):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
|
|
assert sibling_cancelled.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_preserve_triggering_error_over_cancelled_sibling_cleanup_error():
|
|
sibling_ready = asyncio.Event()
|
|
sibling_cancelled = asyncio.Event()
|
|
|
|
async def _cleanup_tool() -> str:
|
|
try:
|
|
sibling_ready.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError as cancel_exc:
|
|
sibling_cancelled.set()
|
|
raise ValueError("cleanup") from cancel_exc
|
|
|
|
async def _error_tool() -> str:
|
|
await sibling_ready.wait()
|
|
raise ValueError("boom")
|
|
|
|
cleanup_tool = function_tool(
|
|
_cleanup_tool,
|
|
name_override="cleanup_tool",
|
|
failure_error_function=None,
|
|
)
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[cleanup_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("cleanup_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
|
|
assert sibling_cancelled.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_report_late_cleanup_exception_from_cancelled_sibling():
|
|
loop = asyncio.get_running_loop()
|
|
original_handler = loop.get_exception_handler()
|
|
reported_contexts: list[dict[str, Any]] = []
|
|
late_cleanup_reported = asyncio.Event()
|
|
sibling_ready = asyncio.Event()
|
|
cleanup_blocked = asyncio.Event()
|
|
cleanup_finished = asyncio.Event()
|
|
release_cleanup = asyncio.Event()
|
|
|
|
def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
reported_contexts.append(context)
|
|
if context.get("message") == (
|
|
"Background function tool task raised during cancellation cleanup after failure "
|
|
"propagation."
|
|
) and isinstance(context.get("exception"), UserError):
|
|
late_cleanup_reported.set()
|
|
|
|
async def _error_tool() -> str:
|
|
await sibling_ready.wait()
|
|
raise ValueError("boom")
|
|
|
|
async def _cleanup_tool() -> str:
|
|
try:
|
|
sibling_ready.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError as cancel_exc:
|
|
cleanup_blocked.set()
|
|
try:
|
|
await release_cleanup.wait()
|
|
finally:
|
|
cleanup_finished.set()
|
|
raise RuntimeError("late-cleanup-boom") from cancel_exc
|
|
|
|
error_tool = function_tool(
|
|
_error_tool,
|
|
name_override="error_tool",
|
|
failure_error_function=None,
|
|
)
|
|
cleanup_tool = function_tool(
|
|
_cleanup_tool,
|
|
name_override="cleanup_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[cleanup_tool, error_tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("cleanup_tool", "{}", call_id="1"),
|
|
get_function_tool_call("error_tool", "{}", call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
loop.set_exception_handler(_exception_handler)
|
|
try:
|
|
with pytest.raises(UserError, match="Error running tool error_tool: boom"):
|
|
await asyncio.wait_for(get_execute_result(agent, response), timeout=0.2)
|
|
|
|
assert cleanup_blocked.is_set()
|
|
release_cleanup.set()
|
|
await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2)
|
|
await asyncio.wait_for(late_cleanup_reported.wait(), timeout=0.5)
|
|
finally:
|
|
loop.set_exception_handler(original_handler)
|
|
|
|
matching_contexts = [
|
|
context
|
|
for context in reported_contexts
|
|
if context.get("message")
|
|
== "Background function tool task raised during cancellation cleanup after failure "
|
|
"propagation."
|
|
]
|
|
assert any(
|
|
isinstance(context.get("exception"), UserError)
|
|
and str(context["exception"]) == "Error running tool cleanup_tool: late-cleanup-boom"
|
|
for context in matching_contexts
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_tool_calls_cancel_pending_tasks_when_parent_cancelled():
|
|
tool_1_started = asyncio.Event()
|
|
tool_2_started = asyncio.Event()
|
|
cancelled_tools: list[str] = []
|
|
|
|
async def _waiting_tool(name: str) -> str:
|
|
try:
|
|
if name == "tool_1":
|
|
tool_1_started.set()
|
|
else:
|
|
tool_2_started.set()
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError:
|
|
cancelled_tools.append(name)
|
|
raise
|
|
|
|
tool_1 = function_tool(
|
|
_waiting_tool,
|
|
name_override="tool_1",
|
|
failure_error_function=None,
|
|
)
|
|
tool_2 = function_tool(
|
|
_waiting_tool,
|
|
name_override="tool_2",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[tool_1, tool_2])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("tool_1", json.dumps({"name": "tool_1"}), call_id="1"),
|
|
get_function_tool_call("tool_2", json.dumps({"name": "tool_2"}), call_id="2"),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response))
|
|
await asyncio.wait_for(tool_1_started.wait(), timeout=0.2)
|
|
await asyncio.wait_for(tool_2_started.wait(), timeout=0.2)
|
|
|
|
execution_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await execution_task
|
|
|
|
assert sorted(cancelled_tools) == ["tool_1", "tool_2"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_cancellation_does_not_wait_for_tool_cleanup():
|
|
tool_started = asyncio.Event()
|
|
cleanup_started = asyncio.Event()
|
|
cleanup_finished = asyncio.Event()
|
|
allow_cleanup_exit = asyncio.Event()
|
|
|
|
async def _slow_cancel_tool() -> str:
|
|
tool_started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
return "unreachable"
|
|
except asyncio.CancelledError:
|
|
cleanup_started.set()
|
|
await allow_cleanup_exit.wait()
|
|
cleanup_finished.set()
|
|
raise
|
|
|
|
tool = function_tool(
|
|
_slow_cancel_tool,
|
|
name_override="slow_cancel_tool",
|
|
failure_error_function=None,
|
|
)
|
|
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("slow_cancel_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response))
|
|
await asyncio.wait_for(tool_started.wait(), timeout=0.2)
|
|
|
|
execution_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await asyncio.wait_for(execution_task, timeout=0.1)
|
|
|
|
await asyncio.wait_for(cleanup_started.wait(), timeout=0.2)
|
|
allow_cleanup_exit.set()
|
|
await asyncio.wait_for(cleanup_finished.wait(), timeout=0.2)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_cancellation_wins_when_shield_raises_after_tool_finishes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
async def _ok_tool() -> str:
|
|
return "ok"
|
|
|
|
tool = function_tool(_ok_tool, name_override="ok_tool", failure_error_function=None)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("ok_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
original_shield = asyncio.shield
|
|
|
|
async def _shield_then_cancel(task: asyncio.Task[Any]) -> Any:
|
|
result = await original_shield(task)
|
|
raise asyncio.CancelledError()
|
|
return result
|
|
|
|
monkeypatch.setattr(asyncio, "shield", _shield_then_cancel)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_cancellation_does_not_report_tool_failure_as_background_error():
|
|
loop = asyncio.get_running_loop()
|
|
original_handler = loop.get_exception_handler()
|
|
reported_contexts: list[dict[str, Any]] = []
|
|
tool_started = asyncio.Event()
|
|
|
|
def _exception_handler(_loop: asyncio.AbstractEventLoop, context: dict[str, Any]) -> None:
|
|
reported_contexts.append(context)
|
|
|
|
async def _failing_tool() -> str:
|
|
tool_started.set()
|
|
await asyncio.sleep(0)
|
|
raise ValueError("boom")
|
|
|
|
tool = function_tool(
|
|
_failing_tool,
|
|
name_override="failing_tool",
|
|
failure_error_function=None,
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("failing_tool", "{}", call_id="1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
loop.set_exception_handler(_exception_handler)
|
|
try:
|
|
execution_task = asyncio.create_task(get_execute_result(agent, response))
|
|
await asyncio.wait_for(tool_started.wait(), timeout=0.2)
|
|
|
|
execution_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await execution_task
|
|
|
|
await asyncio.sleep(0)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
loop.set_exception_handler(original_handler)
|
|
|
|
assert not any(
|
|
context.get("message")
|
|
== "Background function tool task raised during cancellation cleanup after failure "
|
|
"propagation."
|
|
and isinstance(context.get("exception"), UserError)
|
|
and str(context["exception"]) == "Error running tool failing_tool: boom"
|
|
for context in reported_contexts
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_context_includes_run_config() -> None:
|
|
async def _tool_with_run_config(context: ToolContext[str]) -> str:
|
|
assert context.run_config is not None
|
|
return str(context.run_config.model)
|
|
|
|
tool = function_tool(
|
|
_tool_with_run_config,
|
|
name_override="tool_with_run_config",
|
|
failure_error_function=None,
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("tool_with_run_config", "{}", call_id="call-1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
run_config = RunConfig(model="gpt-4.1-mini")
|
|
|
|
result = await get_execute_result(agent, response, run_config=run_config)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert_item_is_function_tool_call_output(result.generated_items[1], "gpt-4.1-mini")
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deferred_function_tool_context_preserves_search_loaded_namespace() -> None:
|
|
async def _tool_with_namespace(context: ToolContext[str]) -> str:
|
|
tool_call_namespace = getattr(context.tool_call, "namespace", None)
|
|
return json.dumps(
|
|
{
|
|
"tool_call_namespace": tool_call_namespace,
|
|
"tool_namespace": context.tool_namespace,
|
|
},
|
|
sort_keys=True,
|
|
)
|
|
|
|
tool = function_tool(
|
|
_tool_with_namespace,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
failure_error_function=None,
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call(
|
|
"get_weather",
|
|
"{}",
|
|
call_id="call-1",
|
|
namespace="get_weather",
|
|
)
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert len(result.generated_items) == 2
|
|
assert_item_is_function_tool_call_output(
|
|
result.generated_items[1],
|
|
'{"tool_call_namespace": "get_weather", "tool_namespace": "get_weather"}',
|
|
)
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handoff_output_leads_to_handoff_next_step():
|
|
agent_1 = Agent(name="test_1")
|
|
agent_2 = Agent(name="test_2")
|
|
agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2])
|
|
response = ModelResponse(
|
|
output=[get_text_message("Hello, world!"), get_handoff_tool_call(agent_1)],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent_3, response)
|
|
|
|
assert isinstance(result.next_step, NextStepHandoff)
|
|
assert result.next_step.new_agent == agent_1
|
|
|
|
assert len(result.generated_items) == 3
|
|
|
|
|
|
class Foo(BaseModel):
|
|
bar: str
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_output_without_tool_runs_again():
|
|
agent = Agent(name="test", output_type=Foo, tools=[get_function_tool("tool_1", "result")])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("tool_1")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert isinstance(result.next_step, NextStepRunAgain)
|
|
assert len(result.generated_items) == 2, "expected 2 items: tool call, tool call output"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_final_output_leads_to_final_output_next_step():
|
|
agent = Agent(name="test", output_type=Foo)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_text_message("Hello, world!"),
|
|
get_final_output_message(Foo(bar="123").model_dump_json()),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
assert result.next_step.output == Foo(bar="123")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handoff_and_final_output_leads_to_handoff_next_step():
|
|
agent_1 = Agent(name="test_1")
|
|
agent_2 = Agent(name="test_2")
|
|
agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2], output_type=Foo)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_final_output_message(Foo(bar="123").model_dump_json()),
|
|
get_handoff_tool_call(agent_1),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent_3, response)
|
|
|
|
assert isinstance(result.next_step, NextStepHandoff)
|
|
assert result.next_step.new_agent == agent_1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multiple_final_output_leads_to_final_output_next_step():
|
|
agent_1 = Agent(name="test_1")
|
|
agent_2 = Agent(name="test_2")
|
|
agent_3 = Agent(name="test_3", handoffs=[agent_1, agent_2], output_type=Foo)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_final_output_message(Foo(bar="123").model_dump_json()),
|
|
get_final_output_message(Foo(bar="456").model_dump_json()),
|
|
],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
result = await get_execute_result(agent_3, response)
|
|
|
|
assert isinstance(result.next_step, NextStepFinalOutput)
|
|
assert result.next_step.output == Foo(bar="456")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_input_guardrail_runs_on_invalid_json(monkeypatch: pytest.MonkeyPatch):
|
|
# Opt in to payload logging so the JSON decode error chain is preserved and the
|
|
# default failure formatter can recover the friendly "parsing tool arguments" message.
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
guardrail_calls: list[str] = []
|
|
|
|
def guardrail(data) -> ToolGuardrailFunctionOutput:
|
|
guardrail_calls.append(data.context.tool_arguments)
|
|
return ToolGuardrailFunctionOutput.allow(output_info="checked")
|
|
|
|
guardrail_obj: ToolInputGuardrail[Any] = ToolInputGuardrail(guardrail_function=guardrail)
|
|
|
|
def _echo(value: str) -> str:
|
|
return value
|
|
|
|
tool = function_tool(
|
|
_echo,
|
|
name_override="guarded",
|
|
tool_input_guardrails=[guardrail_obj],
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("guarded", "bad_json")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
result = await get_execute_result(agent, response)
|
|
|
|
assert guardrail_calls == ["bad_json"]
|
|
assert result.tool_input_guardrail_results
|
|
assert result.tool_input_guardrail_results[0].output.output_info == "checked"
|
|
|
|
output_item = next(
|
|
item for item in result.generated_items if isinstance(item, ToolCallOutputItem)
|
|
)
|
|
assert "An error occurred while parsing tool arguments" in str(output_item.output)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_json_raises_with_failure_error_function_none():
|
|
def _echo(value: str) -> str:
|
|
return value
|
|
|
|
tool = function_tool(
|
|
_echo,
|
|
name_override="guarded",
|
|
failure_error_function=None,
|
|
)
|
|
agent = Agent(name="test", tools=[tool])
|
|
response = ModelResponse(
|
|
output=[get_function_tool_call("guarded", "bad_json")],
|
|
usage=Usage(),
|
|
response_id=None,
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="Invalid JSON input for tool"):
|
|
await get_execute_result(agent, response)
|
|
|
|
|
|
# === Helpers ===
|
|
|
|
|
|
def assert_item_is_message(item: RunItem, text: str) -> None:
|
|
assert isinstance(item, MessageOutputItem)
|
|
assert item.raw_item.type == "message"
|
|
assert item.raw_item.role == "assistant"
|
|
assert item.raw_item.content[0].type == "output_text"
|
|
assert item.raw_item.content[0].text == text
|
|
|
|
|
|
def assert_item_is_function_tool_call(
|
|
item: RunItem, name: str, arguments: str | None = None
|
|
) -> None:
|
|
assert isinstance(item, ToolCallItem)
|
|
raw_item = getattr(item, "raw_item", None)
|
|
assert getattr(raw_item, "type", None) == "function_call"
|
|
assert getattr(raw_item, "name", None) == name
|
|
if arguments:
|
|
assert getattr(raw_item, "arguments", None) == arguments
|
|
|
|
|
|
def assert_item_is_function_tool_call_output(item: RunItem, output: str) -> None:
|
|
assert isinstance(item, ToolCallOutputItem)
|
|
raw_item = cast(dict[str, Any], item.raw_item)
|
|
assert raw_item["type"] == "function_call_output"
|
|
assert raw_item["output"] == output
|
|
|
|
|
|
def make_processed_response(
|
|
*,
|
|
new_items: list[RunItem] | None = None,
|
|
handoffs: list[ToolRunHandoff] | None = None,
|
|
functions: list[ToolRunFunction] | None = None,
|
|
computer_actions: list[ToolRunComputerAction] | None = None,
|
|
local_shell_calls: list[ToolRunLocalShellCall] | None = None,
|
|
shell_calls: list[ToolRunShellCall] | None = None,
|
|
apply_patch_calls: list[ToolRunApplyPatchCall] | None = None,
|
|
mcp_approval_requests: list[ToolRunMCPApprovalRequest] | None = None,
|
|
tools_used: list[str] | None = None,
|
|
interruptions: list[ToolApprovalItem] | None = None,
|
|
) -> ProcessedResponse:
|
|
"""Build a ProcessedResponse with empty collections by default."""
|
|
|
|
return ProcessedResponse(
|
|
new_items=new_items or [],
|
|
handoffs=handoffs or [],
|
|
functions=functions or [],
|
|
computer_actions=computer_actions or [],
|
|
local_shell_calls=local_shell_calls or [],
|
|
shell_calls=shell_calls or [],
|
|
apply_patch_calls=apply_patch_calls or [],
|
|
mcp_approval_requests=mcp_approval_requests or [],
|
|
tools_used=tools_used or [],
|
|
interruptions=interruptions or [],
|
|
)
|
|
|
|
|
|
def test_processed_response_reports_interruptions() -> None:
|
|
processed_response = make_processed_response(
|
|
interruptions=[cast(ToolApprovalItem, object())],
|
|
)
|
|
|
|
assert processed_response.has_interruptions() is True
|
|
|
|
|
|
async def get_execute_result(
|
|
agent: Agent[Any],
|
|
response: ModelResponse,
|
|
*,
|
|
original_input: str | list[TResponseInputItem] | None = None,
|
|
generated_items: list[RunItem] | None = None,
|
|
hooks: RunHooks[Any] | None = None,
|
|
context_wrapper: RunContextWrapper[Any] | None = None,
|
|
run_config: RunConfig | None = None,
|
|
) -> SingleStepResult:
|
|
output_schema = get_output_schema(agent)
|
|
handoffs = await get_handoffs(agent, context_wrapper or RunContextWrapper(None))
|
|
|
|
processed_response = run_loop.process_model_response(
|
|
agent=agent,
|
|
all_tools=await agent.get_all_tools(context_wrapper or RunContextWrapper(None)),
|
|
response=response,
|
|
output_schema=output_schema,
|
|
handoffs=handoffs,
|
|
)
|
|
return await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(agent),
|
|
original_input=original_input or "hello",
|
|
new_response=response,
|
|
pre_step_items=generated_items or [],
|
|
processed_response=processed_response,
|
|
output_schema=output_schema,
|
|
hooks=hooks or RunHooks(),
|
|
context_wrapper=context_wrapper or RunContextWrapper(None),
|
|
run_config=run_config or RunConfig(),
|
|
)
|
|
|
|
|
|
async def run_execute_with_processed_response(
|
|
agent: Agent[Any], processed_response: ProcessedResponse
|
|
) -> SingleStepResult:
|
|
"""Execute tools for a pre-constructed ProcessedResponse."""
|
|
|
|
return await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(agent),
|
|
original_input="test",
|
|
pre_step_items=[],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
output_schema=None,
|
|
hooks=RunHooks(),
|
|
context_wrapper=make_context_wrapper(),
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class ToolApprovalRun:
|
|
agent: Agent[Any]
|
|
processed_response: ProcessedResponse
|
|
expected_tool_name: str
|
|
|
|
|
|
def _function_tool_approval_run() -> ToolApprovalRun:
|
|
async def _test_tool() -> str:
|
|
return "tool_result"
|
|
|
|
tool = function_tool(_test_tool, name_override="test_tool", needs_approval=True)
|
|
agent = make_agent(tools=[tool])
|
|
tool_call = make_function_tool_call("test_tool", arguments="{}")
|
|
tool_run = ToolRunFunction(function_tool=tool, tool_call=tool_call)
|
|
processed_response = make_processed_response(functions=[tool_run])
|
|
return ToolApprovalRun(
|
|
agent=agent,
|
|
processed_response=processed_response,
|
|
expected_tool_name="test_tool",
|
|
)
|
|
|
|
|
|
def _shell_tool_approval_run() -> ToolApprovalRun:
|
|
shell_tool = ShellTool(executor=lambda request: "output", needs_approval=True)
|
|
agent = make_agent(tools=[shell_tool])
|
|
tool_call = make_shell_call(
|
|
"call_shell", id_value="shell_call", commands=["echo hi"], status="completed"
|
|
)
|
|
tool_run = ToolRunShellCall(tool_call=tool_call, shell_tool=shell_tool)
|
|
processed_response = make_processed_response(shell_calls=[tool_run])
|
|
return ToolApprovalRun(
|
|
agent=agent,
|
|
processed_response=processed_response,
|
|
expected_tool_name="shell",
|
|
)
|
|
|
|
|
|
def _apply_patch_tool_approval_run() -> ToolApprovalRun:
|
|
editor = RecordingEditor()
|
|
apply_patch_tool = ApplyPatchTool(editor=editor, needs_approval=True)
|
|
agent = make_agent(tools=[apply_patch_tool])
|
|
tool_call = make_apply_patch_dict("call_apply")
|
|
tool_run = ToolRunApplyPatchCall(tool_call=tool_call, apply_patch_tool=apply_patch_tool)
|
|
processed_response = make_processed_response(apply_patch_calls=[tool_run])
|
|
return ToolApprovalRun(
|
|
agent=agent,
|
|
processed_response=processed_response,
|
|
expected_tool_name="apply_patch",
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"setup_fn",
|
|
[
|
|
_function_tool_approval_run,
|
|
_shell_tool_approval_run,
|
|
_apply_patch_tool_approval_run,
|
|
],
|
|
ids=["function_tool", "shell_tool", "apply_patch_tool"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_handles_tool_approval_items(
|
|
setup_fn: Callable[[], ToolApprovalRun],
|
|
) -> None:
|
|
"""Tool approvals should surface as interruptions across tool types."""
|
|
scenario = setup_fn()
|
|
result = await run_execute_with_processed_response(scenario.agent, scenario.processed_response)
|
|
|
|
assert_single_approval_interruption(result, tool_name=scenario.expected_tool_name)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_preserves_synthetic_namespace_for_deferred_top_level_approval() -> (
|
|
None
|
|
):
|
|
async def _deferred_weather() -> str:
|
|
return "tool_result"
|
|
|
|
tool = function_tool(
|
|
_deferred_weather,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
needs_approval=True,
|
|
)
|
|
agent = make_agent(tools=[tool])
|
|
tool_call = cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("get_weather", "{}", namespace="get_weather"),
|
|
)
|
|
tool_run = ToolRunFunction(function_tool=tool, tool_call=tool_call)
|
|
processed_response = make_processed_response(functions=[tool_run])
|
|
|
|
result = await run_execute_with_processed_response(agent, processed_response)
|
|
interruption = assert_single_approval_interruption(result, tool_name="get_weather")
|
|
|
|
assert interruption.tool_namespace == "get_weather"
|
|
assert getattr(interruption.raw_item, "namespace", None) == "get_weather"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deferred_tool_approval_allows_bare_alias_when_visible_peer_is_disabled() -> None:
|
|
async def _visible_weather() -> str:
|
|
return "visible"
|
|
|
|
async def _deferred_weather() -> str:
|
|
return "deferred"
|
|
|
|
visible_tool = function_tool(
|
|
_visible_weather,
|
|
name_override="get_weather",
|
|
needs_approval=True,
|
|
is_enabled=False,
|
|
)
|
|
deferred_tool = function_tool(
|
|
_deferred_weather,
|
|
name_override="get_weather",
|
|
defer_loading=True,
|
|
needs_approval=True,
|
|
)
|
|
agent = make_agent(tools=[visible_tool, deferred_tool])
|
|
tool_call = cast(
|
|
ResponseFunctionToolCall,
|
|
get_function_tool_call("get_weather", "{}", namespace="get_weather"),
|
|
)
|
|
tool_run = ToolRunFunction(function_tool=deferred_tool, tool_call=tool_call)
|
|
processed_response = make_processed_response(functions=[tool_run])
|
|
|
|
result = await run_execute_with_processed_response(agent, processed_response)
|
|
interruption = assert_single_approval_interruption(result, tool_name="get_weather")
|
|
|
|
assert interruption.tool_namespace == "get_weather"
|
|
assert interruption._allow_bare_name_alias is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_runs_hosted_mcp_callback_when_present():
|
|
"""Hosted MCP approvals should invoke on_approval_request callbacks."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=lambda request: {"approve": True},
|
|
)
|
|
agent = make_agent(tools=[mcp_tool])
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-1",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await run_execute_with_processed_response(agent, processed_response)
|
|
|
|
assert not isinstance(result.next_step, NextStepInterruption)
|
|
assert any(isinstance(item, MCPApprovalResponseItem) for item in result.new_step_items)
|
|
assert not result.processed_response or not result.processed_response.interruptions
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_uses_public_agent_for_hosted_mcp_callback_results():
|
|
"""Hosted MCP callback responses should expose the public agent when execution uses a clone."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=lambda request: {"approve": True},
|
|
)
|
|
public_agent = make_agent(tools=[mcp_tool])
|
|
execution_agent = public_agent.clone()
|
|
set_public_agent(execution_agent, public_agent)
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-callback-public-agent",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(execution_agent),
|
|
original_input="test",
|
|
pre_step_items=[],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
output_schema=None,
|
|
hooks=RunHooks(),
|
|
context_wrapper=make_context_wrapper(),
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
assert not isinstance(result.next_step, NextStepInterruption)
|
|
assert any(
|
|
isinstance(item, MCPApprovalResponseItem) and item.agent is public_agent
|
|
for item in result.new_step_items
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_surfaces_hosted_mcp_interruptions_without_callback():
|
|
"""Hosted MCP approvals should surface as interruptions when no callback is provided."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=None,
|
|
)
|
|
agent = make_agent(tools=[mcp_tool])
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-2",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await run_execute_with_processed_response(agent, processed_response)
|
|
|
|
assert isinstance(result.next_step, NextStepInterruption)
|
|
assert result.next_step.interruptions
|
|
assert any(isinstance(item, ToolApprovalItem) for item in result.next_step.interruptions)
|
|
assert any(
|
|
isinstance(item, ToolApprovalItem)
|
|
and getattr(item.raw_item, "id", None) == "mcp-approval-2"
|
|
for item in result.new_step_items
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_uses_public_agent_for_hosted_mcp_interruptions():
|
|
"""Hosted MCP approval items should expose the public agent when execution uses a clone."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=None,
|
|
)
|
|
public_agent = make_agent(tools=[mcp_tool])
|
|
execution_agent = public_agent.clone()
|
|
set_public_agent(execution_agent, public_agent)
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-public-agent",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(execution_agent),
|
|
original_input="test",
|
|
pre_step_items=[],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
output_schema=None,
|
|
hooks=RunHooks(),
|
|
context_wrapper=make_context_wrapper(),
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
assert isinstance(result.next_step, NextStepInterruption)
|
|
assert result.next_step.interruptions
|
|
assert all(item.agent is public_agent for item in result.next_step.interruptions)
|
|
assert any(
|
|
isinstance(item, ToolApprovalItem)
|
|
and getattr(item.raw_item, "id", None) == "mcp-approval-public-agent"
|
|
and item.agent is public_agent
|
|
for item in result.new_step_items
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_interrupted_turn_uses_public_agent_for_resumed_hosted_mcp_approvals():
|
|
"""Resumed hosted MCP approvals should keep the public agent on approval responses."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=None,
|
|
)
|
|
public_agent = make_agent(tools=[mcp_tool])
|
|
execution_agent = public_agent.clone()
|
|
set_public_agent(execution_agent, public_agent)
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-resume-public-agent",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
approval_item = ToolApprovalItem(
|
|
agent=public_agent,
|
|
raw_item=request_item,
|
|
tool_name="list_repo_languages",
|
|
)
|
|
context_wrapper = make_context_wrapper()
|
|
context_wrapper.approve_tool(approval_item)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=execution_agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
|
|
result = await turn_resolution.resolve_interrupted_turn(
|
|
bindings=_bind_agent(execution_agent),
|
|
original_input="test",
|
|
original_pre_step_items=[approval_item],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
hooks=RunHooks(),
|
|
context_wrapper=context_wrapper,
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
responses = [
|
|
item
|
|
for item in result.new_step_items
|
|
if isinstance(item, MCPApprovalResponseItem)
|
|
and item.raw_item.get("approval_request_id") == "mcp-approval-resume-public-agent"
|
|
]
|
|
assert responses
|
|
assert all(item.agent is public_agent for item in responses)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_handoffs_uses_public_agent_for_ignored_extra_handoffs():
|
|
"""Ignored extra handoff outputs should stay owned by the public agent."""
|
|
|
|
first_target = Agent(name="alpha")
|
|
second_target = Agent(name="beta")
|
|
public_agent = Agent(name="triage", handoffs=[first_target, second_target])
|
|
execution_agent = public_agent.clone()
|
|
set_public_agent(execution_agent, public_agent)
|
|
response = ModelResponse(
|
|
output=[get_handoff_tool_call(first_target), get_handoff_tool_call(second_target)],
|
|
usage=Usage(),
|
|
response_id="resp",
|
|
)
|
|
|
|
result = await get_execute_result(execution_agent, response)
|
|
|
|
ignored_outputs = [
|
|
item
|
|
for item in result.new_step_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
and item.output == "Multiple handoffs detected, ignoring this one."
|
|
]
|
|
assert len(ignored_outputs) == 1
|
|
assert ignored_outputs[0].agent is public_agent
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_handoffs_preserves_tool_input_guardrail_results():
|
|
"""Tool input guardrail results from concurrent function calls must survive a handoff."""
|
|
|
|
def guardrail(data) -> ToolGuardrailFunctionOutput:
|
|
return ToolGuardrailFunctionOutput.allow(output_info="checked")
|
|
|
|
guardrail_obj: ToolInputGuardrail[Any] = ToolInputGuardrail(guardrail_function=guardrail)
|
|
|
|
def _echo(value: str) -> str:
|
|
return value
|
|
|
|
guarded_tool = function_tool(
|
|
_echo,
|
|
name_override="guarded",
|
|
tool_input_guardrails=[guardrail_obj],
|
|
)
|
|
target = Agent(name="target")
|
|
public_agent = Agent(name="triage", tools=[guarded_tool], handoffs=[target])
|
|
execution_agent = public_agent.clone()
|
|
set_public_agent(execution_agent, public_agent)
|
|
response = ModelResponse(
|
|
output=[
|
|
get_function_tool_call("guarded", json.dumps({"value": "hi"}), call_id="c1"),
|
|
get_handoff_tool_call(target),
|
|
],
|
|
usage=Usage(),
|
|
response_id="resp",
|
|
)
|
|
|
|
result = await get_execute_result(execution_agent, response)
|
|
|
|
assert isinstance(result.next_step, NextStepHandoff)
|
|
assert result.tool_input_guardrail_results, (
|
|
"Tool input guardrail results should not be dropped when a handoff fires alongside "
|
|
"a function tool call."
|
|
)
|
|
assert result.tool_input_guardrail_results[0].output.output_info == "checked"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_emits_hosted_mcp_rejection_response():
|
|
"""Hosted MCP rejections without callbacks should emit approval responses."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=None,
|
|
)
|
|
agent = make_agent(tools=[mcp_tool])
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-reject",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
context_wrapper = make_context_wrapper()
|
|
reject_tool_call(context_wrapper, agent, request_item, tool_name="list_repo_languages")
|
|
|
|
result = await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(agent),
|
|
original_input="test",
|
|
pre_step_items=[],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
output_schema=None,
|
|
hooks=RunHooks(),
|
|
context_wrapper=context_wrapper,
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
responses = [
|
|
item for item in result.new_step_items if isinstance(item, MCPApprovalResponseItem)
|
|
]
|
|
assert responses, "Rejection should emit an MCP approval response."
|
|
assert responses[0].raw_item["approve"] is False
|
|
assert responses[0].raw_item["approval_request_id"] == "mcp-approval-reject"
|
|
assert "reason" not in responses[0].raw_item
|
|
assert not isinstance(result.next_step, NextStepInterruption)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_tools_emits_hosted_mcp_rejection_reason_from_explicit_message():
|
|
"""Hosted MCP rejections should forward explicit rejection messages as reasons."""
|
|
|
|
mcp_tool = HostedMCPTool(
|
|
tool_config={
|
|
"type": "mcp",
|
|
"server_label": "test_mcp_server",
|
|
"server_url": "https://example.com",
|
|
"require_approval": "always",
|
|
},
|
|
on_approval_request=None,
|
|
)
|
|
agent = make_agent(tools=[mcp_tool])
|
|
request_item = McpApprovalRequest(
|
|
id="mcp-approval-reject-reason",
|
|
type="mcp_approval_request",
|
|
server_label="test_mcp_server",
|
|
arguments="{}",
|
|
name="list_repo_languages",
|
|
)
|
|
processed_response = make_processed_response(
|
|
new_items=[MCPApprovalRequestItem(raw_item=request_item, agent=agent)],
|
|
mcp_approval_requests=[
|
|
ToolRunMCPApprovalRequest(
|
|
request_item=request_item,
|
|
mcp_tool=mcp_tool,
|
|
)
|
|
],
|
|
)
|
|
context_wrapper = make_context_wrapper()
|
|
reject_tool_call(
|
|
context_wrapper,
|
|
agent,
|
|
request_item,
|
|
tool_name="list_repo_languages",
|
|
rejection_message="Denied by policy",
|
|
)
|
|
|
|
result = await run_loop.execute_tools_and_side_effects(
|
|
bindings=_bind_agent(agent),
|
|
original_input="test",
|
|
pre_step_items=[],
|
|
new_response=ModelResponse(output=[], usage=Usage(), response_id="resp"),
|
|
processed_response=processed_response,
|
|
output_schema=None,
|
|
hooks=RunHooks(),
|
|
context_wrapper=context_wrapper,
|
|
run_config=RunConfig(),
|
|
)
|
|
|
|
responses = [
|
|
item for item in result.new_step_items if isinstance(item, MCPApprovalResponseItem)
|
|
]
|
|
assert responses, "Rejection should emit an MCP approval response."
|
|
assert responses[0].raw_item["approve"] is False
|
|
assert responses[0].raw_item["approval_request_id"] == "mcp-approval-reject-reason"
|
|
assert responses[0].raw_item["reason"] == "Denied by policy"
|