Files
openai--openai-agents-python/tests/test_agent_runner.py
T
2026-07-13 12:39:17 +08:00

5308 lines
170 KiB
Python

from __future__ import annotations
import asyncio
import json
import tempfile
import warnings
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
from unittest.mock import patch
import httpx
import pytest
from openai import APIConnectionError, BadRequestError
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses.response_output_text import AnnotationFileCitation, ResponseOutputText
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
from typing_extensions import TypedDict
from agents import (
Agent,
GuardrailFunctionOutput,
Handoff,
HandoffInputData,
InputGuardrail,
InputGuardrailTripwireTriggered,
ModelBehaviorError,
ModelRetryAdvice,
ModelRetrySettings,
ModelSettings,
OpenAIConversationsSession,
OutputGuardrail,
OutputGuardrailTripwireTriggered,
RunConfig,
RunContextWrapper,
Runner,
SQLiteSession,
ToolExecutionConfig,
ToolGuardrailFunctionOutput,
ToolInputGuardrailData,
ToolTimeoutError,
UserError,
handoff,
retry_policies,
tool_input_guardrail,
tool_namespace,
)
from agents.agent import ToolsToFinalOutputResult
from agents.computer import Computer
from agents.items import (
HandoffOutputItem,
ModelResponse,
ReasoningItem,
RunItem,
ToolApprovalItem,
ToolCallItem,
ToolCallOutputItem,
TResponseInputItem,
)
from agents.lifecycle import RunHooks
from agents.run import AgentRunner, get_default_agent_runner, set_default_agent_runner
from agents.run_config import _default_trace_include_sensitive_data
from agents.run_internal.agent_bindings import bind_public_agent
from agents.run_internal.items import (
TOOL_CALL_SESSION_DESCRIPTION_KEY,
TOOL_CALL_SESSION_TITLE_KEY,
drop_orphan_function_calls,
ensure_input_item_format,
fingerprint_input_item,
normalize_input_items_for_api,
normalize_resumed_input,
)
from agents.run_internal.oai_conversation import OpenAIServerConversationTracker
from agents.run_internal.run_loop import get_new_response
from agents.run_internal.run_steps import NextStepFinalOutput, SingleStepResult
from agents.run_internal.session_persistence import (
_collect_retry_owned_tail_serializations,
persist_session_items_for_guardrail_trip,
prepare_input_with_session,
rewind_session_items,
save_result_to_session,
wait_for_session_cleanup,
)
from agents.run_internal.tool_execution import execute_approved_tools
from agents.run_internal.tool_use_tracker import AgentToolUseTracker
from agents.run_state import RunState
from agents.tool import ComputerTool, FunctionToolResult, ShellTool, function_tool
from agents.tool_context import ToolContext
from agents.usage import Usage
from .fake_model import FakeModel
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 .utils.factories import make_run_state
from .utils.hitl import make_context_wrapper, make_model_and_agent, make_shell_call
from .utils.simple_session import CountingSession, IdStrippingSession, SimpleListSession
class _DummyRunItem:
def __init__(self, payload: dict[str, Any], item_type: str = "tool_call_output_item"):
self._payload = payload
self.type = item_type
def to_input_item(self) -> dict[str, Any]:
return self._payload
async def run_execute_approved_tools(
agent: Agent[Any],
approval_item: ToolApprovalItem,
*,
approve: bool | None,
run_config: RunConfig | None = None,
mutate_state: Callable[[RunState[Any, Agent[Any]], ToolApprovalItem], None] | None = None,
) -> list[RunItem]:
"""Execute approved tools with a consistent setup."""
context_wrapper: RunContextWrapper[Any] = make_context_wrapper()
state = make_run_state(
agent,
context=context_wrapper,
original_input="test",
max_turns=1,
)
if approve is True:
state.approve(approval_item)
elif approve is False:
state.reject(approval_item)
if mutate_state is not None:
mutate_state(state, approval_item)
generated_items: list[RunItem] = []
all_tools = await agent.get_all_tools(context_wrapper)
await execute_approved_tools(
agent=agent,
interruptions=[approval_item],
context_wrapper=context_wrapper,
generated_items=generated_items,
run_config=run_config or RunConfig(),
hooks=RunHooks(),
all_tools=all_tools,
)
return generated_items
async def _run_agent_with_optional_streaming(
agent: Agent[Any],
*,
input: str | list[TResponseInputItem],
streamed: bool,
**kwargs: Any,
):
if streamed:
result = Runner.run_streamed(agent, input=input, **kwargs)
async for _ in result.stream_events():
pass
return result
return await Runner.run(agent, input=input, **kwargs)
def test_set_default_agent_runner_roundtrip():
runner = AgentRunner()
set_default_agent_runner(runner)
assert get_default_agent_runner() is runner
# Reset to ensure other tests are unaffected.
set_default_agent_runner(None)
assert isinstance(get_default_agent_runner(), AgentRunner)
def test_run_streamed_preserves_legacy_positional_previous_response_id():
captured: dict[str, Any] = {}
class DummyRunner:
def run_streamed(self, starting_agent: Any, input: Any, **kwargs: Any):
captured.update(kwargs)
return object()
original_runner = get_default_agent_runner()
set_default_agent_runner(cast(Any, DummyRunner()))
try:
Runner.run_streamed(
cast(Any, None),
"hello",
None,
10,
None,
None,
"resp-legacy",
)
finally:
set_default_agent_runner(original_runner)
assert captured["previous_response_id"] == "resp-legacy"
assert captured["error_handlers"] is None
def test_default_trace_include_sensitive_data_env(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA", "false")
assert _default_trace_include_sensitive_data() is False
monkeypatch.setenv("OPENAI_AGENTS_TRACE_INCLUDE_SENSITIVE_DATA", "TRUE")
assert _default_trace_include_sensitive_data() is True
def test_run_config_defaults_nested_handoff_history_opt_in():
assert RunConfig().nest_handoff_history is False
def testdrop_orphan_function_calls_removes_orphans():
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_orphan",
"name": "tool_one",
"arguments": "{}",
},
),
cast(TResponseInputItem, {"type": "message", "role": "user", "content": "hello"}),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_keep",
"name": "tool_keep",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "call_keep", "output": "done"},
),
cast(TResponseInputItem, {"type": "shell_call", "call_id": "shell_orphan"}),
cast(TResponseInputItem, {"type": "shell_call", "call_id": "shell_keep"}),
cast(
TResponseInputItem,
{"type": "shell_call_output", "call_id": "shell_keep", "output": []},
),
cast(TResponseInputItem, {"type": "apply_patch_call", "call_id": "patch_orphan"}),
cast(TResponseInputItem, {"type": "apply_patch_call", "call_id": "patch_keep"}),
cast(
TResponseInputItem,
{"type": "apply_patch_call_output", "call_id": "patch_keep", "output": "done"},
),
cast(TResponseInputItem, {"type": "computer_call", "call_id": "computer_orphan"}),
cast(TResponseInputItem, {"type": "computer_call", "call_id": "computer_keep"}),
cast(
TResponseInputItem,
{"type": "computer_call_output", "call_id": "computer_keep", "output": {}},
),
cast(TResponseInputItem, {"type": "local_shell_call", "call_id": "local_shell_orphan"}),
cast(TResponseInputItem, {"type": "local_shell_call", "call_id": "local_shell_keep"}),
cast(
TResponseInputItem,
{
"type": "local_shell_call_output",
"call_id": "local_shell_keep",
"output": {"stdout": "", "stderr": "", "outcome": {}},
},
),
]
filtered = drop_orphan_function_calls(items)
orphan_call_ids = {
"call_orphan",
"shell_orphan",
"patch_orphan",
"computer_orphan",
"local_shell_orphan",
}
for entry in filtered:
if isinstance(entry, dict):
assert entry.get("call_id") not in orphan_call_ids
def _has_call(call_type: str, call_id: str) -> bool:
return any(
isinstance(entry, dict)
and entry.get("type") == call_type
and entry.get("call_id") == call_id
for entry in filtered
)
assert _has_call("function_call", "call_keep")
assert _has_call("shell_call", "shell_keep")
assert _has_call("apply_patch_call", "patch_keep")
assert _has_call("computer_call", "computer_keep")
assert _has_call("local_shell_call", "local_shell_keep")
def test_normalize_resumed_input_drops_orphan_function_calls():
raw_input: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "tool_orphan",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "paired_call",
"name": "tool_paired",
"arguments": "{}",
},
),
cast(
TResponseInputItem,
{"type": "function_call_output", "call_id": "paired_call", "output": "ok"},
),
]
normalized = normalize_resumed_input(raw_input)
assert isinstance(normalized, list)
call_ids = [
cast(dict[str, Any], item).get("call_id")
for item in normalized
if isinstance(item, dict) and item.get("type") == "function_call"
]
assert "orphan_call" not in call_ids
assert "paired_call" in call_ids
def test_normalize_resumed_input_drops_orphan_tool_search_calls():
raw_input: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": "orphan_search",
"arguments": {"query": "orphan"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": "paired_search",
"arguments": {"query": "paired"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": "paired_search",
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
normalized = normalize_resumed_input(raw_input)
assert isinstance(normalized, list)
call_ids = [
cast(dict[str, Any], item).get("call_id")
for item in normalized
if isinstance(item, dict) and item.get("type") == "tool_search_call"
]
assert "orphan_search" not in call_ids
assert "paired_search" in call_ids
def test_normalize_resumed_input_preserves_hosted_tool_search_pair_without_call_ids():
raw_input: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "paired"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": None,
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
normalized = normalize_resumed_input(raw_input)
assert isinstance(normalized, list)
assert [cast(dict[str, Any], item)["type"] for item in normalized] == [
"tool_search_call",
"tool_search_output",
]
def test_normalize_resumed_input_matches_latest_anonymous_tool_search_call():
raw_input: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "orphan"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_call",
"call_id": None,
"arguments": {"query": "paired"},
"execution": "server",
"status": "completed",
},
),
cast(
TResponseInputItem,
{
"type": "tool_search_output",
"call_id": None,
"execution": "server",
"status": "completed",
"tools": [],
},
),
]
normalized = normalize_resumed_input(raw_input)
assert isinstance(normalized, list)
assert [cast(dict[str, Any], item)["type"] for item in normalized] == [
"tool_search_call",
"tool_search_output",
]
assert cast(dict[str, Any], normalized[0])["arguments"] == {"query": "paired"}
def testnormalize_input_items_for_api_preserves_provider_data():
items: list[TResponseInputItem] = [
cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_norm",
"status": "completed",
"output": "out",
"provider_data": {"trace": "keep"},
},
),
cast(
TResponseInputItem,
{
"type": "message",
"role": "user",
"content": "hi",
"provider_data": {"trace": "remove"},
},
),
]
normalized = normalize_input_items_for_api(items)
first = cast(dict[str, Any], normalized[0])
second = cast(dict[str, Any], normalized[1])
assert first["type"] == "function_call_output"
assert first["call_id"] == "call_norm"
assert first["provider_data"] == {"trace": "keep"}
assert second["role"] == "user"
assert second["provider_data"] == {"trace": "remove"}
def test_fingerprint_input_item_returns_none_when_model_dump_fails():
class _BrokenModelDump:
def model_dump(self, *_args: Any, **_kwargs: Any) -> dict[str, Any]:
raise RuntimeError("model_dump failed")
assert fingerprint_input_item(_BrokenModelDump()) is None
def test_server_conversation_tracker_tracks_previous_response_id():
tracker = OpenAIServerConversationTracker(conversation_id=None, previous_response_id="resp_a")
response = ModelResponse(
output=[get_text_message("hello")],
usage=Usage(),
response_id="resp_b",
)
tracker.track_server_items(response)
assert tracker.previous_response_id == "resp_b"
assert len(tracker.server_items) == 1
def _as_message(item: Any) -> dict[str, Any]:
assert isinstance(item, dict)
role = item.get("role")
assert isinstance(role, str)
assert role in {"assistant", "user", "system", "developer"}
return cast(dict[str, Any], item)
def _find_reasoning_input_item(
items: str | list[TResponseInputItem] | Any,
) -> dict[str, Any] | None:
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, dict) and item.get("type") == "reasoning":
return cast(dict[str, Any], item)
return None
@pytest.mark.asyncio
async def test_simple_first_run():
model = FakeModel()
agent = Agent(
name="test",
model=model,
)
model.set_next_output([get_text_message("first")])
result = await Runner.run(agent, input="test")
assert result.input == "test"
assert len(result.new_items) == 1, "exactly one item should be generated"
assert result.final_output == "first"
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
assert result.raw_responses[0].output == [get_text_message("first")]
assert result.last_agent == agent
assert len(result.to_input_list()) == 2, "should have original input and generated item"
model.set_next_output([get_text_message("second")])
result = await Runner.run(
agent, input=[get_text_input_item("message"), get_text_input_item("another_message")]
)
assert len(result.new_items) == 1, "exactly one item should be generated"
assert result.final_output == "second"
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
assert len(result.to_input_list()) == 3, "should have original input and generated item"
@pytest.mark.asyncio
async def test_subsequent_runs():
model = FakeModel()
agent = Agent(
name="test",
model=model,
)
model.set_next_output([get_text_message("third")])
result = await Runner.run(agent, input="test")
assert result.input == "test"
assert len(result.new_items) == 1, "exactly one item should be generated"
assert len(result.to_input_list()) == 2, "should have original input and generated item"
model.set_next_output([get_text_message("fourth")])
result = await Runner.run(agent, input=result.to_input_list())
assert len(result.input) == 2, f"should have previous input but got {result.input}"
assert len(result.new_items) == 1, "exactly one item should be generated"
assert result.final_output == "fourth"
assert len(result.raw_responses) == 1, "exactly one model response should be generated"
assert result.raw_responses[0].output == [get_text_message("fourth")]
assert result.last_agent == agent
assert len(result.to_input_list()) == 3, "should have original input and generated items"
@pytest.mark.asyncio
async def test_tool_call_runs():
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
# Second turn: text message
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == "done"
assert len(result.raw_responses) == 2, (
"should have two responses: the first which produces a tool call, and the second which"
"handles the tool result"
)
assert len(result.to_input_list()) == 5, (
"should have five inputs: the original input, the message, the tool call, the tool result "
"and the done message"
)
@pytest.mark.asyncio
async def test_parallel_tool_call_with_cancelled_sibling_reaches_final_output() -> None:
async def _ok_tool() -> str:
return "ok"
async def _cancel_tool() -> str:
raise asyncio.CancelledError("tool-cancelled")
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[
function_tool(_ok_tool, name_override="ok_tool"),
function_tool(_cancel_tool, name_override="cancel_tool"),
],
)
model.add_multiple_turn_outputs(
[
[
get_function_tool_call("ok_tool", "{}", call_id="call_ok"),
get_function_tool_call("cancel_tool", "{}", call_id="call_cancel"),
],
[get_text_message("final answer")],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == "final answer"
assert len(result.raw_responses) == 2
second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"])
tool_outputs = [
item for item in second_turn_input if item.get("type") == "function_call_output"
]
assert tool_outputs == [
{"call_id": "call_ok", "output": "ok", "type": "function_call_output"},
{
"call_id": "call_cancel",
"output": (
"An error occurred while running the tool. Please try again. Error: tool-cancelled"
),
"type": "function_call_output",
},
]
@pytest.mark.asyncio
async def test_single_tool_call_with_cancelled_tool_reaches_final_output() -> None:
async def _cancel_tool() -> str:
raise asyncio.CancelledError("tool-cancelled")
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[function_tool(_cancel_tool, name_override="cancel_tool")],
)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("cancel_tool", "{}", call_id="call_cancel")],
[get_text_message("final answer")],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == "final answer"
assert len(result.raw_responses) == 2
second_turn_input = cast(list[dict[str, Any]], model.last_turn_args["input"])
tool_outputs = [
item for item in second_turn_input if item.get("type") == "function_call_output"
]
assert tool_outputs == [
{
"call_id": "call_cancel",
"output": (
"An error occurred while running the tool. Please try again. Error: tool-cancelled"
),
"type": "function_call_output",
},
]
@pytest.mark.asyncio
async def test_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None:
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.add_multiple_turn_outputs(
[
[
ResponseReasoningItem(
id="rs_first",
type="reasoning",
summary=[Summary(text="Thinking...", type="summary_text")],
),
get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call_first"),
],
[get_text_message("done")],
]
)
result = await Runner.run(
agent,
input="hello",
run_config=RunConfig(reasoning_item_id_policy="omit"),
)
assert result.final_output == "done"
second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input"))
assert second_request_reasoning is not None
assert "id" not in second_request_reasoning
history_reasoning = _find_reasoning_input_item(result.to_input_list())
assert history_reasoning is not None
assert "id" not in history_reasoning
@pytest.mark.asyncio
async def test_call_model_input_filter_can_reintroduce_reasoning_ids() -> None:
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.add_multiple_turn_outputs(
[
[
ResponseReasoningItem(
id="rs_filter",
type="reasoning",
summary=[Summary(text="Thinking...", type="summary_text")],
),
get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call_filter"),
],
[get_text_message("done")],
]
)
def reintroduce_reasoning_id(data: Any) -> Any:
updated_input: list[TResponseInputItem] = []
for item in data.model_data.input:
if isinstance(item, dict) and item.get("type") == "reasoning" and "id" not in item:
updated_input.append(cast(TResponseInputItem, {**item, "id": "rs_reintroduced"}))
else:
updated_input.append(item)
data.model_data.input = updated_input
return data.model_data
result = await Runner.run(
agent,
input="hello",
run_config=RunConfig(
reasoning_item_id_policy="omit",
call_model_input_filter=reintroduce_reasoning_id,
),
)
assert result.final_output == "done"
second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input"))
assert second_request_reasoning is not None
assert second_request_reasoning.get("id") == "rs_reintroduced"
history_reasoning = _find_reasoning_input_item(result.to_input_list())
assert history_reasoning is not None
assert "id" not in history_reasoning
@pytest.mark.asyncio
async def test_resumed_run_uses_serialized_reasoning_item_id_policy() -> None:
model = FakeModel()
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
return "ok"
agent = Agent(
name="test",
model=model,
tools=[approval_tool],
)
model.add_multiple_turn_outputs(
[
[
ResponseReasoningItem(
id="rs_resume",
type="reasoning",
summary=[Summary(text="Thinking...", type="summary_text")],
),
get_function_tool_call(
"approval_tool",
json.dumps({}),
call_id="call_resume",
),
],
[get_text_message("done")],
]
)
first_run = await Runner.run(
agent,
input="hello",
run_config=RunConfig(reasoning_item_id_policy="omit"),
)
assert len(first_run.interruptions) == 1
state = first_run.to_state()
state.approve(first_run.interruptions[0])
restored_state = await RunState.from_string(agent, state.to_string())
resumed = await Runner.run(agent, restored_state)
assert resumed.final_output == "done"
second_request_reasoning = _find_reasoning_input_item(model.last_turn_args.get("input"))
assert second_request_reasoning is not None
assert "id" not in second_request_reasoning
@pytest.mark.asyncio
async def test_pending_approval_skips_tool_input_guardrails_by_default() -> None:
model = FakeModel()
guardrail_runs = 0
@tool_input_guardrail
def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
nonlocal guardrail_runs
guardrail_runs += 1
return ToolGuardrailFunctionOutput.allow()
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[count_guardrail],
)
def approval_tool() -> str:
return "ok"
agent = Agent(name="test", model=model, tools=[approval_tool])
model.set_next_output([get_function_tool_call("approval_tool", "{}", call_id="call_default")])
result = await Runner.run(agent, "hello")
assert len(result.interruptions) == 1
assert guardrail_runs == 0
assert result.tool_input_guardrail_results == []
@pytest.mark.asyncio
async def test_pre_approval_tool_input_guardrails_can_reject_before_pending_approval() -> None:
model = FakeModel()
executed = False
@tool_input_guardrail
def reject_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.reject_content("blocked before approval")
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[reject_guardrail],
)
def approval_tool() -> str:
nonlocal executed
executed = True
return "ok"
agent = Agent(name="test", model=model, tools=[approval_tool])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("approval_tool", "{}", call_id="call_reject")],
[get_text_message("done")],
]
)
result = await Runner.run(
agent,
"hello",
run_config=RunConfig(
tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True)
),
)
assert result.final_output == "done"
assert result.interruptions == []
assert executed is False
assert len(result.tool_input_guardrail_results) == 1
assert any(
isinstance(item, ToolCallOutputItem) and item.output == "blocked before approval"
for item in result.new_items
)
@pytest.mark.asyncio
async def test_pre_approval_tool_input_guardrails_rerun_after_resume() -> None:
model = FakeModel()
guardrail_runs = 0
executed = 0
@tool_input_guardrail
def count_guardrail(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
nonlocal guardrail_runs
guardrail_runs += 1
return ToolGuardrailFunctionOutput.allow()
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[count_guardrail],
)
def approval_tool() -> str:
nonlocal executed
executed += 1
return "ok"
agent = Agent(name="test", model=model, tools=[approval_tool])
model.add_multiple_turn_outputs(
[
[get_function_tool_call("approval_tool", "{}", call_id="call_resume")],
[get_text_message("done")],
]
)
run_config = RunConfig(
tool_execution=ToolExecutionConfig(pre_approval_tool_input_guardrails=True)
)
first = await Runner.run(agent, "hello", run_config=run_config)
assert len(first.interruptions) == 1
assert guardrail_runs == 1
assert executed == 0
assert len(first.tool_input_guardrail_results) == 1
state = first.to_state()
state.approve(first.interruptions[0])
restored_state = await RunState.from_string(agent, state.to_string())
resumed = await Runner.run(agent, restored_state, run_config=run_config)
assert resumed.final_output == "done"
assert guardrail_runs == 2
assert executed == 1
assert len(resumed.tool_input_guardrail_results) == 1
@pytest.mark.asyncio
async def test_tool_call_context_includes_current_agent() -> None:
model = FakeModel()
captured_contexts: list[ToolContext[Any]] = []
@function_tool(name_override="foo")
def foo(context: ToolContext[Any]) -> str:
captured_contexts.append(context)
return "tool_result"
agent = Agent(
name="test",
model=model,
tools=[foo],
)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("foo", "{}")],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == "done"
assert len(captured_contexts) == 1
assert captured_contexts[0].agent is agent
@pytest.mark.asyncio
async def test_handoffs():
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
agent_2 = Agent(
name="test",
model=model,
)
agent_3 = Agent(
name="test",
model=model,
handoffs=[agent_1, agent_2],
tools=[get_function_tool("some_function", "result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a tool call
[get_function_tool_call("some_function", json.dumps({"a": "b"}))],
# Second turn: a message and a handoff
[get_text_message("a_message"), get_handoff_tool_call(agent_1)],
# Third turn: text message
[get_text_message("done")],
]
)
result = await Runner.run(agent_3, input="user_message")
assert result.final_output == "done"
assert len(result.raw_responses) == 3, "should have three model responses"
assert len(result.to_input_list()) == 7, (
"should have 7 inputs: summary message, tool call, tool result, message, handoff, "
"handoff result, and done message"
)
assert result.last_agent == agent_1, "should have handed off to agent_1"
@pytest.mark.asyncio
async def test_nested_handoff_filters_model_input_but_preserves_session_items():
model = FakeModel()
delegate = Agent(
name="delegate",
model=model,
)
triage = Agent(
name="triage",
model=model,
handoffs=[delegate],
tools=[get_function_tool("some_function", "result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a tool call.
[get_function_tool_call("some_function", json.dumps({"a": "b"}))],
# Second turn: a message and a handoff.
[get_text_message("a_message"), get_handoff_tool_call(delegate)],
# Third turn: final message.
[get_text_message("done")],
]
)
model_input_types: list[list[str]] = []
def capture_model_input(data):
types: list[str] = []
for item in data.model_data.input:
if isinstance(item, dict):
item_type = item.get("type")
if isinstance(item_type, str):
types.append(item_type)
model_input_types.append(types)
return data.model_data
session = SimpleListSession()
result = await Runner.run(
triage,
input="user_message",
run_config=RunConfig(
nest_handoff_history=True,
call_model_input_filter=capture_model_input,
),
session=session,
)
assert result.final_output == "done"
assert len(model_input_types) >= 3
handoff_input_types = model_input_types[2]
assert "function_call" not in handoff_input_types
assert "function_call_output" not in handoff_input_types
assert any(isinstance(item, ToolCallOutputItem) for item in result.new_items)
assert any(isinstance(item, HandoffOutputItem) for item in result.new_items)
session_items = await session.get_items()
has_function_call_output = any(
isinstance(item, dict) and item.get("type") == "function_call_output"
for item in session_items
)
assert has_function_call_output
@pytest.mark.asyncio
async def test_nested_handoff_filters_reasoning_items_from_model_input():
model = FakeModel()
delegate = Agent(
name="delegate",
model=model,
)
triage = Agent(
name="triage",
model=model,
handoffs=[delegate],
)
model.add_multiple_turn_outputs(
[
[
ResponseReasoningItem(
id="reasoning_1",
type="reasoning",
summary=[Summary(text="Thinking about a handoff.", type="summary_text")],
),
get_handoff_tool_call(delegate),
],
[get_text_message("done")],
]
)
captured_inputs: list[list[dict[str, Any]]] = []
def capture_model_input(data):
if isinstance(data.model_data.input, list):
captured_inputs.append(
[item for item in data.model_data.input if isinstance(item, dict)]
)
return data.model_data
result = await Runner.run(
triage,
input="user_message",
run_config=RunConfig(
nest_handoff_history=True,
call_model_input_filter=capture_model_input,
),
)
assert result.final_output == "done"
assert len(captured_inputs) >= 2
handoff_input = captured_inputs[1]
handoff_input_types = [
item["type"] for item in handoff_input if isinstance(item.get("type"), str)
]
assert "reasoning" not in handoff_input_types
@pytest.mark.asyncio
async def test_resume_preserves_filtered_model_input_after_handoff():
model = FakeModel()
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
return "ok"
delegate = Agent(
name="delegate",
model=model,
tools=[approval_tool],
)
triage = Agent(
name="triage",
model=model,
handoffs=[delegate],
tools=[get_function_tool("some_function", "result")],
)
model.add_multiple_turn_outputs(
[
[
get_function_tool_call(
"some_function", json.dumps({"a": "b"}), call_id="triage-call"
)
],
[get_text_message("a_message"), get_handoff_tool_call(delegate)],
[get_function_tool_call("approval_tool", json.dumps({}), call_id="delegate-call")],
[get_text_message("done")],
]
)
model_input_call_ids: list[set[str]] = []
model_input_output_call_ids: list[set[str]] = []
def capture_model_input(data):
call_ids: set[str] = set()
output_call_ids: set[str] = set()
for item in data.model_data.input:
if not isinstance(item, dict):
continue
item_type = item.get("type")
call_id = item.get("call_id")
if not isinstance(call_id, str):
continue
if item_type == "function_call":
call_ids.add(call_id)
elif item_type == "function_call_output":
output_call_ids.add(call_id)
model_input_call_ids.append(call_ids)
model_input_output_call_ids.append(output_call_ids)
return data.model_data
run_config = RunConfig(
nest_handoff_history=True,
call_model_input_filter=capture_model_input,
)
first = await Runner.run(triage, input="user_message", run_config=run_config)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = await Runner.run(triage, state, run_config=run_config)
last_call_ids = model_input_call_ids[-1]
last_output_call_ids = model_input_output_call_ids[-1]
assert "triage-call" not in last_call_ids
assert "triage-call" not in last_output_call_ids
assert "delegate-call" in last_call_ids
assert "delegate-call" in last_output_call_ids
assert resumed.final_output == "done"
@pytest.mark.asyncio
async def test_resumed_state_updates_agent_after_handoff() -> None:
model = FakeModel()
@function_tool(name_override="triage_tool", needs_approval=True)
def triage_tool() -> str:
return "ok"
@function_tool(name_override="delegate_tool", needs_approval=True)
def delegate_tool() -> str:
return "ok"
delegate = Agent(
name="delegate",
model=model,
tools=[delegate_tool],
)
triage = Agent(
name="triage",
model=model,
handoffs=[delegate],
tools=[triage_tool],
)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("triage_tool", "{}", call_id="triage-1")],
[get_text_message("handoff"), get_handoff_tool_call(delegate)],
[get_function_tool_call("delegate_tool", "{}", call_id="delegate-1")],
]
)
first = await Runner.run(triage, input="user_message")
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
second = await Runner.run(triage, state)
assert second.interruptions
assert any(item.tool_name == delegate_tool.name for item in second.interruptions), (
"handoff should switch approvals to the delegate agent"
)
assert state._current_agent is delegate
class Foo(TypedDict):
bar: str
@pytest.mark.asyncio
async def test_structured_output():
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
tools=[get_function_tool("bar", "bar_result")],
output_type=Foo,
)
agent_2 = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "foo_result")],
handoffs=[agent_1],
)
model.add_multiple_turn_outputs(
[
# First turn: a tool call
[get_function_tool_call("foo", json.dumps({"bar": "baz"}))],
# Second turn: a message and a handoff
[get_text_message("a_message"), get_handoff_tool_call(agent_1)],
# Third turn: tool call with preamble message
[
get_text_message(json.dumps(Foo(bar="preamble"))),
get_function_tool_call("bar", json.dumps({"bar": "baz"})),
],
# Fourth turn: structured output
[get_final_output_message(json.dumps(Foo(bar="baz")))],
]
)
result = await Runner.run(
agent_2,
input=[
get_text_input_item("user_message"),
get_text_input_item("another_message"),
],
run_config=RunConfig(nest_handoff_history=True),
)
assert result.final_output == Foo(bar="baz")
assert len(result.raw_responses) == 4, "should have four model responses"
assert len(result.to_input_list()) == 10, (
"should have input: conversation summary, function call, function call result, message, "
"handoff, handoff output, preamble message, tool call, tool call result, final output"
)
assert len(result.to_input_list(mode="normalized")) == 6, (
"should have normalized replay input: conversation summary, carried-forward message, "
"preamble message, tool call, tool call result, final output"
)
assert result.last_agent == agent_1, "should have handed off to agent_1"
assert result.final_output == Foo(bar="baz"), "should have structured output"
def remove_new_items(handoff_input_data: HandoffInputData) -> HandoffInputData:
return HandoffInputData(
input_history=handoff_input_data.input_history,
pre_handoff_items=(),
new_items=(),
run_context=handoff_input_data.run_context,
)
@pytest.mark.asyncio
async def test_handoff_filters():
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
agent_2 = Agent(
name="test",
model=model,
handoffs=[
handoff(
agent=agent_1,
input_filter=remove_new_items,
)
],
)
model.add_multiple_turn_outputs(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
result = await Runner.run(agent_2, input="user_message")
assert result.final_output == "last"
assert len(result.raw_responses) == 2, "should have two model responses"
assert len(result.to_input_list()) == 2, (
"should only have 2 inputs: orig input and last message"
)
@pytest.mark.asyncio
async def test_opt_in_handoff_history_nested_and_filters_respected():
model = FakeModel()
agent_1 = Agent(
name="delegate",
model=model,
)
agent_2 = Agent(
name="triage",
model=model,
handoffs=[agent_1],
)
model.add_multiple_turn_outputs(
[
[get_text_message("triage summary"), get_handoff_tool_call(agent_1)],
[get_text_message("resolution")],
]
)
result = await Runner.run(
agent_2,
input="user_message",
run_config=RunConfig(nest_handoff_history=True),
)
assert isinstance(result.input, list)
assert len(result.input) == 1
summary = _as_message(result.input[0])
assert summary["role"] == "assistant"
summary_content = summary["content"]
assert isinstance(summary_content, str)
assert "<CONVERSATION HISTORY>" in summary_content
assert "triage summary" in summary_content
assert "user_message" in summary_content
passthrough_model = FakeModel()
delegate = Agent(name="delegate", model=passthrough_model)
def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
return data
triage_with_filter = Agent(
name="triage",
model=passthrough_model,
handoffs=[handoff(delegate, input_filter=passthrough_filter)],
)
passthrough_model.add_multiple_turn_outputs(
[
[get_text_message("triage summary"), get_handoff_tool_call(delegate)],
[get_text_message("resolution")],
]
)
filtered_result = await Runner.run(
triage_with_filter,
input="user_message",
run_config=RunConfig(nest_handoff_history=True),
)
assert isinstance(filtered_result.input, str)
assert filtered_result.input == "user_message"
@pytest.mark.asyncio
async def test_opt_in_handoff_history_accumulates_across_multiple_handoffs():
triage_model = FakeModel()
delegate_model = FakeModel()
closer_model = FakeModel()
closer = Agent(name="closer", model=closer_model)
delegate = Agent(name="delegate", model=delegate_model, handoffs=[closer])
triage = Agent(name="triage", model=triage_model, handoffs=[delegate])
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs(
[[get_text_message("delegate update"), get_handoff_tool_call(closer)]]
)
closer_model.add_multiple_turn_outputs([[get_text_message("resolution")]])
result = await Runner.run(
triage,
input="user_question",
run_config=RunConfig(nest_handoff_history=True),
)
assert result.final_output == "resolution"
assert closer_model.first_turn_args is not None
closer_input = closer_model.first_turn_args["input"]
assert isinstance(closer_input, list)
summary = _as_message(closer_input[0])
assert summary["role"] == "assistant"
summary_content = summary["content"]
assert isinstance(summary_content, str)
assert summary_content.count("<CONVERSATION HISTORY>") == 1
assert "triage summary" in summary_content
assert "delegate update" in summary_content
assert "user_question" in summary_content
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
@pytest.mark.parametrize("nest_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
async def test_server_managed_handoff_history_auto_disables_with_warning(
streamed: bool,
nest_source: str,
caplog: pytest.LogCaptureFixture,
) -> None:
triage_model = FakeModel()
delegate_model = FakeModel()
delegate = Agent(name="delegate", model=delegate_model)
run_config = RunConfig()
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
if nest_source == "handoff":
triage_handoffs = [handoff(delegate, nest_handoff_history=True)]
else:
triage_handoffs = [delegate]
run_config = RunConfig(nest_handoff_history=True)
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
with caplog.at_level("WARNING", logger="openai.agents"):
result = await _run_agent_with_optional_streaming(
triage,
input="user_message",
streamed=streamed,
run_config=run_config,
auto_previous_response_id=True,
)
assert result.final_output == "done"
assert "do not support nest_handoff_history" in caplog.text
assert delegate_model.first_turn_args is not None
delegate_input = delegate_model.first_turn_args["input"]
assert isinstance(delegate_input, list)
assert len(delegate_input) == 1
handoff_output = delegate_input[0]
assert handoff_output.get("type") == "function_call_output"
assert "delegate" in str(handoff_output.get("output"))
assert not any(
isinstance(item, dict)
and item.get("role") == "assistant"
and "<CONVERSATION HISTORY>" in str(item.get("content"))
for item in delegate_input
)
@pytest.mark.asyncio
@pytest.mark.parametrize("streamed", [False, True], ids=["non_streamed", "streamed"])
@pytest.mark.parametrize("filter_source", ["run_config", "handoff"], ids=["run_config", "handoff"])
async def test_server_managed_handoff_input_filters_still_raise(
streamed: bool,
filter_source: str,
) -> None:
triage_model = FakeModel()
delegate_model = FakeModel()
delegate = Agent(name="delegate", model=delegate_model)
def passthrough_filter(data: HandoffInputData) -> HandoffInputData:
return data
run_config = RunConfig()
triage_handoffs: list[Agent[Any] | Handoff[Any, Any]]
if filter_source == "handoff":
triage_handoffs = [handoff(delegate, input_filter=passthrough_filter)]
else:
triage_handoffs = [delegate]
run_config = RunConfig(handoff_input_filter=passthrough_filter)
triage = Agent(name="triage", model=triage_model, handoffs=triage_handoffs)
triage_model.add_multiple_turn_outputs(
[[get_text_message("triage summary"), get_handoff_tool_call(delegate)]]
)
delegate_model.add_multiple_turn_outputs([[get_text_message("done")]])
with pytest.raises(
UserError,
match="Server-managed conversations do not support handoff input filters",
):
await _run_agent_with_optional_streaming(
triage,
input="user_message",
streamed=streamed,
run_config=run_config,
auto_previous_response_id=True,
)
assert delegate_model.first_turn_args is None
@pytest.mark.asyncio
async def test_async_input_filter_supported():
# DO NOT rename this without updating pyproject.toml
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
return agent_1
async def async_input_filter(data: HandoffInputData) -> HandoffInputData:
return data # pragma: no cover
agent_2 = Agent[None](
name="test",
model=model,
handoffs=[
Handoff(
tool_name=Handoff.default_tool_name(agent_1),
tool_description=Handoff.default_tool_description(agent_1),
input_json_schema={},
on_invoke_handoff=on_invoke_handoff,
agent_name=agent_1.name,
input_filter=async_input_filter,
)
],
)
model.add_multiple_turn_outputs(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
result = await Runner.run(agent_2, input="user_message")
assert result.final_output == "last"
@pytest.mark.asyncio
async def test_invalid_input_filter_fails():
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
return agent_1
def invalid_input_filter(data: HandoffInputData) -> HandoffInputData:
# Purposely returning a string to simulate invalid output
return "foo" # type: ignore
agent_2 = Agent[None](
name="test",
model=model,
handoffs=[
Handoff(
tool_name=Handoff.default_tool_name(agent_1),
tool_description=Handoff.default_tool_description(agent_1),
input_json_schema={},
on_invoke_handoff=on_invoke_handoff,
agent_name=agent_1.name,
input_filter=invalid_input_filter,
)
],
)
model.add_multiple_turn_outputs(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
with pytest.raises(UserError):
await Runner.run(agent_2, input="user_message")
@pytest.mark.asyncio
async def test_non_callable_input_filter_causes_error():
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
async def on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) -> Agent[Any]:
return agent_1
agent_2 = Agent[None](
name="test",
model=model,
handoffs=[
Handoff(
tool_name=Handoff.default_tool_name(agent_1),
tool_description=Handoff.default_tool_description(agent_1),
input_json_schema={},
on_invoke_handoff=on_invoke_handoff,
agent_name=agent_1.name,
# Purposely ignoring the type error here to simulate invalid input
input_filter="foo", # type: ignore
)
],
)
model.add_multiple_turn_outputs(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
with pytest.raises(UserError):
await Runner.run(agent_2, input="user_message")
@pytest.mark.asyncio
async def test_handoff_on_input():
call_output: str | None = None
def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None:
nonlocal call_output
call_output = data["bar"]
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
agent_2 = Agent(
name="test",
model=model,
handoffs=[
handoff(
agent=agent_1,
on_handoff=on_input,
input_type=Foo,
)
],
)
model.add_multiple_turn_outputs(
[
[
get_text_message("1"),
get_text_message("2"),
get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))),
],
[get_text_message("last")],
]
)
result = await Runner.run(agent_2, input="user_message")
assert result.final_output == "last"
assert call_output == "test_input", "should have called the handoff with the correct input"
@pytest.mark.asyncio
async def test_async_handoff_on_input():
call_output: str | None = None
async def on_input(_ctx: RunContextWrapper[Any], data: Foo) -> None:
nonlocal call_output
call_output = data["bar"]
model = FakeModel()
agent_1 = Agent(
name="test",
model=model,
)
agent_2 = Agent(
name="test",
model=model,
handoffs=[
handoff(
agent=agent_1,
on_handoff=on_input,
input_type=Foo,
)
],
)
model.add_multiple_turn_outputs(
[
[
get_text_message("1"),
get_text_message("2"),
get_handoff_tool_call(agent_1, args=json.dumps(Foo(bar="test_input"))),
],
[get_text_message("last")],
]
)
result = await Runner.run(agent_2, input="user_message")
assert result.final_output == "last"
assert call_output == "test_input", "should have called the handoff with the correct input"
@pytest.mark.asyncio
async def test_wrong_params_on_input_causes_error():
agent_1 = Agent(
name="test",
)
def _on_handoff_too_many_params(ctx: RunContextWrapper[Any], foo: Foo, bar: str) -> None:
pass
with pytest.raises(UserError):
handoff(
agent_1,
input_type=Foo,
# Purposely ignoring the type error here to simulate invalid input
on_handoff=_on_handoff_too_many_params, # type: ignore
)
def on_handoff_too_few_params(ctx: RunContextWrapper[Any]) -> None:
pass
with pytest.raises(UserError):
handoff(
agent_1,
input_type=Foo,
# Purposely ignoring the type error here to simulate invalid input
on_handoff=on_handoff_too_few_params, # type: ignore
)
@pytest.mark.asyncio
async def test_invalid_handoff_input_json_causes_error():
agent = Agent(name="test")
h = handoff(agent, input_type=Foo, on_handoff=lambda _ctx, _input: None)
with pytest.raises(ModelBehaviorError):
await h.on_invoke_handoff(
RunContextWrapper(None),
# Purposely ignoring the type error here to simulate invalid input
None, # type: ignore
)
with pytest.raises(ModelBehaviorError):
await h.on_invoke_handoff(RunContextWrapper(None), "invalid")
@pytest.mark.asyncio
async def test_input_guardrail_tripwire_triggered_causes_exception():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
agent = Agent(
name="test", input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)]
)
model = FakeModel()
model.set_next_output([get_text_message("user_message")])
with pytest.raises(InputGuardrailTripwireTriggered):
await Runner.run(agent, input="user_message")
@pytest.mark.asyncio
async def test_input_guardrail_tripwire_does_not_save_assistant_message_to_session():
async def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
# Delay to ensure the agent has time to produce output before the guardrail finishes.
await asyncio.sleep(0.01)
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
session = SimpleListSession()
model = FakeModel()
model.set_next_output([get_text_message("should_not_be_saved")])
agent = Agent(
name="test",
model=model,
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)],
)
with pytest.raises(InputGuardrailTripwireTriggered):
await Runner.run(agent, input="user_message", session=session)
items = await session.get_items()
assert len(items) == 1
first_item = cast(dict[str, Any], items[0])
assert "role" in first_item
assert first_item["role"] == "user"
@pytest.mark.asyncio
async def test_prepare_input_with_session_keeps_function_call_outputs():
history_item = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_prepare",
"output": "ok",
},
)
session = SimpleListSession(history=[history_item])
prepared_input, session_items = await prepare_input_with_session("hello", session, None)
assert isinstance(prepared_input, list)
assert len(session_items) == 1
assert cast(dict[str, Any], session_items[0]).get("role") == "user"
first_item = cast(dict[str, Any], prepared_input[0])
last_item = cast(dict[str, Any], prepared_input[-1])
assert first_item["type"] == "function_call_output"
assert last_item["role"] == "user"
assert last_item["content"] == "hello"
@pytest.mark.asyncio
async def test_prepare_input_with_session_prefers_latest_function_call_output():
history_output = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_latest",
"output": "history-output",
},
)
session = SimpleListSession(history=[history_output])
latest_output = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_latest",
"output": "new-output",
},
)
prepared_input, session_items = await prepare_input_with_session([latest_output], session, None)
assert isinstance(prepared_input, list)
prepared_outputs = [
cast(dict[str, Any], item)
for item in prepared_input
if isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == "call_latest"
]
assert len(prepared_outputs) == 1
assert prepared_outputs[0]["output"] == "new-output"
assert len(session_items) == 1
assert cast(dict[str, Any], session_items[0])["output"] == "new-output"
@pytest.mark.asyncio
async def test_prepare_input_with_session_drops_orphan_function_calls():
orphan_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "tool_orphan",
"arguments": "{}",
},
)
session = SimpleListSession(history=[orphan_call])
prepared_input, session_items = await prepare_input_with_session("hello", session, None)
assert isinstance(prepared_input, list)
assert len(session_items) == 1
assert not any(
isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == "orphan_call"
for item in prepared_input
)
assert any(
isinstance(item, dict) and item.get("role") == "user" and item.get("content") == "hello"
for item in prepared_input
)
@pytest.mark.asyncio
async def test_prepare_input_with_session_preserves_pending_new_shell_calls() -> None:
orphan_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "orphan_call",
"name": "tool_orphan",
"arguments": "{}",
},
)
pending_shell_call = cast(
TResponseInputItem,
make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]),
)
session = SimpleListSession(history=[orphan_call])
prepared_input, session_items = await prepare_input_with_session(
[pending_shell_call],
session,
None,
)
assert isinstance(prepared_input, list)
assert session_items == [pending_shell_call]
assert not any(
isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == "orphan_call"
for item in prepared_input
)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in prepared_input
)
def test_ensure_api_input_item_handles_model_dump_objects():
class _ModelDumpItem:
def model_dump(self, exclude_unset: bool = True) -> dict[str, Any]:
return {
"type": "function_call_output",
"call_id": "call_model_dump",
"output": "dumped",
}
dummy_item: Any = _ModelDumpItem()
converted = ensure_input_item_format(dummy_item)
assert converted["type"] == "function_call_output"
assert converted["output"] == "dumped"
def test_ensure_api_input_item_avoids_pydantic_serialization_warnings():
annotation = AnnotationFileCitation.model_construct(
type="container_file_citation",
file_id="file_123",
filename="result.txt",
index=0,
)
output_text = ResponseOutputText.model_construct(
type="output_text",
text="done",
annotations=[annotation],
)
with warnings.catch_warnings(record=True) as captured:
warnings.simplefilter("always")
converted = ensure_input_item_format(cast(Any, output_text))
converted_payload = cast(dict[str, Any], converted)
assert captured == []
assert converted_payload["type"] == "output_text"
assert converted_payload["annotations"][0]["type"] == "container_file_citation"
def test_ensure_api_input_item_preserves_object_output():
payload = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_object",
"output": {"complex": "value"},
},
)
converted = ensure_input_item_format(payload)
assert converted["type"] == "function_call_output"
assert isinstance(converted["output"], dict)
assert converted["output"] == {"complex": "value"}
@pytest.mark.asyncio
async def test_prepare_input_with_session_uses_sync_callback():
history_item = cast(TResponseInputItem, {"role": "user", "content": "hi"})
session = SimpleListSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
first = cast(dict[str, Any], history[0])
assert first["role"] == "user"
return history + new_input
prepared, session_items = await prepare_input_with_session("second", session, callback)
assert len(prepared) == 2
last_item = cast(dict[str, Any], prepared[-1])
assert last_item["role"] == "user"
assert last_item.get("content") == "second"
# session_items should contain only the new turn input
assert len(session_items) == 1
assert cast(dict[str, Any], session_items[0]).get("role") == "user"
@pytest.mark.asyncio
async def test_prepare_input_with_session_awaits_async_callback():
history_item = cast(TResponseInputItem, {"role": "user", "content": "initial"})
session = SimpleListSession(history=[history_item])
async def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
await asyncio.sleep(0)
return history + new_input
prepared, session_items = await prepare_input_with_session("later", session, callback)
assert len(prepared) == 2
first_item = cast(dict[str, Any], prepared[0])
assert first_item["role"] == "user"
assert first_item.get("content") == "initial"
assert len(session_items) == 1
assert cast(dict[str, Any], session_items[0]).get("role") == "user"
@pytest.mark.asyncio
async def test_prepare_input_with_session_callback_drops_new_items():
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
session = SimpleListSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
_ = new_input
return history
prepared, session_items = await prepare_input_with_session("new", session, callback)
assert prepared == [history_item]
assert session_items == []
@pytest.mark.asyncio
async def test_prepare_input_with_session_callback_reorders_new_items():
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
session = SimpleListSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
return [new_input[1], history[0], new_input[0]]
new_input = [get_text_input_item("first"), get_text_input_item("second")]
prepared, session_items = await prepare_input_with_session(new_input, session, callback)
assert cast(dict[str, Any], prepared[0]).get("content") == "second"
assert cast(dict[str, Any], prepared[1]).get("content") == "history"
assert cast(dict[str, Any], prepared[2]).get("content") == "first"
assert [cast(dict[str, Any], item).get("content") for item in session_items] == [
"second",
"first",
]
@pytest.mark.asyncio
async def test_prepare_input_with_session_callback_accepts_extra_items():
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
session = SimpleListSession(history=[history_item])
extra_item = cast(TResponseInputItem, {"role": "assistant", "content": "extra"})
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
return [extra_item, history[0], new_input[0]]
prepared, session_items = await prepare_input_with_session("new", session, callback)
assert [cast(dict[str, Any], item).get("content") for item in prepared] == [
"extra",
"history",
"new",
]
assert [cast(dict[str, Any], item).get("content") for item in session_items] == [
"extra",
"new",
]
@pytest.mark.asyncio
async def test_prepare_input_with_session_ignores_callback_without_history():
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
session = SimpleListSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
_ = history
_ = new_input
return []
prepared, session_items = await prepare_input_with_session(
"new",
session,
callback,
include_history_in_prepared_input=False,
preserve_dropped_new_items=True,
)
assert [cast(dict[str, Any], item).get("content") for item in prepared] == ["new"]
assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"]
@pytest.mark.asyncio
async def test_prepare_input_with_session_rejects_non_callable_callback():
session = SimpleListSession()
with pytest.raises(UserError, match="session_input_callback"):
await prepare_input_with_session("hello", session, cast(Any, "bad_callback"))
@pytest.mark.asyncio
async def test_prepare_input_with_session_rejects_non_list_callback_result():
session = SimpleListSession()
def callback(history: list[TResponseInputItem], new_input: list[TResponseInputItem]) -> str:
_ = history
_ = new_input
return "not-a-list"
with pytest.raises(UserError, match="Session input callback must return a list"):
await prepare_input_with_session("hello", session, cast(Any, callback))
@pytest.mark.asyncio
async def test_prepare_input_with_session_matches_copied_items_by_content() -> None:
history_item = cast(TResponseInputItem, {"role": "user", "content": "history"})
session = SimpleListSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
return [
cast(TResponseInputItem, dict(cast(dict[str, Any], history[0]))),
cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))),
]
prepared, session_items = await prepare_input_with_session("new", session, callback)
assert [cast(dict[str, Any], item).get("content") for item in prepared] == [
"history",
"new",
]
assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"]
@pytest.mark.asyncio
async def test_prepare_input_with_openai_conversation_strips_assistant_history_ids() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
self.history = history
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
if limit is None:
return list(self.history)
return self.history[-limit:]
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.history.extend(items)
async def pop_item(self) -> TResponseInputItem | None:
return self.history.pop() if self.history else None
async def clear_session(self) -> None:
self.history.clear()
history_item = cast(
TResponseInputItem,
{
"id": "conv_item_assistant",
"type": "message",
"role": "assistant",
"content": "history",
"provider_data": {"server": "metadata"},
},
)
user_history_item = cast(
TResponseInputItem,
{
"id": "conv_item_user",
"type": "message",
"role": "user",
"content": "user history",
"provider_data": {"server": "metadata"},
},
)
function_call_item = cast(
TResponseInputItem,
{
"id": "conv_item_call",
"type": "function_call",
"call_id": "call_history",
"name": "lookup",
"arguments": "{}",
},
)
function_call_output_item = cast(
TResponseInputItem,
{
"id": "conv_item_output",
"type": "function_call_output",
"call_id": "call_history",
"output": "ok",
},
)
session = DummyOpenAIConversationsSession(
history=[user_history_item, history_item, function_call_item, function_call_output_item]
)
prepared, session_items = await prepare_input_with_session("new", session, None)
assert isinstance(prepared, list)
user_payload = cast(dict[str, Any], prepared[0])
history_payload = cast(dict[str, Any], prepared[1])
call_payload = cast(dict[str, Any], prepared[2])
output_payload = cast(dict[str, Any], prepared[3])
new_payload = cast(dict[str, Any], prepared[4])
assert user_payload["role"] == "user"
assert user_payload["id"] == "conv_item_user"
assert "provider_data" in user_payload
assert history_payload["role"] == "assistant"
assert "id" not in history_payload
assert "provider_data" not in history_payload
assert call_payload["id"] == "conv_item_call"
assert output_payload["id"] == "conv_item_output"
assert new_payload["role"] == "user"
assert new_payload["content"] == "new"
assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"]
@pytest.mark.asyncio
async def test_prepare_input_with_regular_session_preserves_history_ids() -> None:
history_item = cast(
TResponseInputItem,
{
"id": "message_id",
"type": "message",
"role": "assistant",
"content": "history",
},
)
session = SimpleListSession(history=[history_item])
prepared, _ = await prepare_input_with_session("new", session, None)
assert isinstance(prepared, list)
history_payload = cast(dict[str, Any], prepared[0])
assert history_payload["id"] == "message_id"
@pytest.mark.asyncio
async def test_prepare_input_with_openai_conversation_callback_matches_assistant_no_ids() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
self.history = history
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
if limit is None:
return list(self.history)
return self.history[-limit:]
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.history.extend(items)
async def pop_item(self) -> TResponseInputItem | None:
return self.history.pop() if self.history else None
async def clear_session(self) -> None:
self.history.clear()
history_item = cast(
TResponseInputItem,
{
"id": "conv_item_assistant",
"type": "message",
"role": "assistant",
"content": "history",
"provider_data": {"server": "metadata"},
},
)
session = DummyOpenAIConversationsSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
history_copy = dict(cast(dict[str, Any], history[0]))
history_copy.pop("id", None)
history_copy.pop("provider_data", None)
return [
cast(TResponseInputItem, history_copy),
cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))),
]
prepared, session_items = await prepare_input_with_session("new", session, callback)
assert isinstance(prepared, list)
assert [cast(dict[str, Any], item).get("content") for item in prepared] == [
"history",
"new",
]
assert [cast(dict[str, Any], item).get("content") for item in session_items] == ["new"]
@pytest.mark.asyncio
async def test_prepare_input_with_openai_conversation_callback_keeps_user_ids_distinct() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self, history: list[TResponseInputItem]) -> None:
self.history = history
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
if limit is None:
return list(self.history)
return self.history[-limit:]
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.history.extend(items)
async def pop_item(self) -> TResponseInputItem | None:
return self.history.pop() if self.history else None
async def clear_session(self) -> None:
self.history.clear()
history_item = cast(
TResponseInputItem,
{
"id": "conv_item_user",
"type": "message",
"role": "user",
"content": "history",
"provider_data": {"server": "metadata"},
},
)
session = DummyOpenAIConversationsSession(history=[history_item])
def callback(
history: list[TResponseInputItem], new_input: list[TResponseInputItem]
) -> list[TResponseInputItem]:
history_copy = dict(cast(dict[str, Any], history[0]))
history_copy.pop("id", None)
history_copy.pop("provider_data", None)
return [
cast(TResponseInputItem, history_copy),
cast(TResponseInputItem, dict(cast(dict[str, Any], new_input[0]))),
]
prepared, session_items = await prepare_input_with_session("new", session, callback)
assert isinstance(prepared, list)
assert [cast(dict[str, Any], item).get("content") for item in prepared] == [
"history",
"new",
]
assert [cast(dict[str, Any], item).get("content") for item in session_items] == [
"history",
"new",
]
@pytest.mark.asyncio
async def test_persist_session_items_for_guardrail_trip_uses_original_input_when_missing() -> None:
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
persisted = await persist_session_items_for_guardrail_trip(
session,
None,
None,
"guardrail input",
run_state,
)
assert persisted == [{"role": "user", "content": "guardrail input"}]
assert await session.get_items() == persisted
@pytest.mark.asyncio
async def test_wait_for_session_cleanup_retries_after_get_items_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
target = cast(TResponseInputItem, {"id": "msg-1", "type": "message", "content": "hello"})
serialized_target = fingerprint_input_item(target)
class FlakyCleanupSession(SimpleListSession):
def __init__(self) -> None:
super().__init__()
self.get_items_calls = 0
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
self.get_items_calls += 1
if self.get_items_calls == 1:
raise RuntimeError("temporary failure")
return []
session = FlakyCleanupSession()
sleeps: list[float] = []
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
assert serialized_target is not None
await wait_for_session_cleanup(session, [serialized_target])
assert session.get_items_calls == 2
assert sleeps == [0.1]
@pytest.mark.asyncio
async def test_wait_for_session_cleanup_logs_when_targets_linger(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
target = cast(TResponseInputItem, {"id": "msg-1", "type": "message", "content": "hello"})
session = SimpleListSession(history=[target])
serialized_target = fingerprint_input_item(target)
sleeps: list[float] = []
async def fake_sleep(delay: float) -> None:
sleeps.append(delay)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
assert serialized_target is not None
with caplog.at_level("DEBUG", logger="openai.agents"):
await wait_for_session_cleanup(session, [serialized_target], max_attempts=2)
assert sleeps == [0.1, 0.2]
assert "Session cleanup verification exhausted attempts" in caplog.text
@pytest.mark.asyncio
async def test_conversation_lock_rewind_skips_when_no_snapshot() -> None:
history_item = cast(TResponseInputItem, {"id": "old", "type": "message"})
new_item = cast(TResponseInputItem, {"id": "new", "type": "message"})
session = CountingSession(history=[history_item])
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
400,
request=request,
json={"error": {"code": "conversation_locked", "message": "locked"}},
)
locked_error = BadRequestError(
"locked",
response=response,
body={"error": {"code": "conversation_locked"}},
)
locked_error.code = "conversation_locked"
model = FakeModel()
model.add_multiple_turn_outputs([locked_error, [get_text_message("ok")]])
agent = Agent(name="test", model=model)
result = await get_new_response(
bindings=bind_public_agent(agent),
system_prompt=None,
input=[history_item, new_item],
output_schema=None,
all_tools=[],
handoffs=[],
hooks=RunHooks(),
context_wrapper=RunContextWrapper(context={}),
run_config=RunConfig(),
tool_use_tracker=AgentToolUseTracker(),
server_conversation_tracker=None,
prompt_config=None,
session=session,
session_items_to_rewind=[],
)
assert isinstance(result, ModelResponse)
assert session.pop_calls == 0
@pytest.mark.asyncio
async def test_get_new_response_uses_agent_retry_settings() -> None:
model = FakeModel()
model.set_hardcoded_usage(Usage(requests=1))
model.add_multiple_turn_outputs(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[get_text_message("ok")],
]
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
result = await get_new_response(
bindings=bind_public_agent(agent),
system_prompt=None,
input=[get_text_input_item("hello")],
output_schema=None,
all_tools=[],
handoffs=[],
hooks=RunHooks(),
context_wrapper=RunContextWrapper(context={}),
run_config=RunConfig(),
tool_use_tracker=AgentToolUseTracker(),
server_conversation_tracker=None,
prompt_config=None,
session=None,
session_items_to_rewind=[],
)
assert isinstance(result, ModelResponse)
assert result.usage.requests == 2
@pytest.mark.asyncio
async def test_save_result_to_session_preserves_function_outputs():
session = SimpleListSession()
original_item = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_original",
"output": "1",
},
)
run_item_payload = {
"type": "function_call_output",
"call_id": "call_result",
"output": "2",
}
dummy_run_item = _DummyRunItem(run_item_payload)
await save_result_to_session(
session,
[original_item],
[cast(RunItem, dummy_run_item)],
None,
)
assert len(session.saved_items) == 2
for saved in session.saved_items:
saved_dict = cast(dict[str, Any], saved)
assert saved_dict["type"] == "function_call_output"
assert "output" in saved_dict
@pytest.mark.asyncio
async def test_save_result_to_session_prefers_latest_duplicate_function_outputs():
session = SimpleListSession()
original_item = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_duplicate",
"output": "old-output",
},
)
new_item_payload = {
"type": "function_call_output",
"call_id": "call_duplicate",
"output": "new-output",
}
new_item = _DummyRunItem(new_item_payload)
await save_result_to_session(
session,
[original_item],
[cast(RunItem, new_item)],
None,
)
duplicates = [
cast(dict[str, Any], item)
for item in session.saved_items
if isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == "call_duplicate"
]
assert len(duplicates) == 1
assert duplicates[0]["output"] == "new-output"
@pytest.mark.asyncio
async def test_rewind_handles_id_stripped_sessions() -> None:
session = IdStrippingSession()
item = cast(TResponseInputItem, {"id": "message-1", "type": "message", "content": "hello"})
await session.add_items([item])
await rewind_session_items(session, [item])
assert session.pop_calls == 1
assert session.saved_items == []
@pytest.mark.asyncio
async def test_rewind_skips_mismatched_tail_suffix() -> None:
target = cast(TResponseInputItem, {"type": "message", "role": "user", "content": "target"})
unrelated = cast(
TResponseInputItem,
{"type": "message", "role": "user", "content": "unrelated tail item"},
)
session = CountingSession(history=[target, unrelated])
await rewind_session_items(session, [target])
assert session.pop_calls == 0
assert session.saved_items == [target, unrelated]
@pytest.mark.asyncio
async def test_rewind_preserves_unrelated_tail_items_when_server_tracker_cleanup_runs() -> None:
known_server_item = cast(
TResponseInputItem,
{"id": "msg_server_1", "type": "message", "role": "assistant", "content": "server item"},
)
unrelated = cast(
TResponseInputItem,
{"type": "message", "role": "user", "content": "unrelated tail item"},
)
target = cast(TResponseInputItem, {"type": "message", "role": "user", "content": "target"})
session = CountingSession(history=[known_server_item, unrelated, target])
tracker = OpenAIServerConversationTracker()
tracker.server_item_ids.add("msg_server_1")
await rewind_session_items(session, [target], tracker)
assert session.pop_calls == 1
assert session.saved_items == [known_server_item, unrelated]
@pytest.mark.asyncio
async def test_rewind_strips_only_retry_owned_tail_items_before_known_server_item() -> None:
known_server_item = cast(
TResponseInputItem,
{"id": "msg_server_1", "type": "message", "role": "assistant", "content": "server item"},
)
retry_owned_tail = cast(
TResponseInputItem,
{"type": "message", "role": "user", "content": "retry-owned local item"},
)
target = cast(TResponseInputItem, {"type": "message", "role": "user", "content": "target"})
session = CountingSession(history=[known_server_item, retry_owned_tail, target])
tracker = OpenAIServerConversationTracker()
tracker.server_item_ids.add("msg_server_1")
retry_owned_fingerprint = fingerprint_input_item(retry_owned_tail)
assert retry_owned_fingerprint is not None
tracker.sent_item_fingerprints.add(retry_owned_fingerprint)
await rewind_session_items(session, [target], tracker)
assert session.pop_calls == 2
assert session.saved_items == [known_server_item]
def test_collect_retry_owned_tail_serializations_returns_empty_for_empty_session() -> None:
tracker = OpenAIServerConversationTracker()
assert (
_collect_retry_owned_tail_serializations(
[],
server_tracker=tracker,
ignore_ids_for_matching=False,
)
== []
)
@pytest.mark.asyncio
async def test_save_result_to_session_does_not_increment_counter_when_nothing_saved() -> None:
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
approval_item = ToolApprovalItem(
agent=agent,
raw_item={"type": "function_call", "call_id": "call-1", "name": "tool"},
)
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
await save_result_to_session(
session,
[],
cast(list[RunItem], [approval_item]),
run_state,
)
assert run_state._current_turn_persisted_item_count == 0
assert session.saved_items == []
@pytest.mark.asyncio
async def test_save_result_to_session_returns_count_and_updates_state() -> None:
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
approval_item = ToolApprovalItem(
agent=agent,
raw_item={"type": "function_call", "call_id": "call-2", "name": "tool"},
)
output_item = _DummyRunItem(
{"type": "message", "role": "assistant", "content": "ok"},
"message_output_item",
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [output_item, approval_item]),
run_state,
)
assert saved_count == 1
assert run_state._current_turn_persisted_item_count == 1
assert len(session.saved_items) == 1
assert cast(dict[str, Any], session.saved_items[0]).get("content") == "ok"
@pytest.mark.asyncio
async def test_save_result_to_session_counts_sanitized_openai_items() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.saved_items: list[TResponseInputItem] = []
async def _get_session_id(self) -> str:
return "conv_test"
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.saved_items.extend(items)
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return []
async def pop_item(self) -> TResponseInputItem | None:
return None
async def clear_session(self) -> None:
return None
session = DummyOpenAIConversationsSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
output_item = _DummyRunItem(
{
"type": "message",
"role": "assistant",
"content": "ok",
"provider_data": {"model": "litellm/test"},
},
"message_output_item",
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [output_item]),
run_state,
)
assert saved_count == 1
assert run_state._current_turn_persisted_item_count == 1
assert len(session.saved_items) == 1
saved = cast(dict[str, Any], session.saved_items[0])
assert "provider_data" not in saved
@pytest.mark.asyncio
async def test_save_result_to_session_omits_reasoning_ids_when_policy_is_omit() -> None:
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
run_state.set_reasoning_item_id_policy("omit")
reasoning_item = ReasoningItem(
agent=agent,
raw_item=ResponseReasoningItem(type="reasoning", id="rs_stream", summary=[]),
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [reasoning_item]),
run_state,
)
assert saved_count == 1
assert len(session.saved_items) == 1
saved_reasoning = cast(dict[str, Any], session.saved_items[0])
assert saved_reasoning.get("type") == "reasoning"
assert "id" not in saved_reasoning
@pytest.mark.asyncio
async def test_save_result_to_openai_conversation_preserves_reasoning_id_when_policy_is_omit() -> (
None
):
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.saved_items: list[TResponseInputItem] = []
async def _get_session_id(self) -> str:
return "conv_test"
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.saved_items.extend(items)
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return []
async def pop_item(self) -> TResponseInputItem | None:
return None
async def clear_session(self) -> None:
return None
session = DummyOpenAIConversationsSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
run_state.set_reasoning_item_id_policy("omit")
reasoning_item = ReasoningItem(
agent=agent,
raw_item=ResponseReasoningItem(
type="reasoning",
id="rs_openai_conversation",
summary=[Summary(text="thinking", type="summary_text")],
),
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [reasoning_item]),
run_state,
)
assert saved_count == 1
assert run_state._current_turn_persisted_item_count == 1
assert len(session.saved_items) == 1
saved_reasoning = cast(dict[str, Any], session.saved_items[0])
assert saved_reasoning.get("type") == "reasoning"
assert saved_reasoning.get("id") == "rs_openai_conversation"
@pytest.mark.asyncio
async def test_save_result_to_openai_conversation_drops_unpersistable_reasoning_item() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.saved_items: list[TResponseInputItem] = []
async def _get_session_id(self) -> str:
return "conv_test"
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.saved_items.extend(items)
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return []
async def pop_item(self) -> TResponseInputItem | None:
return None
async def clear_session(self) -> None:
return None
session = DummyOpenAIConversationsSession()
agent = Agent(name="agent", model=FakeModel())
run_state: RunState[Any] = RunState(
context=RunContextWrapper(context={}),
original_input="input",
starting_agent=agent,
max_turns=1,
)
malformed_reasoning = _DummyRunItem(
{"type": "reasoning", "summary": [], "content": []},
"reasoning_item",
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [malformed_reasoning]),
run_state,
)
assert saved_count == 1
assert run_state._current_turn_persisted_item_count == 1
assert session.saved_items == []
@pytest.mark.asyncio
async def test_save_result_to_openai_conversation_keeps_reasoning_encrypted_content() -> None:
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.saved_items: list[TResponseInputItem] = []
async def _get_session_id(self) -> str:
return "conv_test"
async def add_items(self, items: list[TResponseInputItem]) -> None:
self.saved_items.extend(items)
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return []
async def pop_item(self) -> TResponseInputItem | None:
return None
async def clear_session(self) -> None:
return None
session = DummyOpenAIConversationsSession()
encrypted_reasoning = _DummyRunItem(
{
"type": "reasoning",
"summary": [],
"content": [],
"encrypted_content": "encrypted",
},
"reasoning_item",
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [encrypted_reasoning]),
None,
)
assert saved_count == 1
assert len(session.saved_items) == 1
saved_reasoning = cast(dict[str, Any], session.saved_items[0])
assert saved_reasoning["encrypted_content"] == "encrypted"
@pytest.mark.asyncio
async def test_save_result_to_session_keeps_tool_call_payload_api_safe() -> None:
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
tool_call = ToolCallItem(
agent=agent,
raw_item=ResponseFunctionToolCall(
id="fc_session",
call_id="call_session",
name="lookup_account",
arguments="{}",
type="function_call",
status="completed",
),
description="Lookup customer records.",
title="Lookup Account",
)
saved_count = await save_result_to_session(
session,
[],
cast(list[RunItem], [tool_call]),
None,
)
assert saved_count == 1
assert len(session.saved_items) == 1
saved_tool_call = cast(dict[str, Any], session.saved_items[0])
assert saved_tool_call["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in saved_tool_call
assert TOOL_CALL_SESSION_TITLE_KEY not in saved_tool_call
assert "description" not in saved_tool_call
assert "title" not in saved_tool_call
@pytest.mark.asyncio
async def test_save_result_to_session_sanitizes_original_input_items() -> None:
session = SimpleListSession()
saved_count = await save_result_to_session(
session,
[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_input",
"name": "lookup_account",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
)
],
[],
None,
)
assert saved_count == 0
assert len(session.saved_items) == 1
saved_tool_call = cast(dict[str, Any], session.saved_items[0])
assert saved_tool_call["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in saved_tool_call
assert TOOL_CALL_SESSION_TITLE_KEY not in saved_tool_call
assert "description" not in saved_tool_call
assert "title" not in saved_tool_call
@pytest.mark.asyncio
async def test_prepare_input_with_session_strips_internal_tool_call_metadata() -> None:
tool_call = cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_history",
"name": "lookup_account",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
)
tool_output = cast(
TResponseInputItem,
{
"type": "function_call_output",
"call_id": "call_history",
"output": "ok",
},
)
session = SimpleListSession(history=[tool_call, tool_output])
prepared_input, session_items = await prepare_input_with_session("hello", session, None)
assert isinstance(prepared_input, list)
prepared_tool_calls = [
cast(dict[str, Any], item)
for item in prepared_input
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == "call_history"
]
assert len(prepared_tool_calls) == 1
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in prepared_tool_calls[0]
assert TOOL_CALL_SESSION_TITLE_KEY not in prepared_tool_calls[0]
assert len(session_items) == 1
assert cast(dict[str, Any], session_items[0])["role"] == "user"
@pytest.mark.asyncio
async def test_prepare_input_with_session_sanitizes_new_tool_call_session_items() -> None:
prepared_input, session_items = await prepare_input_with_session(
[
cast(
TResponseInputItem,
{
"type": "function_call",
"call_id": "call_new",
"name": "lookup_account",
"arguments": "{}",
TOOL_CALL_SESSION_DESCRIPTION_KEY: "Lookup customer records.",
TOOL_CALL_SESSION_TITLE_KEY: "Lookup Account",
},
)
],
SimpleListSession(),
None,
)
assert isinstance(prepared_input, list)
assert len(prepared_input) == 1
prepared_tool_call = cast(dict[str, Any], prepared_input[0])
assert prepared_tool_call["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in prepared_tool_call
assert TOOL_CALL_SESSION_TITLE_KEY not in prepared_tool_call
assert len(session_items) == 1
session_tool_call = cast(dict[str, Any], session_items[0])
assert session_tool_call["type"] == "function_call"
assert TOOL_CALL_SESSION_DESCRIPTION_KEY not in session_tool_call
assert TOOL_CALL_SESSION_TITLE_KEY not in session_tool_call
@pytest.mark.asyncio
async def test_session_persists_only_new_step_items(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure only per-turn new_step_items are persisted to the session."""
session = SimpleListSession()
agent = Agent(name="agent", model=FakeModel())
pre_item = _DummyRunItem(
{"type": "message", "role": "assistant", "content": "old"}, "message_output_item"
)
new_item = _DummyRunItem(
{"type": "message", "role": "assistant", "content": "new"}, "message_output_item"
)
new_response = ModelResponse(output=[], usage=Usage(), response_id="resp-1")
turn_result = SingleStepResult(
original_input="hello",
model_response=new_response,
pre_step_items=[cast(RunItem, pre_item)],
new_step_items=[cast(RunItem, new_item)],
next_step=NextStepFinalOutput(output="done"),
tool_input_guardrail_results=[],
tool_output_guardrail_results=[],
)
calls: list[list[RunItem]] = []
from agents.run_internal import session_persistence as sp
real_save_result = sp.save_result_to_session
async def save_wrapper(
sess: Any,
original_input: Any,
new_items: list[RunItem],
run_state: RunState | None = None,
**kwargs: Any,
) -> None:
calls.append(list(new_items))
await real_save_result(sess, original_input, new_items, run_state, **kwargs)
async def fake_run_single_turn(**_: Any) -> SingleStepResult:
return turn_result
async def fake_run_output_guardrails(*_: Any, **__: Any) -> list[Any]:
return []
async def noop_initialize_computer_tools(*_: Any, **__: Any) -> None:
return None
monkeypatch.setattr("agents.run.save_result_to_session", save_wrapper)
monkeypatch.setattr(
"agents.run_internal.session_persistence.save_result_to_session", save_wrapper
)
monkeypatch.setattr("agents.run.run_single_turn", fake_run_single_turn)
monkeypatch.setattr("agents.run_internal.run_loop.run_single_turn", fake_run_single_turn)
monkeypatch.setattr("agents.run.run_output_guardrails", fake_run_output_guardrails)
monkeypatch.setattr(
"agents.run_internal.run_loop.run_output_guardrails", fake_run_output_guardrails
)
async def fake_get_all_tools(*_: Any, **__: Any) -> list[Any]:
return []
monkeypatch.setattr("agents.run.get_all_tools", fake_get_all_tools)
monkeypatch.setattr("agents.run_internal.run_loop.get_all_tools", fake_get_all_tools)
monkeypatch.setattr("agents.run.initialize_computer_tools", noop_initialize_computer_tools)
monkeypatch.setattr(
"agents.run_internal.run_loop.initialize_computer_tools", noop_initialize_computer_tools
)
result = await Runner.run(agent, input="hello", session=session)
assert result.final_output == "done"
# First save writes the user input; second save should contain only the new_step_items.
assert len(calls) >= 2
assert calls[-1] == [cast(RunItem, new_item)]
items = await session.get_items()
assert len(items) == 2
assert any("new" in cast(dict[str, Any], item).get("content", "") for item in items)
assert not any("old" in cast(dict[str, Any], item).get("content", "") for item in items)
@pytest.mark.asyncio
async def test_output_guardrail_tripwire_triggered_causes_exception():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
model = FakeModel()
agent = Agent(
name="test",
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)],
model=model,
)
model.set_next_output([get_text_message("user_message")])
with pytest.raises(OutputGuardrailTripwireTriggered):
await Runner.run(agent, input="user_message")
@pytest.mark.asyncio
async def test_input_guardrail_no_tripwire_continues_execution():
"""Test input guardrail that doesn't trigger tripwire continues execution."""
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False, # Doesn't trigger tripwire
)
model = FakeModel()
model.set_next_output([get_text_message("response")])
agent = Agent(
name="test",
model=model,
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)],
)
# Should complete successfully without raising exception
result = await Runner.run(agent, input="user_message")
assert result.final_output == "response"
@pytest.mark.asyncio
async def test_output_guardrail_no_tripwire_continues_execution():
"""Test output guardrail that doesn't trigger tripwire continues execution."""
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False, # Doesn't trigger tripwire
)
model = FakeModel()
model.set_next_output([get_text_message("response")])
agent = Agent(
name="test",
model=model,
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)],
)
# Should complete successfully without raising exception
result = await Runner.run(agent, input="user_message")
assert result.final_output == "response"
@function_tool
def test_tool_one():
return Foo(bar="tool_one_result")
@function_tool
def test_tool_two():
return "tool_two_result"
@pytest.mark.asyncio
async def test_tool_use_behavior_first_output():
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result"), test_tool_one, test_tool_two],
tool_use_behavior="stop_on_first_tool",
output_type=Foo,
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[
get_text_message("a_message"),
get_function_tool_call("test_tool_one", None),
get_function_tool_call("test_tool_two", None),
],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == Foo(bar="tool_one_result"), (
"should have used the first tool result"
)
def custom_tool_use_behavior(
context: RunContextWrapper[Any], results: list[FunctionToolResult]
) -> ToolsToFinalOutputResult:
if "test_tool_one" in [result.tool.name for result in results]:
return ToolsToFinalOutputResult(is_final_output=True, final_output="the_final_output")
else:
return ToolsToFinalOutputResult(is_final_output=False, final_output=None)
@pytest.mark.asyncio
async def test_tool_use_behavior_custom_function():
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result"), test_tool_one, test_tool_two],
tool_use_behavior=custom_tool_use_behavior,
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[
get_text_message("a_message"),
get_function_tool_call("test_tool_two", None),
],
# Second turn: a message and tool call
[
get_text_message("a_message"),
get_function_tool_call("test_tool_one", None),
get_function_tool_call("test_tool_two", None),
],
]
)
result = await Runner.run(agent, input="user_message")
assert len(result.raw_responses) == 2, "should have two model responses"
assert result.final_output == "the_final_output", "should have used the custom function"
@pytest.mark.asyncio
async def test_model_settings_override():
model = FakeModel()
agent = Agent(
name="test", model=model, model_settings=ModelSettings(temperature=1.0, max_tokens=1000)
)
model.add_multiple_turn_outputs(
[
[
get_text_message("a_message"),
],
]
)
await Runner.run(
agent,
input="user_message",
run_config=RunConfig(model_settings=ModelSettings(0.5)),
)
# temperature is overridden by Runner.run, but max_tokens is not
assert model.last_turn_args["model_settings"].temperature == 0.5
assert model.last_turn_args["model_settings"].max_tokens == 1000
@pytest.mark.asyncio
async def test_previous_response_id_passed_between_runs():
"""Test that previous_response_id is passed to the model on subsequent runs."""
model = FakeModel()
model.set_next_output([get_text_message("done")])
agent = Agent(name="test", model=model)
assert model.last_turn_args.get("previous_response_id") is None
await Runner.run(agent, input="test", previous_response_id="resp-non-streamed-test")
assert model.last_turn_args.get("previous_response_id") == "resp-non-streamed-test"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"run_kwargs",
[
{"conversation_id": "conv-test"},
{"previous_response_id": "resp-test"},
{"auto_previous_response_id": True},
],
)
async def test_run_rejects_session_with_server_managed_conversation(run_kwargs: dict[str, Any]):
model = FakeModel()
model.set_next_output([get_text_message("done")])
agent = Agent(name="test", model=model)
session = SimpleListSession()
with pytest.raises(UserError, match="Session persistence"):
await Runner.run(agent, input="test", session=session, **run_kwargs)
@pytest.mark.asyncio
async def test_run_rejects_session_with_resumed_conversation_state():
model = FakeModel()
agent = Agent(name="test", model=model)
session = SimpleListSession()
context_wrapper = RunContextWrapper(context=None)
state = RunState(
context=context_wrapper,
original_input="hello",
starting_agent=agent,
conversation_id="conv-test",
)
with pytest.raises(UserError, match="Session persistence"):
await Runner.run(agent, state, session=session)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"run_kwargs",
[
{"conversation_id": "conv-test"},
{"previous_response_id": "resp-test"},
{"auto_previous_response_id": True},
],
)
async def test_run_streamed_rejects_session_with_server_managed_conversation(
run_kwargs: dict[str, Any],
):
model = FakeModel()
model.set_next_output([get_text_message("done")])
agent = Agent(name="test", model=model)
session = SimpleListSession()
with pytest.raises(UserError, match="Session persistence"):
Runner.run_streamed(agent, input="test", session=session, **run_kwargs)
@pytest.mark.asyncio
async def test_run_streamed_rejects_session_with_resumed_conversation_state():
model = FakeModel()
agent = Agent(name="test", model=model)
session = SimpleListSession()
context_wrapper = RunContextWrapper(context=None)
state = RunState(
context=context_wrapper,
original_input="hello",
starting_agent=agent,
conversation_id="conv-test",
)
with pytest.raises(UserError, match="Session persistence"):
Runner.run_streamed(agent, state, session=session)
@pytest.mark.asyncio
async def test_multi_turn_previous_response_id_passed_between_runs():
"""Test that previous_response_id is passed to the model on subsequent runs."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
# Second turn: text message
[get_text_message("done")],
]
)
assert model.last_turn_args.get("previous_response_id") is None
await Runner.run(agent, input="test", previous_response_id="resp-test-123")
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_previous_response_id_passed_between_runs_streamed():
"""Test that previous_response_id is passed to the model on subsequent streamed runs."""
model = FakeModel()
model.set_next_output([get_text_message("done")])
agent = Agent(
name="test",
model=model,
)
assert model.last_turn_args.get("previous_response_id") is None
result = Runner.run_streamed(agent, input="test", previous_response_id="resp-stream-test")
async for _ in result.stream_events():
pass
assert model.last_turn_args.get("previous_response_id") == "resp-stream-test"
@pytest.mark.asyncio
async def test_previous_response_id_passed_between_runs_streamed_multi_turn():
"""Test that previous_response_id is passed to the model on subsequent streamed runs."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("foo", json.dumps({"a": "b"}))],
# Second turn: text message
[get_text_message("done")],
]
)
assert model.last_turn_args.get("previous_response_id") is None
result = Runner.run_streamed(agent, input="test", previous_response_id="resp-stream-test")
async for _ in result.stream_events():
pass
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_conversation_id_only_sends_new_items_multi_turn():
"""Test that conversation_id mode only sends new items on subsequent turns."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: another message and tool call
[get_text_message("b_message"), get_function_tool_call("test_func", '{"arg": "bar"}')],
# Third turn: final text message
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="user_message", conversation_id="conv-test-123")
assert result.final_output == "done"
# Check the first call - it should include the original input since generated_items is empty
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check the input from the last turn (third turn after function execution)
last_input = model.last_turn_args["input"]
# In conversation_id mode, the third turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
@pytest.mark.asyncio
async def test_conversation_id_only_sends_new_items_multi_turn_streamed():
"""Test that conversation_id mode only sends new items on subsequent turns (streamed mode)."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: another message and tool call
[get_text_message("b_message"), get_function_tool_call("test_func", '{"arg": "bar"}')],
# Third turn: final text message
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="user_message", conversation_id="conv-test-123")
async for _ in result.stream_events():
pass
assert result.final_output == "done"
# Check the first call - it should include the original input since generated_items is empty
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check the input from the last turn (third turn after function execution)
last_input = model.last_turn_args["input"]
# In conversation_id mode, the third turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
@pytest.mark.asyncio
async def test_previous_response_id_only_sends_new_items_multi_turn():
"""Test that previous_response_id mode only sends new items and updates
previous_response_id between turns."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = await Runner.run(
agent, input="user_message", previous_response_id="initial-response-123"
)
assert result.final_output == "done"
# Check the first call - it should include the original input since generated_items is empty
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check the input from the last turn (second turn after function execution)
last_input = model.last_turn_args["input"]
# In previous_response_id mode, the third turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1 # Only the function result
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
# Verify that previous_response_id is modified according to fake_model behavior
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_previous_response_id_retry_does_not_resend_initial_input_multi_turn():
class StatefulRetrySafeFakeModel(FakeModel):
def get_retry_advice(self, request):
if request.previous_response_id or request.conversation_id:
return ModelRetryAdvice(suggested=True, replay_safety="safe")
return None
model = StatefulRetrySafeFakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
model.add_multiple_turn_outputs(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
[get_text_message("done")],
]
)
result = await Runner.run(
agent, input="user_message", previous_response_id="initial-response-123"
)
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert len(last_input) == 1
assert last_input[0].get("type") == "function_call_output"
@pytest.mark.asyncio
async def test_previous_response_id_only_sends_new_items_multi_turn_streamed():
"""Test that previous_response_id mode only sends new items and updates
previous_response_id between turns (streamed mode)."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = Runner.run_streamed(
agent, input="user_message", previous_response_id="initial-response-123"
)
async for _ in result.stream_events():
pass
assert result.final_output == "done"
# Check the first call - it should include the original input since generated_items is empty
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check the input from the last turn (second turn after function execution)
last_input = model.last_turn_args["input"]
# In previous_response_id mode, the third turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1 # Only the function result
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
# Verify that previous_response_id is modified according to fake_model behavior
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_previous_response_id_retry_does_not_resend_initial_input_multi_turn_streamed():
class StatefulRetrySafeFakeModel(FakeModel):
def get_retry_advice(self, request):
if request.previous_response_id or request.conversation_id:
return ModelRetryAdvice(suggested=True, replay_safety="safe")
return None
model = StatefulRetrySafeFakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
model.add_multiple_turn_outputs(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
[get_text_message("done")],
]
)
result = Runner.run_streamed(
agent, input="user_message", previous_response_id="initial-response-123"
)
async for _ in result.stream_events():
pass
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert len(last_input) == 1
assert last_input[0].get("type") == "function_call_output"
@pytest.mark.asyncio
async def test_default_send_all_items():
"""Test that without conversation_id or previous_response_id, all items are sent."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = await Runner.run(
agent, input="user_message"
) # No conversation_id or previous_response_id
assert result.final_output == "done"
# Check the input from the last turn (second turn after function execution)
last_input = model.last_turn_args["input"]
# In default, the second turn should contain ALL items:
# 1. Original user message
# 2. Assistant response message
# 3. Function call
# 4. Function result
assert isinstance(last_input, list)
assert (
len(last_input) == 4
) # User message + assistant message + function call + function result
# Verify the items are in the expected order
user_message = last_input[0]
assistant_message = last_input[1]
function_call = last_input[2]
function_result = last_input[3]
# Check user message
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check assistant message
assert assistant_message.get("role") == "assistant"
# Check function call
assert function_call.get("name") == "test_func"
assert function_call.get("arguments") == '{"arg": "foo"}'
# Check function result
assert function_result.get("type") == "function_call_output"
assert function_result.get("call_id") is not None
@pytest.mark.asyncio
async def test_default_send_all_items_streamed():
"""Test that without conversation_id or previous_response_id, all items are sent
(streamed mode)."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = Runner.run_streamed(
agent, input="user_message"
) # No conversation_id or previous_response_id
async for _ in result.stream_events():
pass
assert result.final_output == "done"
# Check the input from the last turn (second turn after function execution)
last_input = model.last_turn_args["input"]
# In default mode, the second turn should contain ALL items:
# 1. Original user message
# 2. Assistant response message
# 3. Function call
# 4. Function result
assert isinstance(last_input, list)
assert (
len(last_input) == 4
) # User message + assistant message + function call + function result
# Verify the items are in the expected order
user_message = last_input[0]
assistant_message = last_input[1]
function_call = last_input[2]
function_result = last_input[3]
# Check user message
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# Check assistant message
assert assistant_message.get("role") == "assistant"
# Check function call
assert function_call.get("name") == "test_func"
assert function_call.get("arguments") == '{"arg": "foo"}'
# Check function result
assert function_result.get("type") == "function_call_output"
assert function_result.get("call_id") is not None
@pytest.mark.asyncio
async def test_default_multi_turn_drops_orphan_hosted_shell_calls() -> None:
model = FakeModel()
agent = Agent(
name="hosted-shell",
model=model,
tools=[ShellTool(environment={"type": "container_auto"})],
)
model.add_multiple_turn_outputs(
[
[make_shell_call("call_shell_1", id_value="shell_1", commands=["echo hi"])],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="user_message")
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert len(last_input) == 1
assert not any(
isinstance(item, dict) and item.get("type") == "shell_call" for item in last_input
)
assert last_input[0].get("role") == "user"
assert last_input[0].get("content") == "user_message"
@pytest.mark.asyncio
async def test_manual_pending_shell_call_input_is_preserved_non_streamed() -> None:
model = FakeModel()
agent = Agent(
name="manual-shell",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
pending_shell_call = cast(
TResponseInputItem,
make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]),
)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("test_func", '{"arg": "foo"}')],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input=[pending_shell_call])
assert result.final_output == "done"
assert isinstance(model.first_turn_args, dict)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in model.first_turn_args["input"]
)
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in last_input
)
@pytest.mark.asyncio
async def test_manual_pending_shell_call_input_is_preserved_non_streamed_with_session() -> None:
model = FakeModel()
agent = Agent(
name="manual-shell",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
session = SimpleListSession()
pending_shell_call = cast(
TResponseInputItem,
make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]),
)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("test_func", '{"arg": "foo"}')],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input=[pending_shell_call], session=session)
assert result.final_output == "done"
assert isinstance(model.first_turn_args, dict)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in model.first_turn_args["input"]
)
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in last_input
)
@pytest.mark.asyncio
async def test_default_multi_turn_streamed_drops_orphan_hosted_shell_calls() -> None:
model = FakeModel()
agent = Agent(
name="hosted-shell",
model=model,
tools=[ShellTool(environment={"type": "container_auto"})],
)
model.add_multiple_turn_outputs(
[
[make_shell_call("call_shell_1", id_value="shell_1", commands=["echo hi"])],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert len(last_input) == 1
assert not any(
isinstance(item, dict) and item.get("type") == "shell_call" for item in last_input
)
assert last_input[0].get("role") == "user"
assert last_input[0].get("content") == "user_message"
@pytest.mark.asyncio
async def test_manual_pending_shell_call_input_is_preserved_streamed() -> None:
model = FakeModel()
agent = Agent(name="manual-shell", model=model)
pending_shell_call = cast(
TResponseInputItem,
make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]),
)
model.set_next_output([get_text_message("done")])
result = Runner.run_streamed(agent, input=[pending_shell_call])
async for _ in result.stream_events():
pass
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in last_input
)
@pytest.mark.asyncio
async def test_manual_pending_shell_call_input_is_preserved_streamed_with_session() -> None:
model = FakeModel()
agent = Agent(name="manual-shell", model=model)
session = SimpleListSession()
pending_shell_call = cast(
TResponseInputItem,
make_shell_call("manual_shell", id_value="shell_1", commands=["echo hi"]),
)
model.set_next_output([get_text_message("done")])
result = Runner.run_streamed(agent, input=[pending_shell_call], session=session)
async for _ in result.stream_events():
pass
assert result.final_output == "done"
last_input = model.last_turn_args["input"]
assert isinstance(last_input, list)
assert any(
isinstance(item, dict)
and item.get("type") == "shell_call"
and item.get("call_id") == "manual_shell"
for item in last_input
)
@pytest.mark.asyncio
async def test_auto_previous_response_id_multi_turn():
"""Test that auto_previous_response_id=True enables
chaining from the first internal turn."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="user_message", auto_previous_response_id=True)
assert result.final_output == "done"
# Check the first call
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# With auto_previous_response_id=True, first call should NOT have previous_response_id
assert model.first_turn_args.get("previous_response_id") is None
# Check the input from the second turn (after function execution)
last_input = model.last_turn_args["input"]
# With auto_previous_response_id=True, the second turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1 # Only the function result
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
# With auto_previous_response_id=True, second call should have
# previous_response_id set to the first response
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_auto_previous_response_id_multi_turn_streamed():
"""Test that auto_previous_response_id=True enables
chaining from the first internal turn (streamed mode)."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="user_message", auto_previous_response_id=True)
async for _ in result.stream_events():
pass
assert result.final_output == "done"
# Check the first call
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# With auto_previous_response_id=True, first call should NOT have previous_response_id
assert model.first_turn_args.get("previous_response_id") is None
# Check the input from the second turn (after function execution)
last_input = model.last_turn_args["input"]
# With auto_previous_response_id=True, the second turn should only contain the tool output
assert isinstance(last_input, list)
assert len(last_input) == 1 # Only the function result
# The single item should be a tool result
tool_result_item = last_input[0]
assert tool_result_item.get("type") == "function_call_output"
assert tool_result_item.get("call_id") is not None
# With auto_previous_response_id=True, second call should have
# previous_response_id set to the first response
assert model.last_turn_args.get("previous_response_id") == "resp-789"
@pytest.mark.asyncio
async def test_without_previous_response_id_and_auto_previous_response_id_no_chaining():
"""Test that without previous_response_id and auto_previous_response_id,
internal turns don't chain."""
model = FakeModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("test_func", "tool_result")],
)
model.add_multiple_turn_outputs(
[
# First turn: a message and tool call
[get_text_message("a_message"), get_function_tool_call("test_func", '{"arg": "foo"}')],
# Second turn: final text message
[get_text_message("done")],
]
)
# Call without passing previous_response_id and without passing auto_previous_response_id
result = await Runner.run(agent, input="user_message")
assert result.final_output == "done"
# Check the first call
assert model.first_turn_args is not None
first_input = model.first_turn_args["input"]
# First call should include the original user input
assert isinstance(first_input, list)
assert len(first_input) == 1 # Should contain the user message
# The input should be the user message
user_message = first_input[0]
assert user_message.get("role") == "user"
assert user_message.get("content") == "user_message"
# First call should NOT have previous_response_id
assert model.first_turn_args.get("previous_response_id") is None
# Check the input from the second turn (after function execution)
last_input = model.last_turn_args["input"]
# Without passing previous_response_id and auto_previous_response_id,
# the second turn should contain all items (no chaining):
# user message, assistant response, function call, and tool result
assert isinstance(last_input, list)
assert len(last_input) == 4 # User message, assistant message, function call, and tool result
# Second call should also NOT have previous_response_id (no chaining)
assert model.last_turn_args.get("previous_response_id") is None
@pytest.mark.asyncio
async def test_dynamic_tool_addition_run() -> None:
"""Test that tools can be added to an agent during a run."""
model = FakeModel()
executed: dict[str, bool] = {"called": False}
agent = Agent(name="test", model=model, tool_use_behavior="run_llm_again")
@function_tool(name_override="tool2")
def tool2() -> str:
executed["called"] = True
return "result2"
@function_tool(name_override="add_tool")
async def add_tool() -> str:
agent.tools.append(tool2)
return "added"
agent.tools.append(add_tool)
model.add_multiple_turn_outputs(
[
[get_function_tool_call("add_tool", json.dumps({}))],
[get_function_tool_call("tool2", json.dumps({}))],
[get_text_message("done")],
]
)
result = await Runner.run(agent, input="start")
assert executed["called"] is True
assert result.final_output == "done"
@pytest.mark.asyncio
async def test_tool_not_found_behavior_returns_error_to_model() -> None:
model = FakeModel()
agent = Agent(name="test", model=model, tool_use_behavior="run_llm_again")
model.add_multiple_turn_outputs(
[
[get_function_tool_call("missing_tool", "{}", call_id="call_missing")],
[get_text_message("recovered")],
]
)
result = await Runner.run(
agent,
input="start",
run_config=RunConfig(tool_not_found_behavior="return_error_to_model"),
)
assert result.final_output == "recovered"
second_turn_input = model.last_turn_args["input"]
assert isinstance(second_turn_input, list)
tool_outputs = [
item
for item in second_turn_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
]
assert tool_outputs == [
{
"call_id": "call_missing",
"output": "Tool 'missing_tool' not found.",
"type": "function_call_output",
}
]
@pytest.mark.asyncio
async def test_tool_not_found_behavior_uses_tool_error_formatter() -> None:
model = FakeModel()
agent = Agent(name="test", model=model, tool_use_behavior="run_llm_again")
model.add_multiple_turn_outputs(
[
[get_function_tool_call("missing_tool", "{}", call_id="call_missing")],
[get_text_message("recovered")],
]
)
seen_kinds: list[str] = []
async def formatter(args: Any) -> str | None:
seen_kinds.append(args.kind)
if args.kind != "tool_not_found":
return None
return f"{args.tool_name} unavailable for {args.call_id}"
result = await Runner.run(
agent,
input="start",
run_config=RunConfig(
tool_not_found_behavior="return_error_to_model",
tool_error_formatter=formatter,
),
)
assert result.final_output == "recovered"
assert seen_kinds == ["tool_not_found"]
second_turn_input = model.last_turn_args["input"]
assert isinstance(second_turn_input, list)
tool_outputs = [
item
for item in second_turn_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
]
assert tool_outputs == [
{
"call_id": "call_missing",
"output": "missing_tool unavailable for call_missing",
"type": "function_call_output",
}
]
@pytest.mark.asyncio
async def test_tool_not_found_behavior_handles_mixed_function_tool_calls() -> None:
model = FakeModel()
calls: list[str] = []
@function_tool(name_override="known_tool")
async def known_tool() -> str:
calls.append("known_tool")
return "known result"
agent = Agent(
name="test",
model=model,
tools=[known_tool],
tool_use_behavior="run_llm_again",
)
model.add_multiple_turn_outputs(
[
[
get_function_tool_call("missing_tool", "{}", call_id="call_missing"),
get_function_tool_call("known_tool", "{}", call_id="call_known"),
],
[get_text_message("done")],
]
)
result = await Runner.run(
agent,
input="start",
run_config=RunConfig(tool_not_found_behavior="return_error_to_model"),
)
assert calls == ["known_tool"]
assert result.final_output == "done"
second_turn_input = model.last_turn_args["input"]
assert isinstance(second_turn_input, list)
tool_outputs = {
item.get("call_id"): item.get("output")
for item in second_turn_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
}
assert tool_outputs == {
"call_known": "known result",
"call_missing": "Tool 'missing_tool' not found.",
}
@pytest.mark.asyncio
async def test_session_add_items_called_multiple_times_for_multi_turn_completion():
"""Test that SQLiteSession.add_items is called multiple times
during a multi-turn agent completion.
"""
with tempfile.TemporaryDirectory() as temp_dir:
db_path = Path(temp_dir) / "test_agent_runner_session_multi_turn_calls.db"
session_id = "runner_session_multi_turn_calls"
session = SQLiteSession(session_id, db_path)
# Define a tool that will be called by the orchestrator agent
@function_tool
async def echo_tool(text: str) -> str:
return f"Echo: {text}"
# Orchestrator agent that calls the tool multiple times in one completion
orchestrator_agent = Agent(
name="orchestrator_agent",
instructions=(
"Call echo_tool twice with inputs of 'foo' and 'bar', then return a summary."
),
tools=[echo_tool],
)
# Patch the model to simulate two tool calls and a final message
model = FakeModel()
orchestrator_agent.model = model
model.add_multiple_turn_outputs(
[
# First turn: tool call
[get_function_tool_call("echo_tool", json.dumps({"text": "foo"}), call_id="1")],
# Second turn: tool call
[get_function_tool_call("echo_tool", json.dumps({"text": "bar"}), call_id="2")],
# Third turn: final output
[get_final_output_message("Summary: Echoed foo and bar")],
]
)
# Patch add_items to count calls
with patch.object(SQLiteSession, "add_items", wraps=session.add_items) as mock_add_items:
result = await Runner.run(orchestrator_agent, input="foo and bar", session=session)
expected_items = [
{"content": "foo and bar", "role": "user"},
{
"arguments": '{"text": "foo"}',
"call_id": "1",
"name": "echo_tool",
"type": "function_call",
"id": "1",
},
{"call_id": "1", "output": "Echo: foo", "type": "function_call_output"},
{
"arguments": '{"text": "bar"}',
"call_id": "2",
"name": "echo_tool",
"type": "function_call",
"id": "1",
},
{"call_id": "2", "output": "Echo: bar", "type": "function_call_output"},
{
"id": "1",
"content": [
{
"annotations": [],
"logprobs": [],
"text": "Summary: Echoed foo and bar",
"type": "output_text",
}
],
"role": "assistant",
"status": "completed",
"type": "message",
},
]
expected_calls = [
# First call is the initial input
(([expected_items[0]],),),
# Second call is the first tool call and its result
(([expected_items[1], expected_items[2]],),),
# Third call is the second tool call and its result
(([expected_items[3], expected_items[4]],),),
# Fourth call is the final output
(([expected_items[5]],),),
]
assert mock_add_items.call_args_list == expected_calls
assert result.final_output == "Summary: Echoed foo and bar"
assert (await session.get_items()) == expected_items
session.close()
@pytest.mark.asyncio
async def test_execute_approved_tools_with_non_function_tool():
"""Test _execute_approved_tools handles non-FunctionTool."""
model = FakeModel()
# Create a computer tool (not a FunctionTool)
class MockComputer(Computer):
@property
def environment(self) -> str: # type: ignore[override]
return "mac"
@property
def dimensions(self) -> tuple[int, int]:
return (1920, 1080)
def screenshot(self) -> str:
return "screenshot"
def click(self, x: int, y: int, button: str) -> None:
pass
def double_click(self, x: int, y: int) -> None:
pass
def drag(self, path: list[tuple[int, int]]) -> None:
pass
def keypress(self, keys: list[str]) -> None:
pass
def move(self, x: int, y: int) -> None:
pass
def scroll(self, x: int, y: int, scroll_x: int, scroll_y: int) -> None:
pass
def type(self, text: str) -> None:
pass
def wait(self) -> None:
pass
computer = MockComputer()
computer_tool = ComputerTool(computer=computer)
agent = Agent(name="TestAgent", model=model, tools=[computer_tool])
# Create an approved tool call for the computer tool
# ComputerTool is not a function tool and should still fail approval execution cleanly.
tool_call = get_function_tool_call(computer_tool.name, "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
# Should add error message about tool not being a function tool
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "not a function tool" in generated_items[0].output.lower()
@pytest.mark.asyncio
async def test_execute_approved_tools_with_rejected_tool():
"""Test _execute_approved_tools handles rejected tools."""
tool_called = False
async def test_tool() -> str:
nonlocal tool_called
tool_called = True
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
# Create a rejected tool call
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
)
# Should add rejection message
assert len(generated_items) == 1
assert "not approved" in generated_items[0].output.lower()
assert not tool_called # Tool should not have been executed
@pytest.mark.asyncio
async def test_execute_approved_tools_with_rejected_tool_uses_run_level_formatter():
"""Rejected tools should prefer RunConfig tool error formatter output."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
run_config=RunConfig(
tool_error_formatter=lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
),
)
assert len(generated_items) == 1
assert generated_items[0].output == "run-level test_tool denied (2)"
@pytest.mark.asyncio
async def test_execute_approved_tools_with_rejected_tool_prefers_explicit_message():
"""Rejected tools should prefer explicit rejection messages over the formatter."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
run_config=RunConfig(
tool_error_formatter=lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
),
mutate_state=lambda state, item: state.reject(
item, rejection_message="explicit rejection message"
),
)
assert len(generated_items) == 1
assert generated_items[0].output == "explicit rejection message"
@pytest.mark.asyncio
async def test_execute_approved_tools_with_rejected_deferred_tool_uses_display_name():
"""Rejected deferred tools should collapse synthetic namespaces in formatter output."""
async def get_weather() -> str:
return "sunny"
tool = function_tool(get_weather, name_override="get_weather", defer_loading=True)
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("get_weather", "{}", namespace="get_weather")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(
agent=agent,
raw_item=tool_call,
tool_name="get_weather",
tool_namespace="get_weather",
)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
run_config=RunConfig(
tool_error_formatter=lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
),
)
assert len(generated_items) == 1
assert generated_items[0].output == "run-level get_weather denied (2)"
@pytest.mark.asyncio
async def test_execute_approved_tools_with_rejected_tool_formatter_none_uses_default():
"""Rejected tools should use default message when formatter returns None."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
run_config=RunConfig(tool_error_formatter=lambda _args: None),
)
assert len(generated_items) == 1
assert generated_items[0].output == "Tool execution was not approved."
@pytest.mark.asyncio
async def test_execute_approved_tools_with_unclear_status():
"""Test _execute_approved_tools handles unclear approval status."""
tool_called = False
async def test_tool() -> str:
nonlocal tool_called
tool_called = True
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
# Create a tool call with unclear status (neither approved nor rejected)
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=None,
)
# Should add unclear status message
assert len(generated_items) == 1
assert "unclear" in generated_items[0].output.lower()
assert not tool_called # Tool should not have been executed
@pytest.mark.asyncio
async def test_execute_approved_tools_with_missing_tool():
"""Test _execute_approved_tools handles missing tools."""
_, agent = make_model_and_agent()
# Agent has no tools
# Create an approved tool call for a tool that doesn't exist
tool_call = get_function_tool_call("nonexistent_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
# Should add error message about tool not found
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "not found" in generated_items[0].output.lower()
@pytest.mark.asyncio
async def test_execute_approved_tools_does_not_resolve_explicit_namespaced_tool_by_bare_name():
crm_calls: list[str] = []
billing_calls: list[str] = []
async def crm_lookup() -> str:
crm_calls.append("crm")
return "crm"
async def billing_lookup() -> str:
billing_calls.append("billing")
return "billing"
crm_tool = tool_namespace(
name="crm",
description="CRM tools",
tools=[function_tool(crm_lookup, name_override="lookup_account")],
)[0]
billing_tool = tool_namespace(
name="billing",
description="Billing tools",
tools=[function_tool(billing_lookup, name_override="lookup_account")],
)[0]
agent = Agent(name="TestAgent", model=FakeModel(), tools=[crm_tool, billing_tool])
tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-ambiguous")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "not found" in generated_items[0].output.lower()
assert crm_calls == []
assert billing_calls == []
@pytest.mark.asyncio
async def test_execute_approved_tools_does_not_fallback_from_namespaced_approval_to_bare_tool():
bare_calls: list[str] = []
async def bare_lookup() -> str:
bare_calls.append("bare")
return "bare"
bare_tool = function_tool(bare_lookup, name_override="lookup_account")
agent = Agent(name="TestAgent", model=FakeModel(), tools=[bare_tool])
tool_call = get_function_tool_call(
"lookup_account",
"{}",
call_id="call-billing",
namespace="billing",
)
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "billing.lookup_account" in generated_items[0].output
assert "not found" in generated_items[0].output.lower()
assert bare_calls == []
@pytest.mark.asyncio
async def test_execute_approved_tools_prefers_visible_top_level_function_over_deferred_same_name_tool( # noqa: E501
):
visible_calls: list[str] = []
deferred_calls: list[str] = []
async def visible_lookup() -> str:
visible_calls.append("visible")
return "visible"
async def deferred_lookup() -> str:
deferred_calls.append("deferred")
return "deferred"
visible_tool = function_tool(visible_lookup, name_override="lookup_account")
deferred_tool = function_tool(
deferred_lookup,
name_override="lookup_account",
defer_loading=True,
)
agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool])
tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-visible")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert generated_items[0].output == "visible"
assert visible_calls == ["visible"]
assert deferred_calls == []
@pytest.mark.asyncio
async def test_execute_approved_tools_uses_internal_lookup_key_for_deferred_top_level_calls() -> (
None
):
visible_calls: list[str] = []
deferred_calls: list[str] = []
async def visible_lookup() -> str:
visible_calls.append("visible")
return "visible"
async def deferred_lookup() -> str:
deferred_calls.append("deferred")
return "deferred"
visible_tool = function_tool(
visible_lookup,
name_override="lookup_account.lookup_account",
)
deferred_tool = function_tool(
deferred_lookup,
name_override="lookup_account",
defer_loading=True,
)
agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool])
tool_call = get_function_tool_call(
"lookup_account",
"{}",
call_id="call-deferred",
namespace="lookup_account",
)
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert generated_items[0].output == "deferred"
assert visible_calls == []
assert deferred_calls == ["deferred"]
@pytest.mark.asyncio
async def test_deferred_collision_rejection_prefers_explicit_message() -> None:
async def visible_lookup() -> str:
return "visible"
async def deferred_lookup() -> str:
return "deferred"
visible_tool = function_tool(
visible_lookup,
name_override="lookup_account.lookup_account",
)
deferred_tool = function_tool(
deferred_lookup,
name_override="lookup_account",
defer_loading=True,
)
agent = Agent(name="TestAgent", model=FakeModel(), tools=[visible_tool, deferred_tool])
tool_call = get_function_tool_call(
"lookup_account",
"{}",
call_id="call-deferred",
namespace="lookup_account",
)
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(
agent=agent,
raw_item=tool_call,
tool_name="lookup_account",
tool_namespace="lookup_account",
tool_lookup_key=("deferred_top_level", "lookup_account"),
)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=False,
run_config=RunConfig(
tool_error_formatter=lambda args: f"run-level {args.tool_name} denied ({args.call_id})"
),
mutate_state=lambda state, item: state.reject(
item, rejection_message="explicit rejection message"
),
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert generated_items[0].output == "explicit rejection message"
@pytest.mark.asyncio
async def test_execute_approved_tools_uses_last_duplicate_top_level_function():
first_calls: list[str] = []
second_calls: list[str] = []
async def first_lookup() -> str:
first_calls.append("first")
return "first"
async def second_lookup() -> str:
second_calls.append("second")
return "second"
first_tool = function_tool(first_lookup, name_override="lookup_account")
second_tool = function_tool(second_lookup, name_override="lookup_account")
agent = Agent(name="TestAgent", model=FakeModel(), tools=[first_tool, second_tool])
tool_call = get_function_tool_call("lookup_account", "{}", call_id="call-shadow")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert generated_items[0].output == "second"
assert first_calls == []
assert second_calls == ["second"]
@pytest.mark.asyncio
async def test_execute_approved_tools_with_missing_call_id():
"""Test _execute_approved_tools handles tool approvals without call IDs."""
_, agent = make_model_and_agent()
tool_call = {"type": "function_call", "name": "test_tool"}
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "missing call id" in generated_items[0].output.lower()
@pytest.mark.asyncio
async def test_execute_approved_tools_with_invalid_raw_item_type():
"""Test _execute_approved_tools handles approvals with unsupported raw_item types."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
tool_call = {"type": "function_call", "name": "test_tool", "call_id": "call-1"}
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "invalid raw_item type" in generated_items[0].output.lower()
@pytest.mark.asyncio
async def test_execute_approved_tools_instance_method():
"""Ensure execute_approved_tools runs approved tools as expected."""
tool_called = False
async def test_tool() -> str:
nonlocal tool_called
tool_called = True
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", json.dumps({}))
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
# Tool should have been called
assert tool_called is True
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert generated_items[0].output == "tool_result"
@pytest.mark.asyncio
async def test_execute_approved_tools_timeout_returns_error_as_result() -> None:
async def slow_tool() -> str:
await asyncio.sleep(0.2)
return "tool_result"
tool = function_tool(slow_tool, name_override="test_tool", timeout=0.01)
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", json.dumps({}))
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
generated_items = await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)
assert len(generated_items) == 1
assert isinstance(generated_items[0], ToolCallOutputItem)
assert "timed out" in generated_items[0].output.lower()
@pytest.mark.asyncio
async def test_execute_approved_tools_timeout_can_raise_exception() -> None:
async def slow_tool() -> str:
await asyncio.sleep(0.2)
return "tool_result"
tool = function_tool(
slow_tool,
name_override="test_tool",
timeout=0.01,
timeout_behavior="raise_exception",
)
_, agent = make_model_and_agent(tools=[tool])
tool_call = get_function_tool_call("test_tool", json.dumps({}))
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
with pytest.raises(ToolTimeoutError, match="timed out"):
await run_execute_approved_tools(
agent=agent,
approval_item=approval_item,
approve=True,
)