2772 lines
91 KiB
Python
2772 lines
91 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from collections.abc import Awaitable, Coroutine
|
|
from dataclasses import dataclass
|
|
from typing import Annotated, Any, Literal, cast
|
|
|
|
import pytest
|
|
from openai.types.responses import (
|
|
ResponseApplyPatchToolCall,
|
|
ResponseCustomToolCall,
|
|
ResponseFileSearchToolCall,
|
|
ResponseFunctionShellToolCall,
|
|
ResponseFunctionShellToolCallOutput,
|
|
ResponseFunctionToolCall,
|
|
ResponseFunctionWebSearch,
|
|
ResponseToolSearchCall,
|
|
ResponseToolSearchOutputItem,
|
|
)
|
|
from openai.types.responses.response_apply_patch_tool_call import OperationCreateFile
|
|
from openai.types.responses.response_code_interpreter_tool_call import (
|
|
ResponseCodeInterpreterToolCall,
|
|
)
|
|
from openai.types.responses.response_function_shell_tool_call import Action
|
|
from openai.types.responses.response_function_tool_call import CallerProgram
|
|
from openai.types.responses.response_function_web_search import ActionSearch
|
|
from openai.types.responses.response_output_item import (
|
|
ImageGenerationCall,
|
|
McpApprovalRequest,
|
|
McpCall,
|
|
McpListTools,
|
|
Program,
|
|
ProgramOutput,
|
|
)
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
from typing_extensions import TypedDict
|
|
|
|
from agents import (
|
|
Agent,
|
|
ApplyPatchTool,
|
|
CodeInterpreterTool,
|
|
CustomTool,
|
|
HostedMCPTool,
|
|
ModelResponse,
|
|
ModelSettings,
|
|
ProgrammaticToolCallingTool,
|
|
RunConfig,
|
|
RunItem,
|
|
Runner,
|
|
RunState,
|
|
ShellTool,
|
|
ToolCallItem,
|
|
ToolCallOutputItem,
|
|
ToolExecutionConfig,
|
|
ToolGuardrailFunctionOutput,
|
|
ToolInputGuardrailData,
|
|
ToolOutputGuardrailData,
|
|
ToolSearchTool,
|
|
Usage,
|
|
UserError,
|
|
function_tool,
|
|
tool_input_guardrail,
|
|
tool_output_guardrail,
|
|
)
|
|
from agents.exceptions import ModelBehaviorError
|
|
from agents.items import ItemHelpers
|
|
from agents.memory import SQLiteSession
|
|
from agents.models.chatcmpl_converter import Converter as ChatCompletionsConverter
|
|
from agents.models.openai_responses import Converter as ResponsesConverter
|
|
from agents.run_internal.turn_resolution import process_model_response
|
|
from agents.testing import ScriptedModel
|
|
from agents.tool_context import ToolContext
|
|
|
|
from .model_test_helpers import get_exact_output_stream_step
|
|
from .test_responses import get_handoff_tool_call, get_text_message
|
|
|
|
PROGRAM_CALL_ID = "call_program"
|
|
FUNCTION_CALL_ID = "call_lookup"
|
|
PROGRAM_CALLER = {"type": "program", "caller_id": PROGRAM_CALL_ID}
|
|
|
|
|
|
class InventoryOutput(BaseModel):
|
|
sku: str
|
|
available_units: int
|
|
|
|
|
|
class InventoryAwaitable(Awaitable[InventoryOutput]):
|
|
def __await__(self) -> Any:
|
|
raise NotImplementedError
|
|
|
|
|
|
class InventoryDict(TypedDict):
|
|
sku: str
|
|
available_units: int
|
|
|
|
|
|
@dataclass
|
|
class InventoryData:
|
|
sku: str
|
|
available_units: int
|
|
|
|
|
|
class AliasedInventoryOutput(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
sku: str
|
|
available_units: int = Field(
|
|
validation_alias="inputUnits",
|
|
serialization_alias="availableUnits",
|
|
)
|
|
|
|
|
|
def _program() -> Program:
|
|
return Program(
|
|
id="program_item",
|
|
call_id=PROGRAM_CALL_ID,
|
|
code='lookup_inventory(sku="A-1")',
|
|
fingerprint="fingerprint",
|
|
type="program",
|
|
)
|
|
|
|
|
|
def _function_call() -> ResponseFunctionToolCall:
|
|
return ResponseFunctionToolCall(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"A-1"}',
|
|
caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID),
|
|
type="function_call",
|
|
)
|
|
|
|
|
|
def _program_output(
|
|
status: Literal["completed", "incomplete"] = "completed",
|
|
) -> ProgramOutput:
|
|
return ProgramOutput(
|
|
id="program_output_item",
|
|
call_id=PROGRAM_CALL_ID,
|
|
result='{"sku":"A-1","available_units":42}',
|
|
status=status,
|
|
type="program_output",
|
|
)
|
|
|
|
|
|
def _hosted_program_call_and_tool(
|
|
output_type: str,
|
|
allowed_callers: list[Any] | None,
|
|
) -> tuple[Any, Any]:
|
|
if output_type in ("mcp_approval_request", "mcp_call", "mcp_list_tools"):
|
|
mcp_config: dict[str, Any] = {
|
|
"type": "mcp",
|
|
"server_label": "docs_server",
|
|
"server_url": "https://example.com/mcp",
|
|
}
|
|
if allowed_callers is not None:
|
|
mcp_config["allowed_callers"] = allowed_callers
|
|
hosted_tool = HostedMCPTool(tool_config=cast(Any, mcp_config))
|
|
if output_type == "mcp_list_tools":
|
|
return (
|
|
McpListTools.model_construct(
|
|
id="mcp_list_tools_1",
|
|
server_label="docs_server",
|
|
tools=[],
|
|
type="mcp_list_tools",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
hosted_tool,
|
|
)
|
|
call_type: Any = McpApprovalRequest if output_type == "mcp_approval_request" else McpCall
|
|
output = call_type.model_construct(
|
|
id=f"{output_type}_1",
|
|
arguments="{}",
|
|
name="search_docs",
|
|
server_label="docs_server",
|
|
type=output_type,
|
|
caller=PROGRAM_CALLER,
|
|
)
|
|
return output, hosted_tool
|
|
|
|
code_interpreter_config: dict[str, Any] = {
|
|
"type": "code_interpreter",
|
|
"container": "auto",
|
|
}
|
|
if allowed_callers is not None:
|
|
code_interpreter_config["allowed_callers"] = allowed_callers
|
|
return (
|
|
ResponseCodeInterpreterToolCall.model_construct(
|
|
id="code_interpreter_1",
|
|
container_id="container_1",
|
|
status="completed",
|
|
type="code_interpreter_call",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
CodeInterpreterTool(tool_config=cast(Any, code_interpreter_config)),
|
|
)
|
|
|
|
|
|
def _caller_dict(value: Any) -> dict[str, str]:
|
|
if isinstance(value, dict):
|
|
return cast(dict[str, str], value)
|
|
return cast(dict[str, str], value.model_dump(exclude_none=True))
|
|
|
|
|
|
def _raw_item_type(value: Any) -> str | None:
|
|
if isinstance(value, dict):
|
|
item_type = value.get("type")
|
|
return item_type if isinstance(item_type, str) else None
|
|
item_type = getattr(value, "type", None)
|
|
return item_type if isinstance(item_type, str) else None
|
|
|
|
|
|
def _function_output_raw_items(result: Any) -> list[dict[str, Any]]:
|
|
return [
|
|
cast(dict[str, Any], item.raw_item)
|
|
for item in result.new_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
and isinstance(item.raw_item, dict)
|
|
and item.raw_item.get("type") == "function_call_output"
|
|
]
|
|
|
|
|
|
def test_responses_converter_serializes_programmatic_tool_configuration() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
converted = ResponsesConverter.convert_tools(
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
handoffs=[],
|
|
)
|
|
|
|
assert converted.tools[0] == {"type": "programmatic_tool_calling"}
|
|
function_payload = cast(dict[str, Any], converted.tools[1])
|
|
assert function_payload["allowed_callers"] == ["programmatic"]
|
|
assert function_payload["output_schema"] == lookup_inventory.output_json_schema
|
|
assert function_payload["output_schema"] == {
|
|
"additionalProperties": False,
|
|
"properties": {
|
|
"sku": {"title": "Sku", "type": "string"},
|
|
"available_units": {"title": "Available Units", "type": "integer"},
|
|
},
|
|
"required": ["sku", "available_units"],
|
|
"title": "InventoryOutput",
|
|
"type": "object",
|
|
}
|
|
assert ResponsesConverter.convert_tool_choice("programmatic_tool_calling") == {
|
|
"type": "programmatic_tool_calling"
|
|
}
|
|
|
|
|
|
def test_function_tool_infers_typed_dict_and_dataclass_output_schemas() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def typed_dict_tool() -> InventoryDict:
|
|
return {"sku": "A-1", "available_units": 42}
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def dataclass_tool() -> InventoryData:
|
|
return InventoryData(sku="A-1", available_units=42)
|
|
|
|
assert typed_dict_tool.output_json_schema is not None
|
|
assert typed_dict_tool.output_json_schema["type"] == "object"
|
|
assert typed_dict_tool.output_json_schema["additionalProperties"] is False
|
|
assert dataclass_tool.output_json_schema is not None
|
|
assert dataclass_tool.output_json_schema["type"] == "object"
|
|
assert dataclass_tool.output_json_schema["additionalProperties"] is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"return_annotation",
|
|
[
|
|
Awaitable[InventoryOutput],
|
|
Coroutine[Any, Any, InventoryOutput],
|
|
InventoryAwaitable,
|
|
Awaitable[InventoryOutput] | InventoryOutput,
|
|
Awaitable,
|
|
Coroutine,
|
|
],
|
|
)
|
|
def test_sync_callable_does_not_infer_through_awaitable_output(
|
|
return_annotation: Any,
|
|
) -> None:
|
|
class LookupInventory:
|
|
def __call__(self) -> Any:
|
|
raise AssertionError("The handler must not run during tool construction.")
|
|
|
|
cast(Any, LookupInventory.__call__).__annotations__["return"] = return_annotation
|
|
|
|
with pytest.raises(UserError, match="programmatic function tool return annotation"):
|
|
function_tool(LookupInventory(), allowed_callers=["programmatic"])
|
|
|
|
|
|
@pytest.mark.skipif(sys.version_info < (3, 12), reason="PEP 695 requires Python 3.12")
|
|
def test_sync_callable_does_not_infer_through_pep695_awaitable_alias() -> None:
|
|
namespace = {
|
|
"Any": Any,
|
|
"Awaitable": Awaitable,
|
|
"InventoryOutput": InventoryOutput,
|
|
}
|
|
exec(
|
|
"type OutputAwaitable = Awaitable[InventoryOutput]\n"
|
|
"class LookupInventory:\n"
|
|
" def __call__(self) -> OutputAwaitable:\n"
|
|
" raise AssertionError\n",
|
|
namespace,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="explicit wrapper function"):
|
|
function_tool(namespace["LookupInventory"](), allowed_callers=["programmatic"])
|
|
|
|
|
|
def test_function_tool_treats_annotated_plain_returns_as_untyped() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def string_tool() -> Annotated[str, "plain string"]:
|
|
return "ok"
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def any_tool() -> Annotated[Any, "untyped value"]:
|
|
return {"status": "ok"}
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def none_tool() -> Annotated[None, "no value"]:
|
|
return None
|
|
|
|
assert string_tool.output_json_schema is None
|
|
assert string_tool._output_type_adapter is None
|
|
assert any_tool.output_json_schema is None
|
|
assert any_tool._output_type_adapter is None
|
|
assert none_tool.output_json_schema is None
|
|
assert none_tool._output_type_adapter is None
|
|
|
|
|
|
def test_function_tool_preserves_annotated_structured_return_metadata() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory() -> Annotated[
|
|
InventoryOutput,
|
|
Field(description="Inventory result"),
|
|
]:
|
|
return InventoryOutput(sku="A-1", available_units=42)
|
|
|
|
assert lookup_inventory.output_json_schema is not None
|
|
assert lookup_inventory.output_json_schema["description"] == "Inventory result"
|
|
assert lookup_inventory._output_type_adapter is not None
|
|
|
|
|
|
def test_function_tool_output_type_override_and_raw_schema_are_mutually_exclusive() -> None:
|
|
def unannotated_tool() -> Any:
|
|
return {"sku": "A-1", "available_units": 42}
|
|
|
|
tool = function_tool(
|
|
unannotated_tool,
|
|
allowed_callers=["programmatic"],
|
|
output_type=InventoryOutput,
|
|
)
|
|
|
|
assert tool.output_json_schema is not None
|
|
assert tool.output_json_schema["title"] == "InventoryOutput"
|
|
|
|
with pytest.raises(UserError, match="cannot both be provided"):
|
|
function_tool(
|
|
unannotated_tool,
|
|
allowed_callers=["programmatic"],
|
|
output_type=InventoryOutput,
|
|
output_json_schema={"type": "object"},
|
|
)
|
|
|
|
with pytest.raises(UserError, match="output_type must define a strict JSON object"):
|
|
function_tool(
|
|
unannotated_tool,
|
|
allowed_callers=["programmatic"],
|
|
output_type=str,
|
|
)
|
|
|
|
|
|
def test_function_tool_rejects_loose_programmatic_output_annotation() -> None:
|
|
def loose_dict_tool() -> dict[str, Any]:
|
|
return {"sku": "A-1", "available_units": 42}
|
|
|
|
with pytest.raises(UserError, match="return annotation must define a strict JSON object"):
|
|
function_tool(loose_dict_tool, allowed_callers=["programmatic"])
|
|
|
|
|
|
def test_function_tool_does_not_infer_non_programmatic_output() -> None:
|
|
@function_tool
|
|
def direct_tool() -> InventoryOutput:
|
|
return InventoryOutput(sku="A-1", available_units=42)
|
|
|
|
assert direct_tool.output_json_schema is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"output_json_schema",
|
|
[
|
|
{"type": "string"},
|
|
{"type": "object", "additionalProperties": True},
|
|
],
|
|
)
|
|
def test_function_tool_rejects_non_object_or_non_strict_raw_output_schema(
|
|
output_json_schema: dict[str, Any],
|
|
) -> None:
|
|
with pytest.raises(UserError, match="output_json_schema must define a.*object schema"):
|
|
function_tool(
|
|
lambda: "ok",
|
|
allowed_callers=["programmatic"],
|
|
output_json_schema=output_json_schema,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_validates_inferred_output_type() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def invalid_tool() -> InventoryOutput:
|
|
return {"sku": "A-1", "available_units": "many"} # type: ignore[return-value]
|
|
|
|
context = ToolContext(
|
|
None,
|
|
tool_name=invalid_tool.name,
|
|
tool_call_id="invalid",
|
|
tool_arguments="{}",
|
|
tool_call=_function_call(),
|
|
)
|
|
with pytest.raises(UserError, match="does not match its declared output type"):
|
|
await invalid_tool.on_invoke_tool(context, "{}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_backed_programmatic_tool_bypasses_default_failure_formatter() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def failing_tool() -> InventoryOutput:
|
|
raise RuntimeError("inventory unavailable")
|
|
|
|
context = ToolContext(
|
|
None,
|
|
tool_name=failing_tool.name,
|
|
tool_call_id="failing",
|
|
tool_arguments="{}",
|
|
tool_call=_function_call(),
|
|
)
|
|
with pytest.raises(RuntimeError, match="inventory unavailable"):
|
|
await failing_tool.on_invoke_tool(context, "{}")
|
|
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
output_json_schema={
|
|
"type": "object",
|
|
"properties": {"error": {"type": "string"}},
|
|
"required": ["error"],
|
|
"additionalProperties": False,
|
|
},
|
|
)
|
|
def failing_declared_schema_tool() -> str:
|
|
raise RuntimeError("declared schema unavailable")
|
|
|
|
declared_context = ToolContext(
|
|
None,
|
|
tool_name=failing_declared_schema_tool.name,
|
|
tool_call_id="failing-declared",
|
|
tool_arguments="{}",
|
|
tool_call=_function_call(),
|
|
)
|
|
with pytest.raises(RuntimeError, match="declared schema unavailable"):
|
|
await failing_declared_schema_tool.on_invoke_tool(declared_context, "{}")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_backed_direct_tool_preserves_argument_error_formatter() -> None:
|
|
@function_tool(allowed_callers=["direct", "programmatic"])
|
|
def failing_tool(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
direct_call = ResponseFunctionToolCall(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name=failing_tool.name,
|
|
arguments="{}",
|
|
type="function_call",
|
|
)
|
|
context = ToolContext(
|
|
None,
|
|
tool_name=failing_tool.name,
|
|
tool_call_id=FUNCTION_CALL_ID,
|
|
tool_arguments="{}",
|
|
tool_call=direct_call,
|
|
)
|
|
|
|
result = await failing_tool.on_invoke_tool(context, "{}")
|
|
|
|
assert result.startswith("An error occurred while running the tool. Please try again. Error:")
|
|
assert "Invalid JSON input for tool failing_tool" in result
|
|
assert "sku" not in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_preserves_direct_error_for_schema_backed_tool() -> None:
|
|
model = ScriptedModel()
|
|
direct_call = ResponseFunctionToolCall(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"A-1"}',
|
|
type="function_call",
|
|
)
|
|
model.extend([[direct_call], [get_text_message("inventory lookup failed")]])
|
|
|
|
@function_tool(allowed_callers=["direct", "programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
raise RuntimeError(f"inventory unavailable for {sku}")
|
|
|
|
result = await Runner.run(
|
|
Agent(name="inventory", model=model, tools=[lookup_inventory]),
|
|
"Check inventory",
|
|
)
|
|
|
|
function_output = next(
|
|
item for item in result.new_items if isinstance(item, ToolCallOutputItem)
|
|
)
|
|
expected_error = (
|
|
"An error occurred while running the tool. Please try again. "
|
|
"Error: inventory unavailable for A-1"
|
|
)
|
|
assert result.final_output == "inventory lookup failed"
|
|
assert function_output.output == expected_error
|
|
assert cast(dict[str, Any], function_output.raw_item)["output"] == expected_error
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_preserves_direct_default_timeout_for_schema_backed_tool() -> None:
|
|
model = ScriptedModel()
|
|
direct_call = ResponseFunctionToolCall(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"A-1"}',
|
|
type="function_call",
|
|
)
|
|
model.extend([[direct_call], [get_text_message("timed out")]])
|
|
|
|
@function_tool(allowed_callers=["direct", "programmatic"], timeout=0.01)
|
|
async def lookup_inventory(sku: str) -> InventoryOutput:
|
|
await asyncio.sleep(0.2)
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
result = await Runner.run(
|
|
Agent(name="inventory", model=model, tools=[lookup_inventory]),
|
|
"Check inventory",
|
|
)
|
|
|
|
function_output = next(
|
|
item for item in result.new_items if isinstance(item, ToolCallOutputItem)
|
|
)
|
|
assert result.final_output == "timed out"
|
|
assert isinstance(function_output.output, str)
|
|
assert "timed out" in function_output.output.lower()
|
|
assert cast(dict[str, Any], function_output.raw_item)["output"] == function_output.output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_backed_function_tool_accepts_conforming_custom_error_output() -> None:
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
failure_error_function=lambda _context, _error: json.dumps(
|
|
{"sku": "ERROR", "available_units": 0}
|
|
),
|
|
)
|
|
def failing_tool() -> InventoryOutput:
|
|
raise RuntimeError("inventory unavailable")
|
|
|
|
context = ToolContext(
|
|
None,
|
|
tool_name=failing_tool.name,
|
|
tool_call_id="failing",
|
|
tool_arguments="{}",
|
|
)
|
|
result = await failing_tool.on_invoke_tool(context, "{}")
|
|
output = ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
result,
|
|
output_json_schema=failing_tool.output_json_schema,
|
|
output_type_adapter=failing_tool._output_type_adapter,
|
|
)
|
|
|
|
assert json.loads(cast(str, output["output"])) == {
|
|
"sku": "ERROR",
|
|
"available_units": 0,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_schema_backed_programmatic_tool_accepts_conforming_custom_timeout_output() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("timeout handled")],
|
|
]
|
|
)
|
|
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
timeout=0.01,
|
|
timeout_error_function=lambda _context, _error: json.dumps(
|
|
{"sku": "TIMEOUT", "available_units": 0}
|
|
),
|
|
)
|
|
async def lookup_inventory(sku: str) -> InventoryOutput:
|
|
await asyncio.sleep(0.2)
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
result = await Runner.run(
|
|
Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
),
|
|
"Check inventory",
|
|
)
|
|
|
|
function_output = next(
|
|
item for item in result.new_items if isinstance(item, ToolCallOutputItem)
|
|
)
|
|
assert result.final_output == "timeout handled"
|
|
assert json.loads(cast(str, cast(dict[str, Any], function_output.raw_item)["output"])) == {
|
|
"sku": "TIMEOUT",
|
|
"available_units": 0,
|
|
}
|
|
|
|
|
|
def test_schema_backed_function_output_rejects_plain_error_text() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory() -> InventoryOutput:
|
|
return InventoryOutput(sku="A-1", available_units=42)
|
|
|
|
with pytest.raises(UserError, match="does not match its declared output schema"):
|
|
ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
"inventory unavailable",
|
|
output_json_schema=lookup_inventory.output_json_schema,
|
|
output_type_adapter=lookup_inventory._output_type_adapter,
|
|
)
|
|
|
|
with pytest.raises(UserError, match="requires a JSON object"):
|
|
ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
"inventory unavailable",
|
|
output_json_schema={"type": "object"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_serializes_typed_output_with_schema_aliases() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def aliased_tool() -> AliasedInventoryOutput:
|
|
return AliasedInventoryOutput(sku="A-1", available_units=42)
|
|
|
|
context = ToolContext(
|
|
None,
|
|
tool_name=aliased_tool.name,
|
|
tool_call_id="aliased",
|
|
tool_arguments="{}",
|
|
)
|
|
result = await aliased_tool.on_invoke_tool(context, "{}")
|
|
output = ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
result,
|
|
output_json_schema=aliased_tool.output_json_schema,
|
|
output_type_adapter=aliased_tool._output_type_adapter,
|
|
)
|
|
|
|
assert aliased_tool.output_json_schema is not None
|
|
assert "availableUnits" in aliased_tool.output_json_schema["properties"]
|
|
assert json.loads(cast(str, output["output"])) == {
|
|
"sku": "A-1",
|
|
"availableUnits": 42,
|
|
}
|
|
|
|
|
|
def test_responses_converter_serializes_allowed_callers_for_other_eligible_tools() -> None:
|
|
async def shell_executor(_request: Any) -> str:
|
|
return "ok"
|
|
|
|
def custom_executor(_context: Any, _input: str) -> str:
|
|
return "ok"
|
|
|
|
class Editor:
|
|
def create_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
def update_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
def delete_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
converted = ResponsesConverter.convert_tools(
|
|
tools=[
|
|
ProgrammaticToolCallingTool(),
|
|
ShellTool(executor=shell_executor, allowed_callers=["programmatic"]),
|
|
ApplyPatchTool(editor=Editor(), allowed_callers=["direct", "programmatic"]),
|
|
CustomTool(
|
|
name="custom",
|
|
description="Custom tool",
|
|
on_invoke_tool=custom_executor,
|
|
allowed_callers=["programmatic"],
|
|
),
|
|
],
|
|
handoffs=[],
|
|
)
|
|
|
|
tool_payloads = [cast(dict[str, Any], tool) for tool in converted.tools]
|
|
assert tool_payloads[1]["allowed_callers"] == ["programmatic"]
|
|
assert tool_payloads[2]["allowed_callers"] == ["direct", "programmatic"]
|
|
assert tool_payloads[3]["allowed_callers"] == ["programmatic"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"allowed_callers",
|
|
[
|
|
[],
|
|
["direct", "direct"],
|
|
["unsupported"],
|
|
],
|
|
)
|
|
def test_tool_construction_rejects_invalid_allowed_callers(
|
|
allowed_callers: list[Any],
|
|
) -> None:
|
|
with pytest.raises(UserError, match="allowed_callers"):
|
|
function_tool(lambda: "ok", allowed_callers=allowed_callers)
|
|
|
|
with pytest.raises(UserError, match="allowed_callers"):
|
|
ShellTool(executor=lambda _request: "ok", allowed_callers=allowed_callers)
|
|
|
|
with pytest.raises(UserError, match="allowed_callers"):
|
|
HostedMCPTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "mcp",
|
|
"server_label": "inventory",
|
|
"server_url": "https://example.com/mcp",
|
|
"allowed_callers": allowed_callers,
|
|
},
|
|
)
|
|
)
|
|
|
|
with pytest.raises(UserError, match="allowed_callers"):
|
|
CodeInterpreterTool(
|
|
tool_config=cast(
|
|
Any,
|
|
{
|
|
"type": "code_interpreter",
|
|
"container": "auto",
|
|
"allowed_callers": allowed_callers,
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
def test_responses_converter_rejects_incomplete_programmatic_configuration() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def programmatic_only() -> str:
|
|
return "ok"
|
|
|
|
with pytest.raises(UserError, match="requires ProgrammaticToolCallingTool"):
|
|
ResponsesConverter.convert_tools(tools=[programmatic_only], handoffs=[])
|
|
|
|
with pytest.raises(UserError, match="requires ProgrammaticToolCallingTool"):
|
|
ResponsesConverter.convert_tools(
|
|
tools=[],
|
|
handoffs=[],
|
|
tool_choice="programmatic_tool_calling",
|
|
)
|
|
|
|
with pytest.raises(UserError, match="requires at least one tool"):
|
|
ResponsesConverter.convert_tools(
|
|
tools=[ProgrammaticToolCallingTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
with pytest.raises(UserError, match="Only one ProgrammaticToolCallingTool"):
|
|
ResponsesConverter.convert_tools(
|
|
tools=[ProgrammaticToolCallingTool(), ProgrammaticToolCallingTool()],
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_responses_converter_accepts_mixed_or_tool_search_managed_configuration() -> None:
|
|
@function_tool(allowed_callers=["direct", "programmatic"])
|
|
def mixed_callers() -> str:
|
|
return "ok"
|
|
|
|
converted_mixed = ResponsesConverter.convert_tools(tools=[mixed_callers], handoffs=[])
|
|
assert cast(dict[str, Any], converted_mixed.tools[0])["allowed_callers"] == [
|
|
"direct",
|
|
"programmatic",
|
|
]
|
|
|
|
converted_search = ResponsesConverter.convert_tools(
|
|
tools=[ProgrammaticToolCallingTool(), ToolSearchTool()],
|
|
handoffs=[],
|
|
allow_opaque_tool_search_surface=True,
|
|
)
|
|
assert converted_search.tools == [
|
|
{"type": "programmatic_tool_calling"},
|
|
{"type": "tool_search"},
|
|
]
|
|
|
|
|
|
def test_chat_completions_rejects_programmatic_tool_configuration() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory() -> InventoryOutput:
|
|
return InventoryOutput(sku="A-1", available_units=42)
|
|
|
|
with pytest.raises(UserError, match="only supported with OpenAI Responses models"):
|
|
ChatCompletionsConverter.tool_to_openai(lookup_inventory)
|
|
|
|
with pytest.raises(UserError, match="programmatic_tool_calling"):
|
|
ChatCompletionsConverter.convert_tool_choice("programmatic_tool_calling")
|
|
|
|
with pytest.raises(UserError, match="Hosted tools are not supported"):
|
|
ChatCompletionsConverter.tool_to_openai(ProgrammaticToolCallingTool())
|
|
|
|
|
|
def test_function_output_preserves_caller_and_uses_declared_json_schema() -> None:
|
|
output = ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
{"sku": "A-1", "available_units": 42},
|
|
output_json_schema={"type": "object"},
|
|
)
|
|
|
|
assert json.loads(cast(str, output["output"])) == {
|
|
"sku": "A-1",
|
|
"available_units": 42,
|
|
}
|
|
assert _caller_dict(output["caller"]) == PROGRAM_CALLER
|
|
|
|
programmatic_output = ItemHelpers.tool_call_output_item(
|
|
_function_call(),
|
|
{"sku": "A-1", "units": [1, 2]},
|
|
)
|
|
assert json.loads(cast(str, programmatic_output["output"])) == {
|
|
"sku": "A-1",
|
|
"units": [1, 2],
|
|
}
|
|
assert _caller_dict(programmatic_output["caller"]) == PROGRAM_CALLER
|
|
|
|
direct_call = ResponseFunctionToolCall(
|
|
id="direct_function_item",
|
|
call_id="direct_call",
|
|
name="lookup_inventory",
|
|
arguments="{}",
|
|
type="function_call",
|
|
)
|
|
legacy_output = ItemHelpers.tool_call_output_item(direct_call, {"sku": "A-1"})
|
|
assert legacy_output["output"] == "{'sku': 'A-1'}"
|
|
|
|
|
|
def test_process_model_response_keeps_program_items_in_order() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
response = ModelResponse(
|
|
output=[_program(), _function_call(), _program_output("incomplete")],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
)
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=response,
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
assert [type(item) for item in processed.new_items] == [
|
|
ToolCallItem,
|
|
ToolCallItem,
|
|
ToolCallOutputItem,
|
|
]
|
|
assert [_raw_item_type(item.raw_item) for item in processed.new_items] == [
|
|
"program",
|
|
"function_call",
|
|
"program_output",
|
|
]
|
|
assert processed.tools_used == [
|
|
"programmatic_tool_calling",
|
|
"lookup_inventory",
|
|
"programmatic_tool_calling",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("call_id", [None, ""])
|
|
def test_process_model_response_rejects_program_without_valid_call_id(
|
|
call_id: str | None,
|
|
) -> None:
|
|
program: dict[str, Any] = {
|
|
"type": "program",
|
|
"id": "program_item",
|
|
"code": "return 42",
|
|
"fingerprint": "fingerprint",
|
|
}
|
|
if call_id is not None:
|
|
program["call_id"] = call_id
|
|
|
|
response = ModelResponse(output=[], usage=Usage(), response_id="response_1")
|
|
response.output = cast(Any, [program])
|
|
agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()])
|
|
|
|
with pytest.raises(ModelBehaviorError, match="without a valid call_id"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=response,
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"program_output",
|
|
[_program_output(), _program_output().model_dump(exclude_none=True)],
|
|
)
|
|
def test_process_model_response_rejects_orphan_program_output(program_output: Any) -> None:
|
|
agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()])
|
|
|
|
with pytest.raises(ModelBehaviorError, match="does not match a parent program item"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[program_output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_process_model_response_accepts_program_output_for_retained_program() -> None:
|
|
agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()])
|
|
existing_program = ToolCallItem(raw_item=_program(), agent=agent)
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program_output()],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=[existing_program],
|
|
)
|
|
|
|
assert len(processed.new_items) == 1
|
|
assert isinstance(processed.new_items[0], ToolCallOutputItem)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("field", "value", "remove_field", "error_match"),
|
|
[
|
|
("status", None, True, "without a valid status"),
|
|
("status", "running", False, "without a valid status"),
|
|
("result", None, True, "without a string result"),
|
|
("result", 42, False, "without a string result"),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_malformed_program_output(
|
|
field: str,
|
|
value: Any,
|
|
remove_field: bool,
|
|
error_match: str,
|
|
) -> None:
|
|
agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()])
|
|
program_output = _program_output().model_dump(exclude_none=True)
|
|
if remove_field:
|
|
program_output.pop(field)
|
|
else:
|
|
program_output[field] = value
|
|
response = ModelResponse(output=[], usage=Usage(), response_id="response_1")
|
|
response.output = cast(Any, [_program(), program_output])
|
|
|
|
with pytest.raises(ModelBehaviorError, match=error_match):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=response,
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("parent_location", ["existing_items", "current_response"])
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_rejects_duplicate_completed_program_output(
|
|
parent_location: str,
|
|
as_mapping: bool,
|
|
) -> None:
|
|
agent = Agent(name="inventory", tools=[ProgrammaticToolCallingTool()])
|
|
program: Any = _program()
|
|
completed_output: Any = _program_output()
|
|
duplicate_output: Any = _program_output().model_copy(
|
|
update={"id": "duplicate_program_output_item"}
|
|
)
|
|
if as_mapping:
|
|
program = program.model_dump(exclude_none=True)
|
|
completed_output = completed_output.model_dump(exclude_none=True)
|
|
duplicate_output = duplicate_output.model_dump(exclude_none=True)
|
|
|
|
response_output = [duplicate_output]
|
|
existing_items: list[RunItem] = []
|
|
if parent_location == "existing_items":
|
|
existing_items = [
|
|
ToolCallItem(raw_item=program, agent=agent),
|
|
ToolCallOutputItem(
|
|
raw_item=completed_output,
|
|
output=(
|
|
completed_output["result"]
|
|
if isinstance(completed_output, dict)
|
|
else completed_output.result
|
|
),
|
|
agent=agent,
|
|
),
|
|
]
|
|
else:
|
|
response_output = [program, completed_output, duplicate_output]
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program is already completed"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=response_output,
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=existing_items,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_rejects_program_owned_call_for_completed_retained_program(
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
program: Any = _program()
|
|
program_output: Any = _program_output()
|
|
tool_call: Any = _function_call()
|
|
if as_mapping:
|
|
program = program.model_dump(exclude_none=True)
|
|
program_output = program_output.model_dump(exclude_none=True)
|
|
tool_call = tool_call.model_dump(exclude_none=True)
|
|
|
|
existing_items: list[RunItem] = [
|
|
ToolCallItem(raw_item=program, agent=agent),
|
|
ToolCallOutputItem(
|
|
raw_item=program_output,
|
|
output=getattr(program_output, "result", None)
|
|
or cast(dict[str, Any], program_output)["result"],
|
|
agent=agent,
|
|
),
|
|
]
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program is already completed"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=existing_items,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
@pytest.mark.parametrize("call_before_completed_output", [False, True])
|
|
def test_process_model_response_rejects_program_owned_call_when_response_completes_program(
|
|
as_mapping: bool,
|
|
call_before_completed_output: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
child_items = (
|
|
[_function_call(), _program_output()]
|
|
if call_before_completed_output
|
|
else [_program_output(), _function_call()]
|
|
)
|
|
output: list[Any] = [_program(), *child_items]
|
|
if as_mapping:
|
|
output = [item.model_dump(exclude_none=True) for item in output]
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program is already completed"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=output, usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_accepts_program_owned_call_for_incomplete_retained_program(
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
program: Any = _program()
|
|
program_output: Any = _program_output("incomplete")
|
|
tool_call: Any = _function_call()
|
|
if as_mapping:
|
|
program = program.model_dump(exclude_none=True)
|
|
program_output = program_output.model_dump(exclude_none=True)
|
|
tool_call = tool_call.model_dump(exclude_none=True)
|
|
|
|
existing_items: list[RunItem] = [
|
|
ToolCallItem(raw_item=program, agent=agent),
|
|
ToolCallOutputItem(
|
|
raw_item=program_output,
|
|
output=getattr(program_output, "result", None)
|
|
or cast(dict[str, Any], program_output)["result"],
|
|
agent=agent,
|
|
),
|
|
]
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=existing_items,
|
|
)
|
|
|
|
assert len(processed.new_items) == 1
|
|
assert _raw_item_type(processed.new_items[0].raw_item) == "function_call"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"output",
|
|
[
|
|
_program(),
|
|
_program().model_dump(exclude_none=True),
|
|
_program_output(),
|
|
_program_output().model_dump(exclude_none=True),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_program_items_without_programmatic_tool(
|
|
output: Any,
|
|
) -> None:
|
|
agent = Agent(name="inventory")
|
|
|
|
with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[output], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_rejects_program_item_without_programmatic_tool() -> None:
|
|
model = ScriptedModel()
|
|
model.enqueue([_program()])
|
|
agent = Agent(name="inventory", model=model)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"):
|
|
await Runner.run(agent, "Check inventory")
|
|
|
|
|
|
def test_process_model_response_rejects_program_owned_calls_without_programmatic_tool() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
async def shell_executor(_request: Any) -> str:
|
|
return "ok"
|
|
|
|
shell_tool = ShellTool(executor=shell_executor, allowed_callers=["programmatic"])
|
|
shell_call = ResponseFunctionShellToolCall(
|
|
id="shell_item",
|
|
call_id="call_shell",
|
|
action=Action(commands=["echo ok"]),
|
|
status="completed",
|
|
type="shell_call",
|
|
caller=cast(Any, PROGRAM_CALLER),
|
|
)
|
|
custom_tool = CustomTool(
|
|
name="custom",
|
|
description="Custom tool",
|
|
on_invoke_tool=lambda _context, _input: "ok",
|
|
allowed_callers=["programmatic"],
|
|
)
|
|
custom_call = ResponseCustomToolCall(
|
|
id="custom_item",
|
|
call_id="call_custom",
|
|
input="input",
|
|
name="custom",
|
|
type="custom_tool_call",
|
|
caller=cast(Any, PROGRAM_CALLER),
|
|
)
|
|
|
|
cases: list[tuple[Any, Any]] = [
|
|
(lookup_inventory, _function_call()),
|
|
(lookup_inventory, _function_call().model_dump(exclude_none=True)),
|
|
(shell_tool, shell_call),
|
|
(shell_tool, shell_call.model_dump(exclude_none=True)),
|
|
(custom_tool, custom_call),
|
|
(custom_tool, custom_call.model_dump(exclude_none=True)),
|
|
]
|
|
for tool, tool_call in cases:
|
|
agent = Agent(name="tool agent", tools=[tool])
|
|
with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_does_not_execute_program_owned_call_without_programmatic_tool() -> None:
|
|
model = ScriptedModel()
|
|
model.enqueue([_function_call()])
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
nonlocal executed
|
|
executed = True
|
|
return sku
|
|
|
|
agent = Agent(name="inventory", model=model, tools=[lookup_inventory])
|
|
|
|
with pytest.raises(ModelBehaviorError, match="programmatic_tool_calling tool"):
|
|
await Runner.run(agent, "Check inventory")
|
|
|
|
assert executed is False
|
|
|
|
|
|
def test_process_model_response_rejects_program_owned_calls_without_parent_program() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
async def shell_executor(_request: Any) -> str:
|
|
return "ok"
|
|
|
|
shell_tool = ShellTool(executor=shell_executor, allowed_callers=["programmatic"])
|
|
shell_call = ResponseFunctionShellToolCall(
|
|
id="shell_item",
|
|
call_id="call_shell",
|
|
action=Action(commands=["echo ok"]),
|
|
status="completed",
|
|
type="shell_call",
|
|
caller=cast(Any, PROGRAM_CALLER),
|
|
)
|
|
custom_tool = CustomTool(
|
|
name="custom",
|
|
description="Custom tool",
|
|
on_invoke_tool=lambda _context, _input: "ok",
|
|
allowed_callers=["programmatic"],
|
|
)
|
|
custom_call = ResponseCustomToolCall(
|
|
id="custom_item",
|
|
call_id="call_custom",
|
|
input="input",
|
|
name="custom",
|
|
type="custom_tool_call",
|
|
caller=cast(Any, PROGRAM_CALLER),
|
|
)
|
|
|
|
cases: list[tuple[Any, Any]] = [
|
|
(lookup_inventory, _function_call()),
|
|
(lookup_inventory, _function_call().model_dump(exclude_none=True)),
|
|
(shell_tool, shell_call),
|
|
(shell_tool, shell_call.model_dump(exclude_none=True)),
|
|
(custom_tool, custom_call),
|
|
(custom_tool, custom_call.model_dump(exclude_none=True)),
|
|
]
|
|
for tool, tool_call in cases:
|
|
agent = Agent(name="tool agent", tools=[ProgrammaticToolCallingTool(), tool])
|
|
with pytest.raises(ModelBehaviorError, match="parent program item"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("parent_location", ["current_response", "existing_items"])
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_accepts_program_owned_call_with_parent_program(
|
|
parent_location: str,
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
tool_call: Any = _function_call()
|
|
parent_program: Any = _program()
|
|
if as_mapping:
|
|
tool_call = tool_call.model_dump(exclude_none=True)
|
|
parent_program = parent_program.model_dump(exclude_none=True)
|
|
|
|
response_output: list[Any] = [tool_call]
|
|
existing_items: list[ToolCallItem] = []
|
|
if parent_location == "current_response":
|
|
response_output.insert(0, parent_program)
|
|
else:
|
|
existing_items.append(ToolCallItem(raw_item=parent_program, agent=agent))
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=response_output, usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=existing_items,
|
|
)
|
|
|
|
assert "function_call" in {_raw_item_type(item.raw_item) for item in processed.new_items}
|
|
|
|
|
|
@pytest.mark.parametrize("child_type", ["function_call", "program_output"])
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_rejects_program_child_before_parent(
|
|
child_type: str,
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
child: Any = _function_call() if child_type == "function_call" else _program_output()
|
|
parent: Any = _program()
|
|
if as_mapping:
|
|
child = child.model_dump(exclude_none=True)
|
|
parent = parent.model_dump(exclude_none=True)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program item"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[child, parent],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("child_type", ["function_call", "program_output"])
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_accepts_server_owned_parent_from_submitted_delta(
|
|
child_type: str,
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
child: Any = _function_call() if child_type == "function_call" else _program_output()
|
|
if as_mapping:
|
|
child = child.model_dump(exclude_none=True)
|
|
submitted_delta = [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": FUNCTION_CALL_ID,
|
|
"output": '{"sku":"A-1","available_units":42}',
|
|
"caller": PROGRAM_CALLER,
|
|
}
|
|
]
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[child], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
server_manages_conversation=True,
|
|
server_managed_input_items=submitted_delta,
|
|
)
|
|
|
|
assert child_type in {_raw_item_type(item.raw_item) for item in processed.new_items}
|
|
|
|
|
|
def test_process_model_response_rejects_server_owned_child_without_parent_evidence() -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program item"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_function_call()],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
server_manages_conversation=True,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_accepts_server_owned_parent_after_incomplete_output(
|
|
as_mapping: bool,
|
|
) -> None:
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
prior_output: Any = _program_output("incomplete")
|
|
if as_mapping:
|
|
prior_output = prior_output.model_dump(exclude_none=True)
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_function_call()],
|
|
usage=Usage(),
|
|
response_id="response_2",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
existing_items=[
|
|
ToolCallOutputItem(
|
|
raw_item=prior_output,
|
|
output='{"status":"waiting"}',
|
|
agent=agent,
|
|
)
|
|
],
|
|
server_manages_conversation=True,
|
|
)
|
|
|
|
assert _raw_item_type(processed.new_items[0].raw_item) == "function_call"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_does_not_execute_program_owned_call_without_parent_program() -> None:
|
|
model = ScriptedModel()
|
|
model.enqueue([_function_call()])
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> str:
|
|
nonlocal executed
|
|
executed = True
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="parent program item"):
|
|
await Runner.run(agent, "Check inventory")
|
|
|
|
assert executed is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("allowed_callers", "caller", "expected_caller"),
|
|
[
|
|
(None, CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), "programmatic"),
|
|
(["direct"], CallerProgram(type="program", caller_id=PROGRAM_CALL_ID), "programmatic"),
|
|
(["programmatic"], None, "direct"),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_disallowed_function_callers(
|
|
allowed_callers: list[Any] | None,
|
|
caller: CallerProgram | None,
|
|
expected_caller: str,
|
|
) -> None:
|
|
@function_tool(allowed_callers=allowed_callers)
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
tool_call = ResponseFunctionToolCall(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"A-1"}',
|
|
caller=caller,
|
|
type="function_call",
|
|
)
|
|
response_output: list[Any] = [_program(), tool_call] if caller is not None else [tool_call]
|
|
|
|
with pytest.raises(ModelBehaviorError, match=f"caller {expected_caller}"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=response_output, usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_process_model_response_rejects_unknown_function_caller_type() -> None:
|
|
@function_tool
|
|
def lookup_inventory(sku: str) -> str:
|
|
return sku
|
|
|
|
agent = Agent(name="inventory", tools=[lookup_inventory])
|
|
tool_call = ResponseFunctionToolCall.model_construct(
|
|
id="function_item",
|
|
call_id=FUNCTION_CALL_ID,
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"A-1"}',
|
|
caller={"type": "unknown", "caller_id": PROGRAM_CALL_ID},
|
|
type="function_call",
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="unsupported caller type 'unknown'"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(output=[tool_call], usage=Usage(), response_id="response_1"),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
def test_process_model_response_rejects_disallowed_non_function_callers() -> None:
|
|
caller = cast(Any, PROGRAM_CALLER)
|
|
|
|
async def shell_executor(_request: Any) -> str:
|
|
return "ok"
|
|
|
|
shell_tool = ShellTool(executor=shell_executor)
|
|
programmatic_tool = ProgrammaticToolCallingTool()
|
|
shell_call = ResponseFunctionShellToolCall(
|
|
id="shell_item",
|
|
call_id="call_shell",
|
|
action=Action(commands=["echo ok"]),
|
|
status="completed",
|
|
type="shell_call",
|
|
caller=caller,
|
|
)
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=Agent(name="shell", tools=[programmatic_tool, shell_tool]),
|
|
all_tools=[programmatic_tool, shell_tool],
|
|
response=ModelResponse(
|
|
output=[_program(), shell_call], usage=Usage(), response_id="response_1"
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
custom_tool = CustomTool(
|
|
name="custom",
|
|
description="Custom tool",
|
|
on_invoke_tool=lambda _context, _input: "ok",
|
|
)
|
|
custom_call = ResponseCustomToolCall(
|
|
id="custom_item",
|
|
call_id="call_custom",
|
|
input="input",
|
|
name="custom",
|
|
type="custom_tool_call",
|
|
caller=caller,
|
|
)
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=Agent(name="custom", tools=[programmatic_tool, custom_tool]),
|
|
all_tools=[programmatic_tool, custom_tool],
|
|
response=ModelResponse(
|
|
output=[_program(), custom_call], usage=Usage(), response_id="response_1"
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
class Editor:
|
|
def create_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
def update_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
def delete_file(self, _operation: Any) -> str:
|
|
return "ok"
|
|
|
|
apply_patch_tool = ApplyPatchTool(editor=Editor(), allowed_callers=["programmatic"])
|
|
apply_patch_call = ResponseApplyPatchToolCall(
|
|
id="apply_patch_item",
|
|
call_id="call_apply_patch",
|
|
operation=OperationCreateFile(type="create_file", path="example.txt", diff="hello"),
|
|
status="completed",
|
|
type="apply_patch_call",
|
|
)
|
|
with pytest.raises(ModelBehaviorError, match="caller direct"):
|
|
process_model_response(
|
|
agent=Agent(name="apply patch", tools=[programmatic_tool, apply_patch_tool]),
|
|
all_tools=[programmatic_tool, apply_patch_tool],
|
|
response=ModelResponse(
|
|
output=[apply_patch_call], usage=Usage(), response_id="response_1"
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"tool_call",
|
|
[
|
|
ResponseFileSearchToolCall.model_construct(
|
|
id="file_search_1",
|
|
queries=["inventory"],
|
|
status="completed",
|
|
type="file_search_call",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
ResponseFunctionWebSearch.model_construct(
|
|
id="web_search_1",
|
|
action=ActionSearch(type="search", query="inventory"),
|
|
status="completed",
|
|
type="web_search_call",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
ImageGenerationCall.model_construct(
|
|
id="image_generation_1",
|
|
status="completed",
|
|
type="image_generation_call",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_program_owned_direct_only_hosted_calls(
|
|
tool_call: Any,
|
|
as_mapping: bool,
|
|
) -> None:
|
|
output = tool_call.model_dump(exclude_none=True) if as_mapping else tool_call
|
|
agent = Agent(name="hosted", tools=[ProgrammaticToolCallingTool()])
|
|
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"tool_search_item",
|
|
[
|
|
ResponseToolSearchCall.model_construct(
|
|
id="tool_search_call_item",
|
|
arguments={},
|
|
execution="server",
|
|
status="completed",
|
|
type="tool_search_call",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
ResponseToolSearchOutputItem.model_construct(
|
|
id="tool_search_output_item",
|
|
execution="server",
|
|
status="completed",
|
|
tools=[],
|
|
type="tool_search_output",
|
|
caller=PROGRAM_CALLER,
|
|
),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_program_owned_tool_search_items(
|
|
tool_search_item: Any,
|
|
) -> None:
|
|
agent = Agent(
|
|
name="tool search",
|
|
tools=[ProgrammaticToolCallingTool(), ToolSearchTool()],
|
|
)
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), tool_search_item],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("output_type", "allowed_callers"),
|
|
[
|
|
("mcp_approval_request", None),
|
|
("mcp_approval_request", ["direct"]),
|
|
("mcp_call", None),
|
|
("mcp_call", ["direct"]),
|
|
("mcp_list_tools", None),
|
|
("mcp_list_tools", ["direct"]),
|
|
("code_interpreter_call", None),
|
|
("code_interpreter_call", ["direct"]),
|
|
],
|
|
)
|
|
def test_process_model_response_rejects_disallowed_hosted_program_callers(
|
|
output_type: str,
|
|
allowed_callers: list[Any] | None,
|
|
) -> None:
|
|
output, hosted_tool = _hosted_program_call_and_tool(output_type, allowed_callers)
|
|
programmatic_tool = ProgrammaticToolCallingTool()
|
|
agent = Agent(name="hosted", tools=[programmatic_tool, hosted_tool])
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"output_type",
|
|
["mcp_approval_request", "mcp_call", "mcp_list_tools", "code_interpreter_call"],
|
|
)
|
|
def test_process_model_response_accepts_allowed_hosted_program_callers(
|
|
output_type: str,
|
|
) -> None:
|
|
output, hosted_tool = _hosted_program_call_and_tool(output_type, ["programmatic"])
|
|
programmatic_tool = ProgrammaticToolCallingTool()
|
|
agent = Agent(name="hosted", tools=[programmatic_tool, hosted_tool])
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
assert len(processed.new_items) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
@pytest.mark.parametrize("allowed_callers", [None, ["direct"]])
|
|
def test_process_model_response_rejects_disallowed_program_owned_shell_output(
|
|
as_mapping: bool,
|
|
allowed_callers: list[Any] | None,
|
|
) -> None:
|
|
shell_output: Any = ResponseFunctionShellToolCallOutput.model_construct(
|
|
id="shell_output_1",
|
|
call_id="shell_call_1",
|
|
status="completed",
|
|
type="shell_call_output",
|
|
output=[],
|
|
caller=PROGRAM_CALLER,
|
|
)
|
|
if as_mapping:
|
|
shell_output = shell_output.model_dump(exclude_none=True)
|
|
shell_tool = ShellTool(executor=lambda _request: "ok", allowed_callers=allowed_callers)
|
|
agent = Agent(
|
|
name="shell",
|
|
tools=[ProgrammaticToolCallingTool(), shell_tool],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), shell_output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("as_mapping", [False, True])
|
|
def test_process_model_response_accepts_allowed_program_owned_shell_output(
|
|
as_mapping: bool,
|
|
) -> None:
|
|
shell_output: Any = ResponseFunctionShellToolCallOutput.model_construct(
|
|
id="shell_output_1",
|
|
call_id="shell_call_1",
|
|
status="completed",
|
|
type="shell_call_output",
|
|
output=[],
|
|
caller=PROGRAM_CALLER,
|
|
)
|
|
if as_mapping:
|
|
shell_output = shell_output.model_dump(exclude_none=True)
|
|
shell_tool = ShellTool(executor=lambda _request: "ok", allowed_callers=["programmatic"])
|
|
agent = Agent(
|
|
name="shell",
|
|
tools=[ProgrammaticToolCallingTool(), shell_tool],
|
|
)
|
|
|
|
processed = process_model_response(
|
|
agent=agent,
|
|
all_tools=agent.tools,
|
|
response=ModelResponse(
|
|
output=[_program(), shell_output],
|
|
usage=Usage(),
|
|
response_id="response_1",
|
|
),
|
|
output_schema=None,
|
|
handoffs=[],
|
|
)
|
|
|
|
assert len(processed.new_items) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
async def test_runner_executes_and_replays_programmatic_function_calls(streamed: bool) -> None:
|
|
outputs = [
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("42 units are available")],
|
|
]
|
|
model = ScriptedModel(
|
|
[get_exact_output_stream_step(output) for output in outputs] if streamed else outputs
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
model_settings=ModelSettings(tool_choice="programmatic_tool_calling"),
|
|
)
|
|
|
|
result: Any
|
|
if streamed:
|
|
result = Runner.run_streamed(agent, "Check inventory")
|
|
events = [event async for event in result.stream_events()]
|
|
assert any(
|
|
getattr(event, "name", None) == "tool_called"
|
|
and _raw_item_type(getattr(getattr(event, "item", None), "raw_item", None)) == "program"
|
|
for event in events
|
|
)
|
|
else:
|
|
result = await Runner.run(agent, "Check inventory")
|
|
|
|
assert result.final_output == "42 units are available"
|
|
assert bool(model.calls)
|
|
assert model.calls[0].model_settings.tool_choice == "programmatic_tool_calling"
|
|
assert model.calls[-1].model_settings.tool_choice is None
|
|
|
|
function_outputs = [
|
|
item
|
|
for item in result.new_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
and getattr(item.raw_item, "get", lambda _key: None)("type") == "function_call_output"
|
|
]
|
|
assert len(function_outputs) == 1
|
|
raw_output = cast(dict[str, Any], function_outputs[0].raw_item)
|
|
assert json.loads(cast(str, raw_output["output"])) == {
|
|
"sku": "A-1",
|
|
"available_units": 42,
|
|
}
|
|
assert _caller_dict(raw_output["caller"]) == PROGRAM_CALLER
|
|
|
|
replayed_output = cast(
|
|
dict[str, Any],
|
|
next(
|
|
item
|
|
for item in model.calls[-1].input
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
),
|
|
)
|
|
assert _caller_dict(replayed_output["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typed_programmatic_tool_preserves_input_guardrail_rejection() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("request rejected")],
|
|
]
|
|
)
|
|
executed = False
|
|
|
|
@tool_input_guardrail
|
|
def reject_tool_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
|
|
return ToolGuardrailFunctionOutput.reject_content("inventory lookup blocked")
|
|
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
tool_input_guardrails=[reject_tool_input],
|
|
)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check inventory")
|
|
|
|
assert executed is False
|
|
assert result.final_output == "request rejected"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
assert json.loads(function_outputs[0]["output"]) == {"error": "inventory lookup blocked"}
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typed_programmatic_tool_preserves_default_timeout_result() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("request timed out")],
|
|
]
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"], timeout=0.01)
|
|
async def lookup_inventory(sku: str) -> InventoryOutput:
|
|
await asyncio.sleep(0.2)
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check inventory")
|
|
|
|
assert result.final_output == "request timed out"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
timeout_output = json.loads(function_outputs[0]["output"])
|
|
assert isinstance(timeout_output, dict)
|
|
assert "timed out" in timeout_output["error"].lower()
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typed_programmatic_tool_preserves_output_guardrail_rejection() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("request rejected")],
|
|
]
|
|
)
|
|
|
|
@tool_output_guardrail
|
|
def reject_tool_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
|
|
return ToolGuardrailFunctionOutput.reject_content("inventory result blocked")
|
|
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
tool_output_guardrails=[reject_tool_output],
|
|
)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check inventory")
|
|
|
|
assert result.final_output == "request rejected"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
assert json.loads(function_outputs[0]["output"]) == {"error": "inventory result blocked"}
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("streaming", [False, True], ids=["nonstreaming", "streaming"])
|
|
@pytest.mark.parametrize("serialize_state", [False, True], ids=["in-memory", "serialized"])
|
|
@pytest.mark.parametrize(
|
|
"rejection_message",
|
|
[None, 'Denied: "東京"'],
|
|
ids=["default-rejection", "custom-rejection"],
|
|
)
|
|
async def test_typed_programmatic_tool_preserves_approval_rejection(
|
|
streaming: bool,
|
|
serialize_state: bool,
|
|
rejection_message: str | None,
|
|
) -> None:
|
|
outputs = [
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("request rejected")],
|
|
]
|
|
model = ScriptedModel(
|
|
[get_exact_output_stream_step(output) for output in outputs] if streaming else outputs
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"], needs_approval=True)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
first_result: Any
|
|
if streaming:
|
|
first_result = Runner.run_streamed(agent, "Check inventory")
|
|
async for _event in first_result.stream_events():
|
|
pass
|
|
else:
|
|
first_result = await Runner.run(agent, "Check inventory")
|
|
assert len(first_result.interruptions) == 1
|
|
|
|
state = first_result.to_state()
|
|
if serialize_state:
|
|
state = await RunState.from_json(agent, state.to_json())
|
|
state.reject(state.get_interruptions()[0], rejection_message=rejection_message)
|
|
result: Any
|
|
if streaming:
|
|
result = Runner.run_streamed(agent, state)
|
|
async for _event in result.stream_events():
|
|
pass
|
|
else:
|
|
result = await Runner.run(agent, state)
|
|
|
|
assert result.final_output == "request rejected"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
expected_message = rejection_message or "Tool execution was not approved."
|
|
assert json.loads(function_outputs[0]["output"]) == {"error": expected_message}
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
assert bool(model.calls)
|
|
replayed_output = cast(
|
|
dict[str, Any],
|
|
next(
|
|
item
|
|
for item in model.calls[-1].input
|
|
if isinstance(item, dict) and item.get("type") == "function_call_output"
|
|
),
|
|
)
|
|
assert json.loads(replayed_output["output"]) == {"error": expected_message}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuilt_mapping_programmatic_approval_preserves_caller() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"], needs_approval=True)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
first_result = await Runner.run(agent, "Check inventory")
|
|
state = first_result.to_state()
|
|
approval = state.get_interruptions()[0]
|
|
approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True)
|
|
assert state._last_processed_response is not None
|
|
state._last_processed_response.functions.clear()
|
|
state.approve(approval)
|
|
|
|
result = await Runner.run(agent, state)
|
|
|
|
assert executed is True
|
|
assert result.final_output == "done"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rebuilt_mapping_programmatic_approval_rechecks_caller_permissions() -> None:
|
|
model = ScriptedModel()
|
|
model.enqueue([_program(), _function_call()])
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"], needs_approval=True)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
first_result = await Runner.run(agent, "Check inventory")
|
|
state = first_result.to_state()
|
|
approval = state.get_interruptions()[0]
|
|
approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True)
|
|
lookup_inventory.allowed_callers = ["direct"]
|
|
assert state._last_processed_response is not None
|
|
state._last_processed_response.functions.clear()
|
|
state.approve(approval)
|
|
|
|
with pytest.raises(ModelBehaviorError, match="caller programmatic"):
|
|
await Runner.run(agent, state)
|
|
|
|
assert executed is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("parent_state", ["missing", "completed"])
|
|
async def test_rebuilt_programmatic_approval_requires_active_parent(parent_state: str) -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"], needs_approval=True)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
first_result = await Runner.run(agent, "Check inventory")
|
|
state = first_result.to_state()
|
|
approval = state.get_interruptions()[0]
|
|
approval.raw_item = cast(Any, approval.raw_item).model_dump(exclude_none=True)
|
|
assert state._last_processed_response is not None
|
|
state._last_processed_response.functions.clear()
|
|
assert state._model_responses
|
|
if parent_state == "missing":
|
|
state._generated_items = [
|
|
item for item in state._generated_items if _raw_item_type(item.raw_item) != "program"
|
|
]
|
|
state._last_processed_response.new_items = [
|
|
item
|
|
for item in state._last_processed_response.new_items
|
|
if _raw_item_type(item.raw_item) != "program"
|
|
]
|
|
state._model_responses[-1].output = [
|
|
item for item in state._model_responses[-1].output if _raw_item_type(item) != "program"
|
|
]
|
|
expected_error = "parent program item"
|
|
else:
|
|
state._model_responses[-1].output.append(_program_output())
|
|
expected_error = "already completed"
|
|
state.approve(approval)
|
|
|
|
with pytest.raises(ModelBehaviorError, match=expected_error):
|
|
await Runner.run(agent, state)
|
|
|
|
assert executed is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_typed_programmatic_tool_preserves_pre_approval_guardrail_rejection() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("request rejected")],
|
|
]
|
|
)
|
|
|
|
@tool_input_guardrail
|
|
def reject_tool_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
|
|
return ToolGuardrailFunctionOutput.reject_content("inventory lookup blocked")
|
|
|
|
@function_tool(
|
|
allowed_callers=["programmatic"],
|
|
needs_approval=True,
|
|
tool_input_guardrails=[reject_tool_input],
|
|
)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
run_config = RunConfig(
|
|
tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True)
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check inventory", run_config=run_config)
|
|
|
|
assert result.final_output == "request rejected"
|
|
function_outputs = _function_output_raw_items(result)
|
|
assert len(function_outputs) == 1
|
|
assert json.loads(function_outputs[0]["output"]) == {"error": "inventory lookup blocked"}
|
|
assert _caller_dict(function_outputs[0]["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_handles_multiple_pauses_from_one_program() -> None:
|
|
model = ScriptedModel()
|
|
second_call = ResponseFunctionToolCall(
|
|
id="function_item_2",
|
|
call_id="call_lookup_2",
|
|
name="lookup_inventory",
|
|
arguments='{"sku":"B-2"}',
|
|
caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID),
|
|
type="function_call",
|
|
)
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output("incomplete"), second_call],
|
|
[
|
|
ProgramOutput(
|
|
id="program_output_item_2",
|
|
call_id=PROGRAM_CALL_ID,
|
|
result='{"total":84}',
|
|
status="completed",
|
|
type="program_output",
|
|
),
|
|
get_text_message("84 units are available"),
|
|
],
|
|
]
|
|
)
|
|
|
|
calls: list[str] = []
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
calls.append(sku)
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
model_settings=ModelSettings(tool_choice="programmatic_tool_calling"),
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check two SKUs")
|
|
|
|
assert result.final_output == "84 units are available"
|
|
assert calls == ["A-1", "B-2"]
|
|
assert model.calls[-1].model_settings.tool_choice is None
|
|
assert len([item for item in result.new_items if isinstance(item, ToolCallOutputItem)]) == 4
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_executes_programmatic_batch_calls_concurrently() -> None:
|
|
model = ScriptedModel()
|
|
batch_calls = [
|
|
ResponseFunctionToolCall(
|
|
id=f"function_item_{index}",
|
|
call_id=f"call_lookup_{index}",
|
|
name="lookup_inventory",
|
|
arguments=json.dumps({"sku": f"SKU-{index}"}),
|
|
caller=CallerProgram(type="program", caller_id=PROGRAM_CALL_ID),
|
|
type="function_call",
|
|
)
|
|
for index in range(9)
|
|
]
|
|
model.extend(
|
|
[
|
|
[_program(), *batch_calls],
|
|
[_program_output(), get_text_message("batch complete")],
|
|
]
|
|
)
|
|
|
|
active_calls = 0
|
|
max_active_calls = 0
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
async def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal active_calls, max_active_calls
|
|
active_calls += 1
|
|
max_active_calls = max(max_active_calls, active_calls)
|
|
await asyncio.sleep(0.01)
|
|
active_calls -= 1
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check nine SKUs")
|
|
|
|
function_outputs = [
|
|
item
|
|
for item in result.new_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
and isinstance(item.raw_item, dict)
|
|
and item.raw_item.get("type") == "function_call_output"
|
|
]
|
|
assert result.final_output == "batch complete"
|
|
assert len(result.raw_responses) == 2
|
|
assert len(function_outputs) == 9
|
|
assert max_active_calls == 9
|
|
assert all(
|
|
_caller_dict(cast(dict[str, Any], item.raw_item)["caller"]) == PROGRAM_CALLER
|
|
for item in function_outputs
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_response_id_continuation_sends_only_program_function_output() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
|
|
result = await Runner.run(agent, "Check inventory", auto_previous_response_id=True)
|
|
|
|
assert result.final_output == "done"
|
|
assert model.calls[-1].previous_response_id == "resp-789"
|
|
last_input = model.calls[-1].input
|
|
assert isinstance(last_input, list)
|
|
assert len(last_input) == 1
|
|
function_output = cast(dict[str, Any], last_input[0])
|
|
assert function_output["type"] == "function_call_output"
|
|
assert _caller_dict(function_output["caller"]) == PROGRAM_CALLER
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
async def test_previous_response_id_continuation_accepts_server_owned_program_output(
|
|
streamed: bool,
|
|
) -> None:
|
|
output = [_program_output(), get_text_message("done")]
|
|
model = ScriptedModel([get_exact_output_stream_step(output) if streamed else output])
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
submitted_delta = [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": FUNCTION_CALL_ID,
|
|
"output": '{"sku":"A-1","available_units":42}',
|
|
"caller": PROGRAM_CALLER,
|
|
}
|
|
]
|
|
|
|
result: Any
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
cast(Any, submitted_delta),
|
|
previous_response_id="response_with_program_parent",
|
|
)
|
|
_events = [event async for event in result.stream_events()]
|
|
else:
|
|
result = await Runner.run(
|
|
agent,
|
|
cast(Any, submitted_delta),
|
|
previous_response_id="response_with_program_parent",
|
|
)
|
|
|
|
assert result.final_output == "done"
|
|
assert model.calls[-1].previous_response_id == "response_with_program_parent"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_previous_response_id_continuation_accepts_repeated_program_pause() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
submitted_delta = [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_previous_lookup",
|
|
"output": '{"sku":"A-0","available_units":21}',
|
|
"caller": PROGRAM_CALLER,
|
|
}
|
|
]
|
|
|
|
result = await Runner.run(
|
|
agent,
|
|
cast(Any, submitted_delta),
|
|
previous_response_id="response_with_program_parent",
|
|
)
|
|
|
|
assert executed is True
|
|
assert result.final_output == "done"
|
|
assert len(result.raw_responses) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("parent_source", ["caller", "incomplete_program_output"])
|
|
async def test_run_state_round_trip_preserves_server_owned_program_parent(
|
|
parent_source: str,
|
|
) -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
executed = False
|
|
|
|
@function_tool(allowed_callers=["programmatic"], needs_approval=True)
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
nonlocal executed
|
|
executed = True
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
if parent_source == "caller":
|
|
submitted_delta = [
|
|
{
|
|
"type": "function_call_output",
|
|
"call_id": "call_previous_lookup",
|
|
"output": '{"sku":"A-0","available_units":21}',
|
|
"caller": PROGRAM_CALLER,
|
|
}
|
|
]
|
|
else:
|
|
submitted_delta = [_program_output("incomplete").model_dump(exclude_none=True)]
|
|
|
|
first_result = await Runner.run(
|
|
agent,
|
|
cast(Any, submitted_delta),
|
|
previous_response_id="response_with_program_parent",
|
|
)
|
|
assert len(first_result.interruptions) == 1
|
|
|
|
restored_state = await RunState.from_json(agent, first_result.to_state().to_json())
|
|
approval = restored_state.get_interruptions()[0]
|
|
restored_state.approve(approval)
|
|
result = await Runner.run(agent, restored_state)
|
|
|
|
assert executed is True
|
|
assert result.final_output == "done"
|
|
assert model.calls[-1].previous_response_id == "resp-789"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sqlite_session_round_trip_preserves_program_history_and_caller() -> None:
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_text_message("done")],
|
|
]
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
agent = Agent(
|
|
name="inventory",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
session = SQLiteSession("programmatic-tool-calling")
|
|
try:
|
|
result = await Runner.run(agent, "Check inventory", session=session)
|
|
assert result.final_output == "done"
|
|
|
|
session_items = await session.get_items()
|
|
assert [_raw_item_type(item) for item in session_items] == [
|
|
None,
|
|
"program",
|
|
"function_call",
|
|
"function_call_output",
|
|
"program_output",
|
|
"message",
|
|
]
|
|
function_output = next(
|
|
cast(dict[str, Any], item)
|
|
for item in session_items
|
|
if _raw_item_type(item) == "function_call_output"
|
|
)
|
|
assert _caller_dict(function_output["caller"]) == PROGRAM_CALLER
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_nested_handoff_summarizes_complete_programmatic_transcript() -> None:
|
|
model = ScriptedModel()
|
|
delegate = Agent(name="delegate", model=model)
|
|
model.extend(
|
|
[
|
|
[_program(), _function_call()],
|
|
[_program_output(), get_handoff_tool_call(delegate)],
|
|
[get_text_message("done")],
|
|
]
|
|
)
|
|
|
|
@function_tool(allowed_callers=["programmatic"])
|
|
def lookup_inventory(sku: str) -> InventoryOutput:
|
|
return InventoryOutput(sku=sku, available_units=42)
|
|
|
|
triage = Agent(
|
|
name="triage",
|
|
model=model,
|
|
handoffs=[delegate],
|
|
tools=[ProgrammaticToolCallingTool(), lookup_inventory],
|
|
)
|
|
captured_inputs: list[list[Any]] = []
|
|
|
|
def capture_model_input(data: Any) -> Any:
|
|
captured_inputs.append(list(data.model_data.input))
|
|
return data.model_data
|
|
|
|
session = SQLiteSession("programmatic-tool-calling-nested-handoff")
|
|
try:
|
|
result = await Runner.run(
|
|
triage,
|
|
"Check inventory and delegate the final response.",
|
|
run_config=RunConfig(
|
|
nest_handoff_history=True,
|
|
call_model_input_filter=capture_model_input,
|
|
),
|
|
session=session,
|
|
)
|
|
|
|
assert result.final_output == "done"
|
|
handoff_input = captured_inputs[-1]
|
|
handoff_types = [_raw_item_type(item) for item in handoff_input]
|
|
assert not {
|
|
"program",
|
|
"function_call",
|
|
"function_call_output",
|
|
"program_output",
|
|
}.intersection(handoff_types)
|
|
|
|
summary_text = "\n".join(
|
|
cast(str, item.get("content"))
|
|
for item in handoff_input
|
|
if isinstance(item, dict) and isinstance(item.get("content"), str)
|
|
)
|
|
assert '"type": "program"' in summary_text
|
|
assert '"type": "function_call"' in summary_text
|
|
assert '"type": "function_call_output"' in summary_text
|
|
assert '"type": "program_output"' in summary_text
|
|
|
|
session_items = await session.get_items()
|
|
assert [_raw_item_type(item) for item in session_items] == [
|
|
None,
|
|
"program",
|
|
"function_call",
|
|
"function_call_output",
|
|
"program_output",
|
|
"function_call",
|
|
"function_call_output",
|
|
"message",
|
|
]
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_function_programmatic_outputs_preserve_caller() -> None:
|
|
caller = cast(Any, PROGRAM_CALLER)
|
|
|
|
async def run_tool(tool: Any, tool_call: Any) -> dict[str, Any]:
|
|
model = ScriptedModel()
|
|
model.extend([[_program(), tool_call], [get_text_message("done")]])
|
|
agent = Agent(
|
|
name="tool agent",
|
|
model=model,
|
|
tools=[ProgrammaticToolCallingTool(), tool],
|
|
)
|
|
result = await Runner.run(agent, "Run the tool")
|
|
return next(
|
|
cast(dict[str, Any], item.raw_item)
|
|
for item in result.new_items
|
|
if isinstance(item, ToolCallOutputItem)
|
|
)
|
|
|
|
async def shell_executor(_request: Any) -> str:
|
|
return "shell done"
|
|
|
|
shell_output = await run_tool(
|
|
ShellTool(executor=shell_executor, allowed_callers=["programmatic"]),
|
|
ResponseFunctionShellToolCall(
|
|
id="shell_item",
|
|
call_id="call_shell",
|
|
action=Action(commands=["echo ok"]),
|
|
status="completed",
|
|
type="shell_call",
|
|
caller=caller,
|
|
),
|
|
)
|
|
assert _caller_dict(shell_output["caller"]) == PROGRAM_CALLER
|
|
|
|
custom_output = await run_tool(
|
|
CustomTool(
|
|
name="custom",
|
|
description="Custom tool",
|
|
on_invoke_tool=lambda _context, _input: "custom done",
|
|
allowed_callers=["programmatic"],
|
|
),
|
|
ResponseCustomToolCall(
|
|
id="custom_item",
|
|
call_id="call_custom",
|
|
input="input",
|
|
name="custom",
|
|
type="custom_tool_call",
|
|
caller=caller,
|
|
),
|
|
)
|
|
assert _caller_dict(custom_output["caller"]) == PROGRAM_CALLER
|
|
|
|
class Editor:
|
|
def create_file(self, _operation: Any) -> str:
|
|
return "patch done"
|
|
|
|
def update_file(self, _operation: Any) -> str:
|
|
return "patch done"
|
|
|
|
def delete_file(self, _operation: Any) -> str:
|
|
return "patch done"
|
|
|
|
apply_patch_output = await run_tool(
|
|
ApplyPatchTool(editor=Editor(), allowed_callers=["programmatic"]),
|
|
{
|
|
"id": "apply_patch_item",
|
|
"call_id": "call_apply_patch",
|
|
"operation": {"type": "create_file", "path": "example.txt", "diff": "hello"},
|
|
"status": "completed",
|
|
"type": "apply_patch_call",
|
|
"caller": caller,
|
|
},
|
|
)
|
|
assert _caller_dict(apply_patch_output["caller"]) == PROGRAM_CALLER
|