Files
openai--openai-agents-python/tests/test_agent_runner_streamed.py

4305 lines
145 KiB
Python

from __future__ import annotations
import asyncio
import contextlib
import json
import logging
from collections.abc import AsyncIterator
from typing import Any, cast
import httpx
import pytest
from openai import APIConnectionError, BadRequestError, NotFoundError
from openai.types.responses import (
ResponseCompletedEvent,
ResponseErrorEvent,
ResponseFailedEvent,
ResponseFunctionToolCall,
ResponseIncompleteEvent,
)
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
from typing_extensions import TypedDict
import agents._debug as _debug
from agents import (
Agent,
GuardrailFunctionOutput,
Handoff,
HandoffInputData,
InputGuardrail,
InputGuardrailTripwireTriggered,
MaxTurnsExceeded,
ModelBehaviorError,
ModelRetrySettings,
ModelSettings,
OpenAIChatCompletionsModel,
OpenAIResponsesWSModel,
OutputGuardrail,
OutputGuardrailTripwireTriggered,
RunContextWrapper,
Runner,
SQLiteSession,
ToolGuardrailFunctionOutput,
ToolInputGuardrailData,
ToolOutputGuardrailData,
ToolsToFinalOutputResult,
UserError,
function_tool,
handoff,
retry_policies,
)
from agents.items import RunItem, ToolApprovalItem, TResponseInputItem, TResponseStreamEvent
from agents.memory.openai_conversations_session import OpenAIConversationsSession
from agents.models.interface import Model
from agents.run import RunConfig
from agents.run_internal import run_loop
from agents.run_internal.run_loop import QueueCompleteSentinel
from agents.run_state import RunState
from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent, StreamEvent
from agents.testing import ModelStep, ScriptedModel
from agents.tool import FunctionTool
from agents.tool_guardrails import (
ToolOutputGuardrailResult,
tool_input_guardrail,
tool_output_guardrail,
)
from agents.usage import Usage, _attach_raw_usage_snapshot
from tests.model_test_helpers import get_response_obj
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.hitl import (
consume_stream,
make_model_and_agent,
queue_function_call_and_text,
resume_streamed_after_first_approval,
)
from .utils.simple_session import CountingSession, SimpleListSession
def _conversation_locked_error() -> BadRequestError:
request = httpx.Request("POST", "https://example.com")
response = httpx.Response(
400,
request=request,
json={"error": {"code": "conversation_locked", "message": "locked"}},
)
error = BadRequestError(
"locked",
response=response,
body={"error": {"code": "conversation_locked"}},
)
error.code = "conversation_locked"
return error
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
class _DummyWSClient:
"""Stand-in for `AsyncOpenAI` that the websocket model only reads connection settings from."""
def __init__(self) -> None:
self.base_url = httpx.URL("https://api.openai.com/v1/")
self.websocket_base_url = None
self.default_query: dict[str, Any] = {}
self.default_headers = {
"Authorization": "Bearer test-key",
"User-Agent": "AsyncOpenAI/Python test",
}
self.timeout: Any = None
async def _refresh_api_key(self) -> None:
return None
def _ws_terminal_response_frame(event_type: str, response_id: str, sequence_number: int) -> str:
response = get_response_obj([get_text_message("partial final")], response_id=response_id)
return json.dumps(
{
"type": event_type,
"response": response.model_dump(),
"sequence_number": sequence_number,
}
)
@pytest.mark.asyncio
async def test_simple_first_run():
model = ScriptedModel()
agent = Agent(
name="test",
model=model,
)
model.enqueue([get_text_message("first")])
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
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.enqueue([get_text_message("second")])
result = Runner.run_streamed(
agent, input=[get_text_input_item("message"), get_text_input_item("another_message")]
)
async for _ in result.stream_events():
pass
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_empty_list_input_reaches_model():
model = ScriptedModel()
agent = Agent(name="test", model=model)
model.enqueue([get_text_message("first")])
result = Runner.run_streamed(agent, input=[])
async for _ in result.stream_events():
pass
assert result.final_output == "first"
assert model.calls[-1].input == []
@pytest.mark.asyncio
async def test_streamed_tool_not_found_behavior_returns_error_to_model() -> None:
model = ScriptedModel()
agent = Agent(name="test", model=model)
model.extend(
[
[get_function_tool_call("missing_tool", "{}", call_id="call_missing")],
[get_text_message("recovered")],
]
)
result = Runner.run_streamed(
agent,
input="start",
run_config=RunConfig(tool_not_found_behavior="return_error_to_model"),
)
async for _ in result.stream_events():
pass
assert result.final_output == "recovered"
second_turn_input = model.calls[-1].input
assert isinstance(second_turn_input, list)
assert {
item.get("call_id"): item.get("output")
for item in second_turn_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
} == {"call_missing": "Tool 'missing_tool' not found."}
@pytest.mark.asyncio
@pytest.mark.parametrize(
("terminal_event_type", "terminal_event_cls"),
[
("response.incomplete", ResponseIncompleteEvent),
("response.failed", ResponseFailedEvent),
],
)
async def test_streamed_run_rejects_failed_terminal_response_payload_events(
terminal_event_type: str, terminal_event_cls: type[Any]
) -> None:
class TerminalPayloadScriptedModel(ScriptedModel):
async def stream_response(
self,
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
*,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
self.last_turn_args = {
"system_instructions": system_instructions,
"input": input,
"model_settings": model_settings,
"tools": tools,
"output_schema": output_schema,
"previous_response_id": previous_response_id,
"conversation_id": conversation_id,
}
if not self.calls:
self.first_turn_args = self.last_turn_args.copy()
response = get_response_obj(
[get_text_message("partial final")], response_id="resp-partial"
)
yield terminal_event_cls(
type=terminal_event_type,
response=response,
sequence_number=0,
)
model = TerminalPayloadScriptedModel()
agent = Agent(name="test", model=model)
result = Runner.run_streamed(agent, input="test")
stream_events: list[StreamEvent] = []
with pytest.raises(ModelBehaviorError, match=terminal_event_type):
async for event in result.stream_events():
stream_events.append(event)
assert len(stream_events) == 2
assert isinstance(stream_events[0], AgentUpdatedStreamEvent)
assert isinstance(stream_events[1], RawResponsesStreamEvent)
assert stream_events[1].data.type == terminal_event_type
assert result.final_output is None
assert result.raw_responses == []
@pytest.mark.asyncio
async def test_streamed_run_rejects_response_error_terminal_event() -> None:
class TerminalErrorScriptedModel(ScriptedModel):
async def stream_response(
self,
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
*,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
self.last_turn_args = {
"system_instructions": system_instructions,
"input": input,
"model_settings": model_settings,
"tools": tools,
"output_schema": output_schema,
"previous_response_id": previous_response_id,
"conversation_id": conversation_id,
}
if not self.calls:
self.first_turn_args = self.last_turn_args.copy()
yield ResponseErrorEvent(
type="error",
code="invalid_request_error",
message="bad request",
param=None,
sequence_number=0,
)
model = TerminalErrorScriptedModel()
agent = Agent(name="test", model=model)
result = Runner.run_streamed(agent, input="test")
stream_events: list[StreamEvent] = []
with pytest.raises(ModelBehaviorError, match="error"):
async for event in result.stream_events():
stream_events.append(event)
assert len(stream_events) == 2
assert isinstance(stream_events[0], AgentUpdatedStreamEvent)
assert isinstance(stream_events[1], RawResponsesStreamEvent)
assert stream_events[1].data.type == "error"
assert stream_events[1].data.code == "invalid_request_error"
assert stream_events[1].data.message == "bad request"
assert result.final_output is None
assert result.raw_responses == []
@pytest.mark.asyncio
@pytest.mark.parametrize("preserve_raw_usage", [None, False, True])
async def test_streamed_run_exposes_request_id_on_raw_responses(
preserve_raw_usage: bool | None,
) -> None:
class RequestIdTerminalScriptedModel(ScriptedModel):
async def stream_response(
self,
system_instructions,
input,
model_settings,
tools,
output_schema,
handoffs,
tracing,
*,
previous_response_id=None,
conversation_id=None,
prompt=None,
):
response = get_response_obj(
[get_text_message("partial final")], response_id="resp-partial"
)
response._request_id = "req_streamed_result_123"
_attach_raw_usage_snapshot(
response,
{"input_tokens": 3, "input_tokens_details": {"cached_tokens": 0}},
)
yield ResponseCompletedEvent(
type="response.completed",
response=response,
sequence_number=0,
)
model = RequestIdTerminalScriptedModel()
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(preserve_raw_usage=preserve_raw_usage),
)
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
assert len(result.raw_responses) == 1
assert result.raw_responses[0].request_id == "req_streamed_result_123"
assert result.raw_responses[0].raw_usage == (
{
"input_tokens": 3,
"input_tokens_details": {"cached_tokens": 0},
}
if preserve_raw_usage is True
else None
)
@pytest.mark.asyncio
async def test_streamed_run_preserves_request_usage_entries_after_retry() -> None:
model = ScriptedModel()
model.set_default_usage(
Usage(
requests=1,
input_tokens=10,
output_tokens=5,
total_tokens=15,
)
)
model.extend(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[get_text_message("done")],
]
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
usage = result.context_wrapper.usage
assert usage.requests == 2
assert len(usage.request_usage_entries) == 2
assert usage.request_usage_entries[0].total_tokens == 0
assert usage.request_usage_entries[1].input_tokens == 10
assert usage.request_usage_entries[1].output_tokens == 5
assert usage.request_usage_entries[1].total_tokens == 15
@pytest.mark.asyncio
async def test_streamed_run_counts_retry_attempts_when_terminal_usage_missing() -> None:
"""Retry accounting must survive successful streams that omit Response.usage.
Non-OpenAI chat-completions adapters (e.g. LiteLLM) can complete a stream without a usage
chunk, leaving ``Response.usage`` as ``None``. Failed retry attempts must still be counted,
matching the non-streaming ``apply_retry_attempt_usage`` path.
"""
async def missing_usage_stream(_call) -> AsyncIterator[TResponseStreamEvent]:
response = get_response_obj([get_text_message("done")])
response.usage = None
yield ResponseCompletedEvent(
type="response.completed",
response=response,
sequence_number=0,
)
model = ScriptedModel(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
ModelStep.stream(missing_usage_stream),
]
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
usage = result.context_wrapper.usage
assert len(model.calls) == 2
assert usage.requests == 2
assert len(usage.request_usage_entries) == 2
assert usage.request_usage_entries[0].total_tokens == 0
assert usage.request_usage_entries[1].total_tokens == 0
@pytest.mark.asyncio
async def test_streamed_model_retry_does_not_rewind_committed_session_input() -> None:
model = ScriptedModel()
model.extend(
[
APIConnectionError(
message="connection error",
request=httpx.Request("POST", "https://example.com"),
),
[get_text_message("done")],
]
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
session = CountingSession(history=[get_text_input_item("previous")])
result = Runner.run_streamed(agent, input="test", session=session)
async for _ in result.stream_events():
pass
saved_items = await session.get_items()
assert [item.get("role") for item in saved_items] == ["user", "user", "assistant"]
assert session.pop_calls == 0
@pytest.mark.asyncio
async def test_streamed_run_preserves_request_usage_entries_after_conversation_locked_retry() -> (
None
):
model = ScriptedModel()
model.set_default_usage(
Usage(
requests=1,
input_tokens=10,
output_tokens=5,
total_tokens=15,
)
)
model.extend(
[
_conversation_locked_error(),
[get_text_message("done")],
]
)
agent = Agent(
name="test",
model=model,
model_settings=ModelSettings(
retry=ModelRetrySettings(
max_retries=1,
policy=retry_policies.network_error(),
)
),
)
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
usage = result.context_wrapper.usage
assert usage.requests == 2
assert len(usage.request_usage_entries) == 2
assert usage.request_usage_entries[0].total_tokens == 0
assert usage.request_usage_entries[1].input_tokens == 10
assert usage.request_usage_entries[1].output_tokens == 5
assert usage.request_usage_entries[1].total_tokens == 15
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
@pytest.mark.parametrize("terminal_event_type", ["response.incomplete", "response.failed"])
async def test_streamed_run_rejects_failed_terminal_response_payload_events_from_ws_model(
monkeypatch, terminal_event_type: str
) -> None:
class DummyWSConnection:
def __init__(self, frames: list[str]):
self._frames = frames
self.close_code: int | None = None
async def send(self, payload: str) -> None:
return None
async def recv(self) -> str:
if not self._frames:
raise RuntimeError("No more websocket frames configured")
return self._frames.pop(0)
async def close(self) -> None:
if self.close_code is None:
self.close_code = 1000
ws = DummyWSConnection([_ws_terminal_response_frame(terminal_event_type, "resp-ws", 1)])
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=_DummyWSClient()) # type: ignore[arg-type]
async def fake_open(
_ws_url: str,
_headers: dict[str, str],
*,
connect_timeout: float | None = None,
) -> DummyWSConnection:
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
agent = Agent(name="test", model=model)
result = Runner.run_streamed(agent, input="test")
stream_events: list[StreamEvent] = []
with pytest.raises(ModelBehaviorError, match=terminal_event_type):
async for event in result.stream_events():
stream_events.append(event)
assert len(stream_events) == 2
assert isinstance(stream_events[0], AgentUpdatedStreamEvent)
assert isinstance(stream_events[1], RawResponsesStreamEvent)
assert stream_events[1].data.type == terminal_event_type
assert result.final_output is None
assert result.raw_responses == []
@pytest.mark.allow_call_model_methods
@pytest.mark.asyncio
async def test_ws_terminal_failure_frees_the_request_lock_for_a_waiting_run(monkeypatch) -> None:
"""A terminal failure must hand the shared websocket back to a run already waiting for it.
Run A holds `_ws_request_lock` while run B blocks on it. A then fails terminally, and B has
to complete on the surviving connection, which only happens if A's cleanup actually ran.
"""
release_run_a = asyncio.Event()
run_a_holds_lock = asyncio.Event()
run_b_waiting_on_lock = asyncio.Event()
class SharedWSConnection:
def __init__(self) -> None:
self.close_code: int | None = None
self.send_calls = 0
self.recv_calls = 0
async def send(self, payload: str) -> None:
self.send_calls += 1
if self.send_calls == 1:
run_a_holds_lock.set()
return None
async def recv(self) -> str:
self.recv_calls += 1
if self.recv_calls == 1:
# Keep run A in-flight until run B is queued behind the request lock.
await release_run_a.wait()
return _ws_terminal_response_frame("response.incomplete", "resp-a", 1)
return _ws_terminal_response_frame("response.completed", "resp-b", 1)
async def close(self) -> None:
if self.close_code is None:
self.close_code = 1000
ws = SharedWSConnection()
open_calls = 0
model = OpenAIResponsesWSModel(model="gpt-4", openai_client=_DummyWSClient()) # type: ignore[arg-type]
request_lock = model._get_ws_request_lock()
async def fake_open(
_ws_url: str,
_headers: dict[str, str],
*,
connect_timeout: float | None = None,
) -> SharedWSConnection:
nonlocal open_calls
open_calls += 1
return ws
monkeypatch.setattr(model, "_open_websocket_connection", fake_open)
original_await_websocket_with_timeout = model._await_websocket_with_timeout
async def observed_await_websocket_with_timeout(
awaitable: Any,
timeout_seconds: float | None,
phase: str,
) -> Any:
if phase == "request lock wait" and request_lock.locked():
run_b_waiting_on_lock.set()
return await original_await_websocket_with_timeout(awaitable, timeout_seconds, phase)
monkeypatch.setattr(
model, "_await_websocket_with_timeout", observed_await_websocket_with_timeout
)
agent = Agent(name="test", model=model)
async def run_a() -> None:
result = Runner.run_streamed(agent, input="a")
with pytest.raises(ModelBehaviorError, match="response.incomplete"):
async for _ in result.stream_events():
pass
async def run_b() -> tuple[Any, list[str | None]]:
result = Runner.run_streamed(agent, input="b")
async for _ in result.stream_events():
pass
return result.final_output, [response.response_id for response in result.raw_responses]
task_a = asyncio.create_task(run_a())
try:
await asyncio.wait_for(run_a_holds_lock.wait(), timeout=5)
assert request_lock.locked(), "run A released the request lock before run B started"
task_b = asyncio.create_task(run_b())
await asyncio.wait_for(run_b_waiting_on_lock.wait(), timeout=5)
assert not task_b.done(), "run B was expected to be waiting on the request lock"
finally:
release_run_a.set()
await task_a
try:
# Only a hang guard: run B needs event loop turns, not wall-clock time.
output, response_ids = await asyncio.wait_for(task_b, timeout=5)
except TimeoutError:
pytest.fail("run A's terminal failure left `_ws_request_lock` held, so run B never woke")
assert output == "partial final", "run B did not complete on the surviving connection"
assert response_ids == ["resp-b"], "run B did not receive its own response"
assert not request_lock.locked(), "the request lock was left held after both runs finished"
assert ws.close_code is None, "the shared connection should survive a terminal failure"
assert open_calls == 1, "run B should reuse the websocket connection opened by run A"
assert ws.send_calls == 2, "run B should complete on the existing websocket connection"
def _terminal_failure_event(event_type: str) -> Any:
"""Build the terminal event the streamed run loop rejects for `event_type`."""
if event_type == "response.incomplete":
return ResponseIncompleteEvent(
response=get_response_obj([], response_id="resp-terminal"),
sequence_number=0,
type="response.incomplete",
)
if event_type == "response.failed":
return ResponseFailedEvent(
response=get_response_obj([], response_id="resp-terminal"),
sequence_number=0,
type="response.failed",
)
if event_type == "error":
return ResponseErrorEvent(
code=None, message="boom", param=None, sequence_number=0, type="error"
)
# `response.error` is not a literal the SDK models, but the run loop rejects it too.
return ResponseErrorEvent.model_construct(
code=None, message="boom", param=None, sequence_number=0, type="response.error"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"terminal_event_type",
["response.incomplete", "response.failed", "error", "response.error"],
)
async def test_streamed_terminal_failure_closes_the_model_stream(terminal_event_type: str) -> None:
"""The run loop must close the model stream itself before a terminal failure leaves the loop.
Comparing the closing task with the iterating one keeps this honest: an abandoned generator
is still finalized eventually, but by asyncio's async-generator hook, in another task and
context, which is what leaves the response span unended.
"""
iterating_task: asyncio.Task[Any] | None = None
closing_task: asyncio.Task[Any] | None = None
class TerminalFailureModel(Model):
async def get_response(self, *args: Any, **kwargs: Any) -> Any:
raise NotImplementedError
async def stream_response(self, *args: Any, **kwargs: Any) -> AsyncIterator[Any]:
nonlocal iterating_task, closing_task
iterating_task = asyncio.current_task()
try:
yield _terminal_failure_event(terminal_event_type)
finally:
closing_task = asyncio.current_task()
agent = Agent(name="test", model=TerminalFailureModel())
result = Runner.run_streamed(agent, input="test")
with pytest.raises(ModelBehaviorError, match=terminal_event_type):
async for _ in result.stream_events():
pass
assert closing_task is not None and closing_task is iterating_task, (
"the run loop must close the model stream in its own task; it was left open or finalized "
"later by asyncio's async-generator hook"
)
@pytest.mark.asyncio
async def test_subsequent_runs():
model = ScriptedModel()
agent = Agent(
name="test",
model=model,
)
model.enqueue([get_text_message("third")])
result = Runner.run_streamed(agent, input="test")
async for _ in result.stream_events():
pass
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.enqueue([get_text_message("fourth")])
result = Runner.run_streamed(agent, input=result.to_input_list())
async for _ in result.stream_events():
pass
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 = ScriptedModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.extend(
[
# 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 = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
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_streamed_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 = ScriptedModel()
agent = Agent(
name="test",
model=model,
tools=[
function_tool(_ok_tool, name_override="ok_tool"),
function_tool(_cancel_tool, name_override="cancel_tool"),
],
)
model.extend(
[
[
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 = Runner.run_streamed(agent, input="user_message")
await consume_stream(result)
assert result.final_output == "final answer"
assert len(result.raw_responses) == 2
second_turn_input = cast(list[dict[str, Any]], model.calls[-1].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_streamed_single_tool_call_with_cancelled_tool_reaches_final_output() -> None:
async def _cancel_tool() -> str:
raise asyncio.CancelledError("tool-cancelled")
model = ScriptedModel()
agent = Agent(
name="test",
model=model,
tools=[function_tool(_cancel_tool, name_override="cancel_tool")],
)
model.extend(
[
[get_function_tool_call("cancel_tool", "{}", call_id="call_cancel")],
[get_text_message("final answer")],
]
)
result = Runner.run_streamed(agent, input="user_message")
await consume_stream(result)
assert result.final_output == "final answer"
assert len(result.raw_responses) == 2
second_turn_input = cast(list[dict[str, Any]], model.calls[-1].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_streamed_reasoning_item_id_policy_omits_follow_up_reasoning_ids() -> None:
model = ScriptedModel()
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
model.extend(
[
[
ResponseReasoningItem(
id="rs_stream",
type="reasoning",
summary=[Summary(text="Thinking...", type="summary_text")],
),
get_function_tool_call("foo", json.dumps({"a": "b"}), call_id="call_stream"),
],
[get_text_message("done")],
]
)
result = Runner.run_streamed(
agent,
input="hello",
run_config=RunConfig(reasoning_item_id_policy="omit"),
)
async for _ in result.stream_events():
pass
assert result.final_output == "done"
second_request_reasoning = _find_reasoning_input_item(model.calls[-1].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
class _StreamedRevokedReasoningIdModel(ScriptedModel):
"""ScriptedModel that 404s like the Responses API when a revoked reasoning ID is replayed."""
def __init__(self) -> None:
super().__init__()
self.revoked_reasoning_ids: set[str] = set()
def stream_response(
self,
system_instructions: str | None,
input: str | list[TResponseInputItem],
*args: Any,
**kwargs: Any,
) -> AsyncIterator[TResponseStreamEvent]:
if isinstance(input, list):
for item in input:
if not isinstance(item, dict) or item.get("type") != "reasoning":
continue
item_id = item.get("id")
if item_id in self.revoked_reasoning_ids:
message = f"Item with id '{item_id}' not found."
body = {"error": {"message": message, "type": "invalid_request_error"}}
raise NotFoundError(
message,
response=httpx.Response(
404,
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
json=body,
),
body=body,
)
return super().stream_response(system_instructions, input, *args, **kwargs)
@pytest.mark.asyncio
async def test_streamed_omit_policy_strips_reasoning_ids_already_stored_in_the_session() -> None:
"""Adopting `omit` must also cover reasoning IDs a session recorded before it was set.
Streaming counterpart of the non-streamed regression test for
https://github.com/openai/openai-agents-python/issues/2020.
"""
model = _StreamedRevokedReasoningIdModel()
specialist = Agent(name="specialist", model=model)
triage = Agent(name="triage", model=model, handoffs=[specialist])
session = SQLiteSession("issue-2020-streamed")
# Turn 1 predates the mitigation, so the session records the reasoning ID.
model.extend(
[
[
ResponseReasoningItem(id="rs_triage", type="reasoning", summary=[]),
get_handoff_tool_call(specialist),
],
[get_text_message("handled")],
]
)
first = Runner.run_streamed(triage, input="hello", session=session)
async for _ in first.stream_events():
pass
assert first.final_output == "handled"
stored_reasoning = _find_reasoning_input_item(await session.get_items())
assert stored_reasoning is not None
assert stored_reasoning.get("id") == "rs_triage"
# The server no longer resolves that reasoning item.
model.revoked_reasoning_ids.add("rs_triage")
# Turn 2 opts into the documented mitigation for this failure.
model.extend([[get_text_message("done")]])
second = Runner.run_streamed(
triage,
input="anything else?",
session=session,
run_config=RunConfig(reasoning_item_id_policy="omit"),
)
async for _ in second.stream_events():
pass
assert second.final_output == "done"
replayed_reasoning = _find_reasoning_input_item(model.calls[-1].input)
assert replayed_reasoning is not None
assert "id" not in replayed_reasoning
@pytest.mark.asyncio
async def test_streamed_run_again_persists_tool_items_to_session():
model = ScriptedModel()
call_id = "call-session-run-again"
agent = Agent(
name="test",
model=model,
tools=[get_function_tool("foo", "tool_result")],
)
session = SimpleListSession()
model.extend(
[
[get_function_tool_call("foo", json.dumps({"a": "b"}), call_id=call_id)],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="user_message", session=session)
await consume_stream(result)
saved_items = await session.get_items()
assert any(
isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == call_id
for item in saved_items
)
assert any(
isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == call_id
for item in saved_items
)
@pytest.mark.asyncio
async def test_handoffs():
model = ScriptedModel()
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.extend(
[
# 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 = Runner.run_streamed(agent_3, input="user_message")
async for _ in result.stream_events():
pass
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"
class Foo(TypedDict):
bar: str
@pytest.mark.asyncio
async def test_structured_output():
model = ScriptedModel()
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.extend(
[
# First turn: a tool call
[
get_function_tool_call(
"foo",
json.dumps({"bar": "baz"}),
call_id="call_foo",
)
],
# 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"}),
call_id="call_bar",
),
],
# Fourth turn: structured output
[get_final_output_message(json.dumps(Foo(bar="baz")))],
]
)
result = Runner.run_streamed(
agent_2,
input=[
get_text_input_item("user_message"),
get_text_input_item("another_message"),
],
run_config=RunConfig(nest_handoff_history=True),
)
async for _ in result.stream_events():
pass
assert result.final_output == Foo(bar="baz")
assert len(result.raw_responses) == 4, "should have four model responses"
assert len(result.to_input_list()) == 11, (
"should preserve ordered history segments plus function calls, messages, handoff items, "
"and the final output without replaying the carried-forward message twice"
)
assert len(result.to_input_list(mode="normalized")) == 7, (
"should have normalized replay input: conversation summary, carried-forward message, "
"handoff summary, 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 = ScriptedModel()
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.extend(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
result = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
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_streamed_nested_handoff_filters_reasoning_items_from_model_input():
model = ScriptedModel()
delegate = Agent(
name="delegate",
model=model,
)
triage = Agent(
name="triage",
model=model,
handoffs=[delegate],
)
model.extend(
[
[
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 = Runner.run_streamed(
triage,
input="user_message",
run_config=RunConfig(
nest_handoff_history=True,
call_model_input_filter=capture_model_input,
),
)
await consume_stream(result)
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_async_input_filter_supported():
# DO NOT rename this without updating pyproject.toml
model = ScriptedModel()
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.extend(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
result = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_invalid_input_filter_fails():
model = ScriptedModel()
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.extend(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
with pytest.raises(UserError):
result = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_non_callable_input_filter_causes_error():
model = ScriptedModel()
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.extend(
[
[get_text_message("1"), get_text_message("2"), get_handoff_tool_call(agent_1)],
[get_text_message("last")],
]
)
with pytest.raises(UserError):
result = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
@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 = ScriptedModel()
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.extend(
[
[
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 = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
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 = ScriptedModel()
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.extend(
[
[
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 = Runner.run_streamed(agent_2, input="user_message")
async for _ in result.stream_events():
pass
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_input_guardrail_tripwire_triggered_causes_exception_streamed():
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=ScriptedModel([[]]),
)
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
@pytest.mark.parametrize(
("model_redacted", "tool_redacted"),
[(True, False), (False, True), (False, False)],
ids=["model_redacted", "tool_redacted", "diagnostic"],
)
@pytest.mark.asyncio
async def test_streamed_finalizer_failure_follows_both_data_policies(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
model_redacted: bool,
tool_redacted: bool,
) -> None:
async def safe_guardrail(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
_ = context, agent, input
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
error = RuntimeError("SECRET_STREAM_FINALIZER_ERROR")
async def fail_finalizer(_result: Any) -> bool:
raise error
monkeypatch.setattr(
run_loop,
"input_guardrail_tripwire_triggered_for_stream",
fail_finalizer,
)
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
agent_name = "SECRET_STREAM_AGENT_NAME"
agent = Agent(
name=agent_name,
input_guardrails=[InputGuardrail(guardrail_function=safe_guardrail)],
model=ScriptedModel(steps=[[get_text_message("done")]]),
)
with caplog.at_level(logging.DEBUG, logger="openai.agents"):
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
assert result.final_output == "done"
record = next(
record
for record in caplog.records
if "Error finalizing streamed result" in record.getMessage()
)
redacted = model_redacted or tool_redacted
if redacted:
assert record.msg == "%s"
assert record.args == ("Error finalizing streamed result",)
assert record.exc_info is None
assert record.exc_text is None
assert "openai_agents_diagnostic_context" not in record.__dict__
rendered = logging.Formatter().format(record)
assert agent_name not in rendered
assert "SECRET_STREAM_FINALIZER_ERROR" not in rendered
else:
context = record.__dict__["openai_agents_diagnostic_context"]
assert context == {"agent_name": agent_name}
assert record.exc_info is not None
assert record.exc_info[1] is error
assert "SECRET_STREAM_FINALIZER_ERROR" in logging.Formatter().format(record)
@pytest.mark.asyncio
async def test_input_guardrail_streamed_does_not_save_assistant_message_to_session():
async def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
await asyncio.sleep(0.01)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
session = SimpleListSession()
model = ScriptedModel()
model.enqueue([get_text_message("should_not_be_saved")])
agent = Agent(
name="test",
model=model,
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)],
)
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message", session=session)
async for _ in result.stream_events():
pass
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_input_guardrail_streamed_persists_user_input_for_sequential_guardrail():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
session = SimpleListSession()
model = ScriptedModel()
model.enqueue([get_text_message("should_not_be_saved")])
agent = Agent(
name="test",
model=model,
input_guardrails=[
InputGuardrail(guardrail_function=guardrail_function, run_in_parallel=False)
],
)
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message", session=session)
async for _ in result.stream_events():
pass
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_input_guardrail_streamed_persists_user_input_for_async_sequential_guardrail():
async def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
await asyncio.sleep(0)
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
session = SimpleListSession()
model = ScriptedModel()
model.enqueue([get_text_message("should_not_be_saved")])
agent = Agent(
name="test",
model=model,
input_guardrails=[
InputGuardrail(guardrail_function=guardrail_function, run_in_parallel=False)
],
)
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message", session=session)
async for _ in result.stream_events():
pass
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_stream_input_persistence_strips_ids_for_openai_conversation_session():
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.saved: list[list[TResponseInputItem]] = []
async def _get_session_id(self) -> str:
return "conv_test"
async def add_items(self, items: list[TResponseInputItem]) -> None:
for item in items:
if isinstance(item, dict):
assert "id" not in item, "IDs should be stripped before saving"
assert "provider_data" not in item, (
"provider_data should be stripped before saving"
)
self.saved.append(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()
model = ScriptedModel()
model.enqueue([get_text_message("ok")])
agent = Agent(
name="test",
model=model,
)
run_config = RunConfig(session_input_callback=lambda existing, new: existing + new)
input_items = [
cast(
TResponseInputItem,
{
"id": "message-1",
"type": "message",
"role": "user",
"content": "hello",
"provider_data": {"model": "litellm/test"},
},
)
]
result = Runner.run_streamed(agent, input=input_items, session=session, run_config=run_config)
async for _ in result.stream_events():
pass
assert session.saved, "input items should be persisted via save_result_to_session"
assert len(session.saved[0]) == 1
saved_item = session.saved[0][0]
assert isinstance(saved_item, dict)
assert "id" not in saved_item, "saved input items should not include IDs"
@pytest.mark.asyncio
async def test_stream_input_persistence_saves_only_new_turn_input(monkeypatch: pytest.MonkeyPatch):
session = SimpleListSession()
model = ScriptedModel()
model.extend(
[
[get_text_message("first")],
[get_text_message("second")],
]
)
agent = Agent(name="test", model=model)
from agents.run_internal import session_persistence as sp
real_save_result = sp.save_result_to_session
input_saves: list[list[TResponseInputItem]] = []
async def save_wrapper(
sess: Any,
original_input: Any,
new_items: list[RunItem],
run_state: Any = None,
**kwargs: Any,
) -> None:
if isinstance(original_input, list) and original_input:
input_saves.append(list(original_input))
await real_save_result(sess, original_input, new_items, run_state, **kwargs)
monkeypatch.setattr(
"agents.run_internal.session_persistence.save_result_to_session", save_wrapper
)
monkeypatch.setattr("agents.run_internal.run_loop.save_result_to_session", save_wrapper)
run_config = RunConfig(session_input_callback=lambda existing, new: existing + new)
first = Runner.run_streamed(
agent, input=[get_text_input_item("hello")], session=session, run_config=run_config
)
async for _ in first.stream_events():
pass
second = Runner.run_streamed(
agent, input=[get_text_input_item("next")], session=session, run_config=run_config
)
async for _ in second.stream_events():
pass
assert len(input_saves) == 2, "each turn should persist only the turn input once"
assert all(len(saved) == 1 for saved in input_saves), (
"each persisted input should contain only the new turn items"
)
first_saved = input_saves[0][0]
second_saved = input_saves[1][0]
assert isinstance(first_saved, dict) and first_saved.get("content") == "hello"
assert isinstance(second_saved, dict) and second_saved.get("content") == "next"
@pytest.mark.asyncio
async def test_slow_input_guardrail_still_raises_exception_streamed():
async def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
# Simulate a slow guardrail that completes after model streaming ends.
await asyncio.sleep(0.05)
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
model = ScriptedModel()
# Ensure the model finishes streaming quickly.
model.enqueue([get_text_message("ok")])
agent = Agent(
name="test",
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)],
model=model,
)
# Even though the guardrail is slower than the model stream, the exception should still raise.
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_output_guardrail_tripwire_triggered_causes_exception_streamed():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
model = ScriptedModel(steps=[[get_text_message("first_test")]])
agent = Agent(
name="test",
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)],
model=model,
)
with pytest.raises(OutputGuardrailTripwireTriggered):
result = Runner.run_streamed(agent, input="user_message")
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_output_guardrail_tripwire_raises_from_run_loop_task_before_stream_consumption():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
model = ScriptedModel(steps=[[get_text_message("first_test")]])
agent = Agent(
name="test",
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)],
model=model,
)
result = Runner.run_streamed(agent, input="user_message")
assert result.run_loop_task is not None
with pytest.raises(OutputGuardrailTripwireTriggered):
await result.run_loop_task
assert result.final_output is None
assert result.is_complete is True
@pytest.mark.asyncio
async def test_output_guardrail_exception_raises_from_run_loop_task_before_stream_consumption():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
raise RuntimeError("guardrail failed")
model = ScriptedModel(steps=[[get_text_message("first_test")]])
agent = Agent(
name="test",
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)],
model=model,
)
result = Runner.run_streamed(agent, input="user_message")
assert result.run_loop_task is not None
with pytest.raises(RuntimeError, match="guardrail failed"):
await result.run_loop_task
assert result.final_output is None
assert result.is_complete is True
@pytest.mark.asyncio
async def test_run_input_guardrail_tripwire_triggered_causes_exception_streamed():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], input: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
agent = Agent(
name="test",
model=ScriptedModel([[]]),
)
with pytest.raises(InputGuardrailTripwireTriggered):
result = Runner.run_streamed(
agent,
input="user_message",
run_config=RunConfig(
input_guardrails=[InputGuardrail(guardrail_function=guardrail_function)]
),
)
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_run_output_guardrail_tripwire_triggered_causes_exception_streamed():
def guardrail_function(
context: RunContextWrapper[Any], agent: Agent[Any], agent_output: Any
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=True,
)
model = ScriptedModel(steps=[[get_text_message("first_test")]])
agent = Agent(
name="test",
model=model,
)
with pytest.raises(OutputGuardrailTripwireTriggered):
result = Runner.run_streamed(
agent,
input="user_message",
run_config=RunConfig(
output_guardrails=[OutputGuardrail(guardrail_function=guardrail_function)]
),
)
async for _ in result.stream_events():
pass
@pytest.mark.asyncio
async def test_streaming_events():
model = ScriptedModel()
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.extend(
[
# First turn: a tool call
[
get_function_tool_call(
"foo",
json.dumps({"bar": "baz"}),
call_id="call_foo",
)
],
# Second turn: a message and a handoff
[get_text_message("a_message"), get_handoff_tool_call(agent_1)],
# Third turn: tool call
[
get_function_tool_call(
"bar",
json.dumps({"bar": "baz"}),
call_id="call_bar",
)
],
# Fourth turn: structured output
[get_final_output_message(json.dumps(Foo(bar="baz")))],
]
)
# event_type: (count, event)
event_counts: dict[str, int] = {}
item_data: list[RunItem] = []
agent_data: list[AgentUpdatedStreamEvent] = []
result = Runner.run_streamed(
agent_2,
input=[
get_text_input_item("user_message"),
get_text_input_item("another_message"),
],
run_config=RunConfig(nest_handoff_history=True),
)
async for event in result.stream_events():
event_counts[event.type] = event_counts.get(event.type, 0) + 1
if event.type == "run_item_stream_event":
item_data.append(event.item)
elif event.type == "agent_updated_stream_event":
agent_data.append(event)
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 preserve ordered history segments plus function calls, messages, handoff items, "
"and the final output without replaying the carried-forward message twice"
)
assert len(result.to_input_list(mode="normalized")) == 6, (
"should have normalized replay input: conversation summary, carried-forward message, "
"handoff summary, 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"
# Now lets check the events
expected_item_type_map = {
# 2 tool_call_item events:
# 1. get_function_tool_call("foo", ...)
# 2. get_function_tool_call("bar", ...)
# get_handoff_tool_call(agent_1) is only reported as a handoff_call_item.
"tool_call": 2,
# Only 2 outputs, handoff tool call doesn't have corresponding tool_call_output event
"tool_call_output": 2,
"message": 2, # get_text_message("a_message") + get_final_output_message(...)
"handoff": 1, # get_handoff_tool_call(agent_1)
"handoff_output": 1, # handoff_output_item
}
total_expected_item_count = sum(expected_item_type_map.values())
assert event_counts["run_item_stream_event"] == total_expected_item_count, (
f"Expected {total_expected_item_count} events, got {event_counts['run_item_stream_event']}"
f"Expected events were: {expected_item_type_map}, got {event_counts}"
)
assert len(item_data) == total_expected_item_count, (
f"should have {total_expected_item_count} run items"
)
assert len(agent_data) == 2, "should have 2 agent updated events"
assert agent_data[0].new_agent == agent_2, "should have started with agent_2"
assert agent_data[1].new_agent == agent_1, "should have handed off to agent_1"
@pytest.mark.asyncio
async def test_dynamic_tool_addition_run_streamed() -> None:
model = ScriptedModel()
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.extend(
[
[get_function_tool_call("add_tool", json.dumps({}), call_id="call-add-tool")],
[get_function_tool_call("tool2", json.dumps({}), call_id="call-tool-two")],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="start")
async for _ in result.stream_events():
pass
assert executed["called"] is True
assert result.final_output == "done"
@pytest.mark.asyncio
async def test_stream_step_items_to_queue_handles_tool_approval_item():
"""Test that stream_step_items_to_queue handles ToolApprovalItem."""
_, agent = make_model_and_agent(name="test")
tool_call = get_function_tool_call("test_tool", "{}")
assert isinstance(tool_call, ResponseFunctionToolCall)
approval_item = ToolApprovalItem(agent=agent, raw_item=tool_call)
queue: asyncio.Queue[StreamEvent | QueueCompleteSentinel] = asyncio.Queue()
# ToolApprovalItem should not be streamed
run_loop.stream_step_items_to_queue([approval_item], queue)
# Queue should be empty since ToolApprovalItem is not streamed
assert queue.empty()
@pytest.mark.asyncio
async def test_streaming_hitl_resume_with_approved_tools():
"""Test resuming streaming run from RunState with approved tools executes them."""
tool_called = False
async def test_tool() -> str:
nonlocal tool_called
tool_called = True
return "tool_result"
# Create a tool that requires approval
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
# First run - tool call that requires approval
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({})),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool")
await consume_stream(first)
# Resume from state - should execute approved tool
result2 = await resume_streamed_after_first_approval(agent, first)
# Tool should have been called
assert tool_called is True
assert result2.final_output == "done"
@pytest.mark.asyncio
async def test_streaming_resume_with_session_does_not_duplicate_items():
"""Ensure session persistence does not duplicate tool items after streaming resume."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
session = SimpleListSession()
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = Runner.run_streamed(agent, state, session=session)
await consume_stream(resumed)
assert resumed.final_output == "done"
saved_items = await session.get_items()
call_count = sum(
1
for item in saved_items
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("call_id") == "call-resume"
)
output_count = sum(
1
for item in saved_items
if isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == "call-resume"
)
assert call_count == 1
assert output_count == 1
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_run_llm_again_approval_persists_completed_sibling(mode: str) -> None:
side_effects: list[str] = []
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
side_effects.append("approved")
return "approved-output"
@function_tool(name_override="sibling_tool")
def sibling_tool() -> str:
side_effects.append("sibling")
return "sibling-output"
model = ScriptedModel(
[
[
get_function_tool_call("approval_tool", "{}", call_id="call-approved"),
get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"),
],
[get_text_message("done")],
]
)
agent = Agent(
name="test",
model=model,
tools=[approval_tool, sibling_tool],
output_guardrails=[
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
],
)
session = SimpleListSession()
async def run_once(input_value: Any) -> Any:
if mode == "non_streamed":
return await Runner.run(agent, input_value, session=session)
result = Runner.run_streamed(agent, input_value, session=session)
await consume_stream(result)
return result
first = await run_once("Use both tools")
assert len(first.interruptions) == 1
assert side_effects == ["sibling"]
saved_before_resume = await session.get_items()
saved_sibling_items = [
item
for item in saved_before_resume
if isinstance(item, dict) and item.get("call_id") == "call-sibling"
]
assert [item.get("type") for item in saved_sibling_items] == [
"function_call",
"function_call_output",
]
assert saved_sibling_items[1].get("output") == "sibling-output"
state = first.to_state()
assert state._current_turn_persisted_item_count > 0
state.approve(first.interruptions[0])
resumed = await run_once(state)
assert resumed.final_output == "done"
assert side_effects == ["sibling", "approved"]
saved_after_resume = await session.get_items()
for call_id in ("call-sibling", "call-approved"):
assert [
item.get("type")
for item in saved_after_resume
if isinstance(item, dict) and item.get("call_id") == call_id
] == ["function_call", "function_call_output"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("terminal_behavior", ["first", "named", "custom"])
@pytest.mark.asyncio
async def test_terminal_behaviors_defer_completed_approval_siblings(
mode: str,
terminal_behavior: str,
) -> None:
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
return "approved-output"
@function_tool(name_override="sibling_tool")
def sibling_tool() -> str:
return "sibling-output"
model = ScriptedModel(
[
[
get_function_tool_call("approval_tool", "{}", call_id="call-approved"),
get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"),
]
]
)
agent = Agent(
name="test",
model=model,
tools=[approval_tool, sibling_tool],
output_guardrails=[
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
],
)
if terminal_behavior == "first":
agent.tool_use_behavior = "stop_on_first_tool"
elif terminal_behavior == "named":
agent.tool_use_behavior = {"stop_at_tool_names": ["approval_tool"]}
else:
agent.tool_use_behavior = lambda _context, results: ToolsToFinalOutputResult(
is_final_output=True,
final_output=results[0].output,
)
session = SimpleListSession()
if mode == "non_streamed":
result = await Runner.run(agent, "Use both tools", session=session)
else:
result = Runner.run_streamed(agent, "Use both tools", session=session)
await consume_stream(result)
assert len(result.interruptions) == 1
assert result.to_state()._current_turn_persisted_item_count == 0
assert "sibling-output" not in json.dumps(await session.get_items())
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("terminal_behavior", ["first", "named", "custom"])
@pytest.mark.asyncio
async def test_persisted_run_llm_again_checkpoint_rejects_terminal_behavior_change(
mode: str,
terminal_behavior: str,
) -> None:
side_effects: list[str] = []
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
side_effects.append("approved")
return "approved-output"
@function_tool(name_override="sibling_tool")
def sibling_tool() -> str:
side_effects.append("sibling")
return "sibling-output"
model = ScriptedModel(
[
[
get_function_tool_call("approval_tool", "{}", call_id="call-approved"),
get_function_tool_call("sibling_tool", "{}", call_id="call-sibling"),
]
]
)
agent = Agent(
name="test",
model=model,
tools=[approval_tool, sibling_tool],
output_guardrails=[
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
],
)
session = SimpleListSession()
if mode == "non_streamed":
first = await Runner.run(agent, "Use both tools", session=session)
else:
first = Runner.run_streamed(agent, "Use both tools", session=session)
await consume_stream(first)
assert side_effects == ["sibling"]
state = first.to_state()
assert state._current_turn_persisted_item_count > 0
state.approve(first.interruptions[0])
if terminal_behavior == "first":
agent.tool_use_behavior = "stop_on_first_tool"
elif terminal_behavior == "named":
agent.tool_use_behavior = {"stop_at_tool_names": ["approval_tool"]}
else:
agent.tool_use_behavior = lambda _context, results: ToolsToFinalOutputResult(
is_final_output=True,
final_output=results[0].output,
)
with pytest.raises(UserError, match="after current-turn items were persisted"):
if mode == "non_streamed":
await Runner.run(agent, state, session=session)
else:
result = Runner.run_streamed(agent, state, session=session)
await consume_stream(result)
assert side_effects == ["sibling"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("outcome", ["passes", "trips", "error"])
@pytest.mark.asyncio
async def test_resumed_approved_tool_final_persists_complete_post_verdict_batch(
mode: str,
outcome: str,
) -> None:
guardrail_state = {"outcome": outcome}
@tool_output_guardrail
def record_tool_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info=data.output)
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_output_guardrails=[record_tool_output],
)
def approval_tool() -> str:
return "approved-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
if guardrail_state["outcome"] == "error":
raise RuntimeError("guardrail failed")
return GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=guardrail_state["outcome"] == "trips",
)
model = ScriptedModel()
model.enqueue([get_function_tool_call("approval_tool", "{}", call_id="call-approved")])
agent = Agent(
name="test",
model=model,
tools=[approval_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
async def run_once(input_value: Any) -> Any:
if mode == "non_streamed":
return await Runner.run(agent, input_value, session=session)
result = Runner.run_streamed(agent, input_value, session=session)
await consume_stream(result)
return result
first = await run_once("Use approval_tool")
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
if outcome == "trips":
with pytest.raises(OutputGuardrailTripwireTriggered):
await run_once(state)
assert [result.output.output_info for result in state._tool_output_guardrail_results] == [
run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
]
elif outcome == "error":
with pytest.raises(RuntimeError, match="guardrail failed"):
await run_once(state)
else:
resumed = await run_once(state)
assert resumed.final_output == "approved-result"
saved_items = await session.get_items()
saved_types = [
item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)
]
assert saved_types == ["user", "function_call", "function_call_output"]
saved_tool_items = [
item
for item in saved_items
if isinstance(item, dict) and item.get("type") in {"function_call", "function_call_output"}
]
assert [(item.get("type"), item.get("call_id")) for item in saved_tool_items] == [
("function_call", "call-approved"),
("function_call_output", "call-approved"),
]
expected_output = (
run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT if outcome == "trips" else "approved-result"
)
assert saved_tool_items[1].get("output") == expected_output
if outcome == "trips":
guardrail_state["outcome"] = "passes"
model.enqueue([get_text_message("done")])
next_result = await run_once("Continue")
assert next_result.final_output == "done"
model_input = model.calls[-1].input
assert isinstance(model_input, list)
replayed_tool_items = [
item
for item in model_input
if isinstance(item, dict)
and item.get("type") in {"function_call", "function_call_output"}
]
assert [(item.get("type"), item.get("call_id")) for item in replayed_tool_items] == [
("function_call", "call-approved"),
("function_call_output", "call-approved"),
]
assert replayed_tool_items[1].get("output") == (
run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
)
assert "approved-result" not in json.dumps(model_input)
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_ambiguous_serialized_approval_state_fails_before_tool_execution(
mode: str,
) -> None:
tool_calls = 0
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
nonlocal tool_calls
tool_calls += 1
return "secret-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
model = ScriptedModel(
[[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]]
)
agent = Agent(
name="test",
model=model,
tools=[approval_tool],
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
first = await Runner.run(agent, "Use approval_tool")
state = first.to_state()
state._current_turn = 2
state._current_turn_persisted_item_count = 1
restored = await RunState.from_json(agent, state.to_json())
restored.approve(restored.get_interruptions()[0])
with pytest.raises(UserError, match="current response boundary cannot be proven"):
if mode == "non_streamed":
await Runner.run(agent, restored, session=None)
else:
result = Runner.run_streamed(agent, restored, session=None)
await consume_stream(result)
assert tool_calls == 0
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("serialized", [False, True], ids=["live", "serialized"])
@pytest.mark.parametrize("attach_session", [False, True], ids=["without-session", "with-session"])
@pytest.mark.asyncio
async def test_legacy_approval_checkpoint_uses_current_session_ownership(
mode: str,
serialized: bool,
attach_session: bool,
) -> None:
side_effects: list[str] = []
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
side_effects.append("executed")
return "approved-result"
model = ScriptedModel(
[[get_function_tool_call("approval_tool", "{}", call_id="call-approved")]]
)
agent = Agent(
name="test",
model=model,
tools=[approval_tool],
tool_use_behavior="stop_on_first_tool",
)
legacy_session = SimpleListSession()
first = await Runner.run(agent, "Use approval_tool", session=legacy_session)
state = first.to_state()
assert state._current_turn_persisted_item_count > 0
if serialized:
state = await RunState.from_json(agent, state.to_json())
state.approve(state.get_interruptions()[0])
agent.output_guardrails = [
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
]
session = legacy_session if attach_session else None
if attach_session:
with pytest.raises(UserError, match="after current-turn items were persisted"):
if mode == "non_streamed":
await Runner.run(agent, state, session=session)
else:
result = Runner.run_streamed(agent, state, session=session)
await consume_stream(result)
assert side_effects == []
return
if mode == "non_streamed":
result = await Runner.run(agent, state, session=None)
else:
result = Runner.run_streamed(agent, state, session=None)
await consume_stream(result)
assert result.final_output == "approved-result"
assert state._current_turn_persisted_item_count == 0
assert side_effects == ["executed"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_output_guardrails_fail_closed_with_server_managed_history(mode: str) -> None:
model = ScriptedModel([[get_text_message("unreachable")]])
agent = Agent(
name="test",
model=model,
output_guardrails=[
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
],
)
with pytest.raises(UserError, match="server-managed conversation history"):
if mode == "non_streamed":
await Runner.run(agent, "hello", previous_response_id="response-id")
else:
Runner.run_streamed(agent, "hello", previous_response_id="response-id")
assert not model.calls
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("strict", [False, True], ids=["default", "strict"])
@pytest.mark.parametrize("use_run_config_model", [False, True], ids=["agent-model", "run-model"])
@pytest.mark.asyncio
async def test_chat_completions_output_guardrails_use_adapter_conversation_policy(
mode: str,
strict: bool,
use_run_config_model: bool,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
scripted_model = ScriptedModel([[get_text_message("accepted-output")]])
chat_model = OpenAIChatCompletionsModel(
model="test",
openai_client=cast(Any, object()),
strict_feature_validation=strict,
)
async def get_response(*args: Any, **kwargs: Any) -> Any:
chat_model._handle_unsupported_server_managed_conversation_state(
previous_response_id=kwargs.get("previous_response_id"),
conversation_id=kwargs.get("conversation_id"),
)
return await scripted_model.get_response(*args, **kwargs)
async def stream_response(*args: Any, **kwargs: Any) -> AsyncIterator[Any]:
chat_model._handle_unsupported_server_managed_conversation_state(
previous_response_id=kwargs.get("previous_response_id"),
conversation_id=kwargs.get("conversation_id"),
)
async for event in scripted_model.stream_response(*args, **kwargs):
yield event
monkeypatch.setattr(chat_model, "get_response", get_response)
monkeypatch.setattr(chat_model, "stream_response", stream_response)
agent = Agent(
name="test",
model=ScriptedModel() if use_run_config_model else chat_model,
output_guardrails=[
OutputGuardrail(
guardrail_function=lambda _context, _agent, _output: GuardrailFunctionOutput(
output_info=None,
tripwire_triggered=False,
)
)
],
)
run_config = RunConfig(model=chat_model) if use_run_config_model else None
caplog.set_level(logging.WARNING, logger="openai.agents")
async def run_once() -> Any:
if mode == "non_streamed":
return await Runner.run(
agent,
"hello",
previous_response_id="response-id",
run_config=run_config,
)
result = Runner.run_streamed(
agent,
"hello",
previous_response_id="response-id",
run_config=run_config,
)
await consume_stream(result)
return result
if strict:
with pytest.raises(UserError, match="OpenAIChatCompletionsModel does not support"):
await run_once()
assert not scripted_model.calls
return
assert (await run_once()).final_output == "accepted-output"
assert "Ignoring unsupported server-managed conversation state" in caplog.text
assert len(scripted_model.calls) == 1
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("session_kind", ["simple", "openai_conversations"])
@pytest.mark.parametrize("arguments", ["{}", ""], ids=["json-object", "empty"])
@pytest.mark.asyncio
async def test_stop_on_first_tool_final_persists_committed_tool_items_on_tripwire(
mode: str,
session_kind: str,
arguments: str,
) -> None:
"""A blocked final output must not discard the session record of a tool that already ran."""
calls: list[str] = []
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
calls.append("ran")
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
model = ScriptedModel()
model.enqueue([get_function_tool_call("commit_tool", arguments, call_id="call-committed")])
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
class DummyOpenAIConversationsSession(OpenAIConversationsSession):
def __init__(self) -> None:
self.history: list[TResponseInputItem] = []
async def get_items(self, limit: int | None = None) -> list[TResponseInputItem]:
return list(self.history if limit is None else 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()
session = SimpleListSession() if session_kind == "simple" else DummyOpenAIConversationsSession()
run_config = RunConfig(
session_input_callback=lambda history, new_input: [*reversed(history), *new_input]
)
with pytest.raises(OutputGuardrailTripwireTriggered):
if mode == "non_streamed":
await Runner.run(agent, "Use commit_tool", session=session, run_config=run_config)
else:
result = Runner.run_streamed(
agent,
"Use commit_tool",
session=session,
run_config=run_config,
)
await consume_stream(result)
assert calls == ["ran"], "the tool never ran, so the test proves nothing"
saved_items = await session.get_items()
saved = [
(item.get("type") or item.get("role"), item.get("call_id"))
for item in saved_items
if isinstance(item, dict)
]
assert saved == [
("user", None),
("function_call", "call-committed"),
("function_call_output", "call-committed"),
]
assert cast(dict[str, Any], saved_items[-1]).get("output") == (
run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
)
assert "committed-result" not in json.dumps(saved_items)
# The next run must see the completed call instead of re-issuing the same side effect.
agent.output_guardrails = []
model.enqueue([get_text_message("done")])
if mode == "non_streamed":
followup: Any = await Runner.run(
agent,
"Continue",
session=session,
run_config=run_config,
)
else:
followup = Runner.run_streamed(
agent,
"Continue",
session=session,
run_config=run_config,
)
await consume_stream(followup)
assert followup.final_output == "done"
assert calls == ["ran"]
model_input = model.calls[-1].input
assert isinstance(model_input, list)
replayed = [
(item.get("type"), item.get("call_id"))
for item in model_input
if isinstance(item, dict) and item.get("type") in {"function_call", "function_call_output"}
]
assert set(replayed) == {
("function_call", "call-committed"),
("function_call_output", "call-committed"),
}
replayed_output = next(
item.get("output")
for item in model_input
if isinstance(item, dict) and item.get("type") == "function_call_output"
)
assert replayed_output == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
assert "committed-result" not in json.dumps(model_input)
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_blocked_message_final_output_is_not_persisted(mode: str) -> None:
"""Control for the committed-tool case: a rejected message is withheld from the session."""
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
model = ScriptedModel()
model.enqueue([get_text_message("should_not_be_saved")])
agent = Agent(
name="test",
model=model,
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
with pytest.raises(OutputGuardrailTripwireTriggered):
if mode == "non_streamed":
await Runner.run(agent, "user_message", session=session)
else:
result = Runner.run_streamed(agent, "user_message", session=session)
await consume_stream(result)
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
assert saved == ["user"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_blocked_final_persists_tool_items_but_not_the_message(mode: str) -> None:
"""A mixed final turn splits: the tool record is kept, the blocked message is not."""
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(
output_info={"reason": "message rejected"},
tripwire_triggered=True,
)
model = ScriptedModel()
model.extend(
[
[get_function_tool_call("commit_tool", "{}", call_id="call-mixed")],
[get_text_message("should_not_be_saved")],
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
with pytest.raises(OutputGuardrailTripwireTriggered) as exc_info:
if mode == "non_streamed":
await Runner.run(agent, "Use commit_tool", session=session)
else:
result = Runner.run_streamed(agent, "Use commit_tool", session=session)
await consume_stream(result)
assert exc_info.value.guardrail_result.agent_output == "should_not_be_saved"
assert exc_info.value.guardrail_result.output.output_info == {"reason": "message rejected"}
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
assert saved == ["user", "function_call", "function_call_output"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"])
@pytest.mark.asyncio
async def test_mixed_final_turn_session_order_and_committed_items(
mode: str,
tripwire: bool,
) -> None:
"""A final turn holding a message *and* a committed tool call keeps model order when it passes.
Only the tripwire case may drop anything, and only the undeliverable message. The passing case
must persist the whole batch in the model's order, so a later run does not replay a reordered
or truncated history.
"""
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire)
model = ScriptedModel()
# The message precedes the tool call, so a split save would reorder the persisted turn.
model.enqueue(
[
get_text_message("assistant-preamble"),
get_function_tool_call("commit_tool", "{}", call_id="call-mixed"),
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
async def run_once() -> Any:
if mode == "non_streamed":
return await Runner.run(agent, "Use commit_tool", session=session)
result = Runner.run_streamed(agent, "Use commit_tool", session=session)
await consume_stream(result)
return result
if tripwire:
with pytest.raises(OutputGuardrailTripwireTriggered):
await run_once()
else:
assert (await run_once()).final_output == "committed-result"
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
if tripwire:
# The undeliverable message is withheld; the tool that already ran is not.
assert saved == ["user", "function_call", "function_call_output"]
else:
assert saved == ["user", "message", "function_call", "function_call_output"]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_failing_output_guardrail_keeps_the_whole_final_turn(
mode: str,
) -> None:
"""A guardrail error leaves no rejection, so the completed turn remains replayable."""
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
raise RuntimeError("guardrail failed")
model = ScriptedModel()
model.enqueue(
[
get_text_message("assistant-preamble"),
get_function_tool_call("commit_tool", "{}", call_id="call-mixed"),
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
async def run_once() -> None:
if mode == "non_streamed":
await Runner.run(agent, "Use commit_tool", session=session)
else:
result = Runner.run_streamed(agent, "Use commit_tool", session=session)
await consume_stream(result)
with pytest.raises(RuntimeError, match="guardrail failed"):
await run_once()
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
assert saved == ["user", "message", "function_call", "function_call_output"]
@pytest.mark.asyncio
async def test_streamed_session_save_error_takes_precedence_over_output_guardrail_error() -> None:
guardrail_failed = False
final_turn_save_attempted = False
class FailingFinalTurnSession(SimpleListSession):
async def add_items(self, items: list[TResponseInputItem]) -> None:
nonlocal final_turn_save_attempted
if guardrail_failed:
final_turn_save_attempted = True
raise LookupError("session save failed")
await super().add_items(items)
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
nonlocal guardrail_failed
guardrail_failed = True
raise RuntimeError("guardrail failed")
model = ScriptedModel()
model.enqueue([get_text_message("assistant-preamble")])
agent = Agent(
name="test",
model=model,
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = FailingFinalTurnSession()
result = Runner.run_streamed(agent, "Hello", session=session)
with pytest.raises(LookupError, match="session save failed") as exc_info:
await consume_stream(result)
assert final_turn_save_attempted is True
assert isinstance(exc_info.value.__context__, RuntimeError)
assert str(exc_info.value.__context__) == "guardrail failed"
assert result.run_loop_exception is exc_info.value
assert await session.get_items() == [{"content": "Hello", "role": "user"}]
@pytest.mark.asyncio
async def test_streamed_session_save_cancellation_is_not_a_public_immediate_cancel() -> None:
guardrail_failed = False
class CancellingFinalTurnSession(SimpleListSession):
async def add_items(self, items: list[TResponseInputItem]) -> None:
if guardrail_failed:
raise asyncio.CancelledError("session save cancelled")
await super().add_items(items)
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
nonlocal guardrail_failed
guardrail_failed = True
raise RuntimeError("guardrail failed")
model = ScriptedModel(steps=[[get_text_message("assistant-preamble")]])
agent = Agent(
name="test",
model=model,
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = CancellingFinalTurnSession()
result = Runner.run_streamed(agent, "Hello", session=session)
with pytest.raises(asyncio.CancelledError, match="session save cancelled") as exc_info:
await consume_stream(result)
assert result._cancel_mode == "none"
assert result._stored_exception is exc_info.value
assert isinstance(exc_info.value.__context__, RuntimeError)
assert str(exc_info.value.__context__) == "guardrail failed"
assert await session.get_items() == [{"content": "Hello", "role": "user"}]
@pytest.mark.asyncio
async def test_streamed_session_save_direct_base_exception_is_terminal() -> None:
guardrail_failed = False
class DirectAbort(BaseException):
pass
class AbortingFinalTurnSession(SimpleListSession):
async def add_items(self, items: list[TResponseInputItem]) -> None:
if guardrail_failed:
raise DirectAbort("session save aborted")
await super().add_items(items)
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
nonlocal guardrail_failed
guardrail_failed = True
raise RuntimeError("guardrail failed")
agent = Agent(
name="test",
model=ScriptedModel(steps=[[get_text_message("assistant-preamble")]]),
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = AbortingFinalTurnSession()
result = Runner.run_streamed(agent, "Hello", session=session)
with pytest.raises(DirectAbort, match="session save aborted") as exc_info:
await consume_stream(result)
assert result._stored_exception is exc_info.value
assert result.run_loop_exception is exc_info.value
assert await session.get_items() == [{"content": "Hello", "role": "user"}]
@pytest.mark.asyncio
async def test_public_immediate_cancel_during_guardrail_recovery_save_stays_prompt() -> None:
guardrail_failed = False
save_started = asyncio.Event()
save_cancelled = asyncio.Event()
never_set = asyncio.Event()
class BlockingFinalTurnSession(SimpleListSession):
async def add_items(self, items: list[TResponseInputItem]) -> None:
if guardrail_failed:
save_started.set()
try:
await never_set.wait()
finally:
save_cancelled.set()
return
await super().add_items(items)
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
nonlocal guardrail_failed
guardrail_failed = True
raise RuntimeError("guardrail failed")
model = ScriptedModel(steps=[[get_text_message("assistant-preamble")]])
agent = Agent(
name="test",
model=model,
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = BlockingFinalTurnSession()
result = Runner.run_streamed(agent, "Hello", session=session)
drain_task = asyncio.create_task(consume_stream(result))
try:
await asyncio.wait_for(save_started.wait(), timeout=1)
result.cancel()
await asyncio.wait_for(drain_task, timeout=1)
finally:
if not drain_task.done():
result.cancel()
drain_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await drain_task
assert save_cancelled.is_set()
assert result._stored_exception is None
assert await session.get_items() == [{"content": "Hello", "role": "user"}]
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.parametrize("tripwire", [False, True], ids=["passes", "trips"])
@pytest.mark.asyncio
async def test_blocked_tool_final_discards_reasoning_response_suffix_on_trip(
mode: str,
tripwire: bool,
) -> None:
"""A reasoning-bearing response is preserved on pass and discarded completely on trip."""
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=tripwire)
model = ScriptedModel()
model.enqueue(
[
ResponseReasoningItem(
id="rs_committed",
summary=[Summary(text="deciding to call the tool", type="summary_text")],
type="reasoning",
),
get_function_tool_call("commit_tool", "{}", call_id="call-reasoned"),
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
async def run_once(input_value: Any) -> Any:
if mode == "non_streamed":
return await Runner.run(agent, input_value, session=session)
result = Runner.run_streamed(agent, input_value, session=session)
await consume_stream(result)
return result
if tripwire:
with pytest.raises(OutputGuardrailTripwireTriggered):
await run_once("Use commit_tool")
else:
assert (await run_once("Use commit_tool")).final_output == "committed-result"
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
expected_saved = (
["user"] if tripwire else ["user", "reasoning", "function_call", "function_call_output"]
)
assert saved == expected_saved
# The reasoning/call/output group has to reach the next request in that order.
agent.output_guardrails = []
model.enqueue([get_text_message("done")])
followup = await run_once("Continue")
assert followup.final_output == "done"
model_input = model.calls[-1].input
assert isinstance(model_input, list)
replayed = [
item.get("type")
for item in model_input
if isinstance(item, dict)
and item.get("type") in {"reasoning", "function_call", "function_call_output"}
]
expected_replayed = [] if tripwire else ["reasoning", "function_call", "function_call_output"]
assert replayed == expected_replayed
@pytest.mark.parametrize("mode", ["non_streamed", "streamed"])
@pytest.mark.asyncio
async def test_blocked_tool_final_discards_suffix_with_multiple_reasoning_groups(
mode: str,
) -> None:
"""Any reasoning item makes the complete rejected current-response suffix unsupported."""
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
return "committed-result"
def output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
model = ScriptedModel()
model.enqueue(
[
ResponseReasoningItem(
id="rs_rejected",
summary=[Summary(text="drafting the message", type="summary_text")],
type="reasoning",
),
get_text_message("rejected-preamble"),
ResponseReasoningItem(
id="rs_committed",
summary=[Summary(text="deciding to call the tool", type="summary_text")],
type="reasoning",
),
get_function_tool_call("commit_tool", "{}", call_id="call-reasoned"),
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
)
session = SimpleListSession()
async def run_once(input_value: Any) -> Any:
if mode == "non_streamed":
return await Runner.run(agent, input_value, session=session)
result = Runner.run_streamed(agent, input_value, session=session)
await consume_stream(result)
return result
with pytest.raises(OutputGuardrailTripwireTriggered):
await run_once("Use commit_tool")
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
assert saved == ["user"]
saved_reasoning_ids = [
item.get("id") for item in saved_items if isinstance(item, dict) and item.get("id")
]
assert saved_reasoning_ids == []
# The unsupported response contributes nothing to the next model request.
agent.output_guardrails = []
model.enqueue([get_text_message("done")])
followup = await run_once("Continue")
assert followup.final_output == "done"
model_input = model.calls[-1].input
assert isinstance(model_input, list)
replayed = [
item.get("type")
for item in model_input
if isinstance(item, dict)
and item.get("type") in {"reasoning", "message", "function_call", "function_call_output"}
]
assert replayed == []
@pytest.mark.parametrize("reasoning_suffix", [False, True], ids=["canonical", "reasoning"])
@pytest.mark.asyncio
async def test_streamed_trip_preserves_accepted_tool_prefix(
reasoning_suffix: bool,
) -> None:
"""Only the rejected current response is replaced or dropped from replay owners."""
side_effects: list[str] = []
@function_tool(name_override="accepted_tool")
def accepted_tool() -> str:
side_effects.append("accepted")
return "accepted-output"
@function_tool(name_override="terminal_tool")
def terminal_tool() -> str:
side_effects.append("terminal")
return "rejected-output"
def reject_output(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
terminal_response: list[Any] = []
if reasoning_suffix:
terminal_response.append(
ResponseReasoningItem(
id="reasoning-current",
summary=[Summary(text="calling terminal tool", type="summary_text")],
type="reasoning",
)
)
terminal_response.append(get_function_tool_call("terminal_tool", "{}", call_id="current-call"))
model = ScriptedModel(
steps=[
[get_function_tool_call("accepted_tool", "{}", call_id="accepted-call")],
terminal_response,
]
)
agent = Agent(
name="test",
model=model,
tools=[accepted_tool, terminal_tool],
tool_use_behavior={"stop_at_tool_names": ["terminal_tool"]},
output_guardrails=[OutputGuardrail(guardrail_function=reject_output)],
)
result = Runner.run_streamed(agent, "run both tools")
with pytest.raises(OutputGuardrailTripwireTriggered):
await consume_stream(result)
assert side_effects == ["accepted", "terminal"]
def call_ids(items: list[RunItem]) -> list[str]:
return [
call_id
for item in items
if (
call_id := (
item.raw_item.get("call_id")
if isinstance(item.raw_item, dict)
else getattr(item.raw_item, "call_id", None)
)
)
is not None
]
expected_call_ids = ["accepted-call", "accepted-call"]
if not reasoning_suffix:
expected_call_ids.extend(["current-call", "current-call"])
assert call_ids(result.new_items) == expected_call_ids
assert call_ids(result._model_input_items) == expected_call_ids
state = result.to_state()
assert call_ids(state._generated_items) == expected_call_ids
assert call_ids(state._session_items) == expected_call_ids
serialized_state = json.dumps(state.to_json())
assert "accepted-output" in serialized_state
assert "rejected-output" not in serialized_state
if reasoning_suffix:
assert "reasoning-current" not in serialized_state
else:
assert run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT in serialized_state
@pytest.mark.asyncio
async def test_streaming_resume_preserves_filtered_model_input_after_handoff():
model = ScriptedModel()
@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.extend(
[
[
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 = Runner.run_streamed(triage, input="user_message", run_config=run_config)
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = Runner.run_streamed(triage, state, run_config=run_config)
await consume_stream(resumed)
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_streaming_resume_persists_tool_outputs_on_run_again():
"""Approved tool outputs should be persisted before streaming resumes the next turn."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
session = SimpleListSession()
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = Runner.run_streamed(agent, state, session=session)
await consume_stream(resumed)
saved_items = await session.get_items()
assert any(
isinstance(item, dict)
and item.get("type") == "function_call_output"
and item.get("call_id") == "call-resume"
for item in saved_items
), "approved tool outputs should be persisted on resume"
@pytest.mark.asyncio
async def test_streaming_resume_carries_persisted_count(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure resumed streaming preserves the persisted count for session saves."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
session = SimpleListSession()
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({}), call_id="call-resume"),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool", session=session)
await consume_stream(first)
assert first.interruptions
persisted_count = first._current_turn_persisted_item_count
assert persisted_count > 0
state = first.to_state()
state.approve(first.interruptions[0])
observed_counts: list[int] = []
run_loop_any = cast(Any, run_loop)
real_save_resumed = run_loop_any.save_resumed_turn_items
async def save_wrapper(
*,
session: Any,
items: list[RunItem],
persisted_count: int,
response_id: str | None,
reasoning_item_id_policy: str | None = None,
store: bool | None = None,
wrapper: RunContextWrapper[Any] | None = None,
) -> int:
observed_counts.append(persisted_count)
result = await real_save_resumed(
session=session,
items=items,
persisted_count=persisted_count,
response_id=response_id,
reasoning_item_id_policy=reasoning_item_id_policy,
store=store,
wrapper=wrapper,
)
return int(result)
monkeypatch.setattr(run_loop_any, "save_resumed_turn_items", save_wrapper)
resumed = Runner.run_streamed(agent, state, session=session)
await consume_stream(resumed)
assert observed_counts, "expected resumed save to capture persisted count"
assert all(count == persisted_count for count in observed_counts)
@pytest.mark.asyncio
async def test_streaming_hitl_resume_enforces_max_turns():
"""Test that streamed resumes advance turn counts for max_turns enforcement."""
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool", needs_approval=True)
model, agent = make_model_and_agent(name="test", tools=[tool])
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({})),
followup=[get_text_message("done")],
)
first = Runner.run_streamed(agent, input="Use test_tool", max_turns=1)
await consume_stream(first)
assert first.interruptions
state = first.to_state()
state.approve(first.interruptions[0])
resumed = Runner.run_streamed(agent, state)
with pytest.raises(MaxTurnsExceeded):
async for _ in resumed.stream_events():
pass
@pytest.mark.asyncio
async def test_streaming_max_turns_emits_pending_tool_output_events() -> None:
async def test_tool() -> str:
return "tool_result"
tool = function_tool(test_tool, name_override="test_tool")
model, agent = make_model_and_agent(name="test", tools=[tool])
queue_function_call_and_text(
model,
get_function_tool_call("test_tool", json.dumps({})),
followup=[get_text_message("done")],
)
result = Runner.run_streamed(agent, input="Use test_tool", max_turns=1)
streamed_item_types: list[str] = []
with pytest.raises(MaxTurnsExceeded):
async for event in result.stream_events():
if event.type == "run_item_stream_event":
streamed_item_types.append(event.item.type)
assert "tool_call_item" in streamed_item_types
assert "tool_call_output_item" in streamed_item_types
@pytest.mark.asyncio
async def test_streaming_non_max_turns_exception_does_not_emit_queued_events() -> None:
model, agent = make_model_and_agent(name="test")
model.enqueue([get_text_message("done")])
result = Runner.run_streamed(agent, input="hello")
result.cancel()
await asyncio.sleep(0)
while not result._event_queue.empty():
result._event_queue.get_nowait()
result._event_queue.task_done()
result._stored_exception = RuntimeError("guardrail-triggered")
result._event_queue.put_nowait(AgentUpdatedStreamEvent(new_agent=agent))
streamed_events: list[StreamEvent] = []
with pytest.raises(RuntimeError, match="guardrail-triggered"):
async for event in result.stream_events():
streamed_events.append(event)
assert streamed_events == []
@pytest.mark.asyncio
async def test_streaming_hitl_server_conversation_tracker_priming():
"""Test that resuming streaming run from RunState primes server conversation tracker."""
model, agent = make_model_and_agent(name="test")
# First run with conversation_id
model.enqueue([get_text_message("First response")])
result1 = Runner.run_streamed(
agent, input="test", conversation_id="conv123", previous_response_id="resp123"
)
await consume_stream(result1)
# Create state from result
state = result1.to_state()
# Resume with same conversation_id - should not duplicate messages
model.enqueue([get_text_message("Second response")])
result2 = Runner.run_streamed(
agent, state, conversation_id="conv123", previous_response_id="resp123"
)
await consume_stream(result2)
# Should complete successfully without message duplication
assert result2.final_output == "Second response"
assert len(result2.new_items) >= 1
def _tool_with_guardrails() -> FunctionTool:
"""Build a function tool guarded by one input and one output tool guardrail."""
@tool_input_guardrail
def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="input-checked")
@tool_output_guardrail
def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="output-checked")
@function_tool(
name_override="guarded_tool",
tool_input_guardrails=[record_input],
tool_output_guardrails=[record_output],
)
def guarded_tool() -> str:
return "tool-result"
return guarded_tool
@pytest.mark.asyncio
async def test_streamed_run_reports_tool_guardrail_results():
"""Streamed runs must expose tool guardrail results like non-streamed runs do."""
model, agent = make_model_and_agent(tools=[_tool_with_guardrails()])
model.extend(
[
[get_function_tool_call("guarded_tool", "{}", call_id="call_1")],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="hello")
await consume_stream(result)
assert result.final_output == "done"
assert len(result.tool_input_guardrail_results) == 1
assert result.tool_input_guardrail_results[0].output.output_info == "input-checked"
assert len(result.tool_output_guardrail_results) == 1
assert result.tool_output_guardrail_results[0].output.output_info == "output-checked"
@pytest.mark.parametrize("tool_guardrail_behavior", ["allow", "reject_content"])
@pytest.mark.asyncio
async def test_streamed_trip_replaces_current_tool_output_guardrail_results(
tool_guardrail_behavior: str,
) -> None:
"""A copied terminal tool result is replaced in public and RunState guardrail results."""
original_outputs: list[ToolGuardrailFunctionOutput] = []
@tool_output_guardrail
def retain_output(data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
if tool_guardrail_behavior == "reject_content":
output = ToolGuardrailFunctionOutput.reject_content(
message=f"Rejected sensitive tool output: {data.output}",
output_info=data.output,
)
else:
output = ToolGuardrailFunctionOutput.allow(output_info=data.output)
original_outputs.append(output)
return output
@function_tool(name_override="secret_tool", tool_output_guardrails=[retain_output])
def secret_tool() -> str:
return "blocked-secret"
def reject_output(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
model = ScriptedModel()
model.enqueue([get_function_tool_call("secret_tool", "{}", call_id="call-secret")])
agent = Agent(
name="test",
model=model,
tools=[secret_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=reject_output)],
)
result = Runner.run_streamed(agent, "run")
prior_output = ToolGuardrailFunctionOutput.allow(output_info="prior-safe")
prior_result = ToolOutputGuardrailResult(guardrail=retain_output, output=prior_output)
result.tool_output_guardrail_results.append(prior_result)
with pytest.raises(OutputGuardrailTripwireTriggered):
await consume_stream(result)
assert len(original_outputs) == 1
assert len(result.tool_output_guardrail_results) == 2
assert result.tool_output_guardrail_results[0] is prior_result
assert result.tool_output_guardrail_results[0].output is prior_output
public_output = result.tool_output_guardrail_results[1].output
assert public_output is not original_outputs[0]
assert public_output.output_info == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
assert public_output.behavior["type"] == tool_guardrail_behavior
if public_output.behavior["type"] == "reject_content":
assert public_output.behavior["message"] == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
assert original_outputs[0].behavior["type"] == "reject_content"
assert "blocked-secret" in original_outputs[0].behavior["message"]
assert result._state is not None
# The caller-added public result was never owned by RunState, so only the current
# data-free result is added to that owner.
assert len(result._state._tool_output_guardrail_results) == 1
state_output = result._state._tool_output_guardrail_results[0].output
assert state_output is public_output
assert state_output.output_info == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
assert state_output.behavior["type"] == tool_guardrail_behavior
serialized_state = result.to_state().to_json()
serialized_results = serialized_state["tool_output_guardrail_results"]
assert serialized_results[0]["output"]["behavior"]["type"] == "allow"
serialized_behavior = serialized_results[-1]["output"]["behavior"]
assert serialized_behavior["type"] == tool_guardrail_behavior
if tool_guardrail_behavior == "reject_content":
assert serialized_behavior["message"] == run_loop._OUTPUT_GUARDRAIL_BLOCKED_TOOL_OUTPUT
assert "blocked-secret" not in json.dumps(serialized_state)
@pytest.mark.asyncio
async def test_streamed_tool_guardrail_results_match_non_streamed():
"""The same run reports the same tool guardrail results in both execution modes."""
def _build() -> tuple[ScriptedModel, Agent[Any]]:
model, agent = make_model_and_agent(tools=[_tool_with_guardrails()])
model.extend(
[
[get_function_tool_call("guarded_tool", "{}", call_id="call_1")],
[get_function_tool_call("guarded_tool", "{}", call_id="call_2")],
[get_text_message("done")],
]
)
return model, agent
_, non_streamed_agent = _build()
non_streamed = await Runner.run(non_streamed_agent, input="hello")
_, streamed_agent = _build()
streamed = Runner.run_streamed(streamed_agent, input="hello")
await consume_stream(streamed)
assert len(non_streamed.tool_input_guardrail_results) == 2
assert len(non_streamed.tool_output_guardrail_results) == 2
assert len(streamed.tool_input_guardrail_results) == len(
non_streamed.tool_input_guardrail_results
)
assert len(streamed.tool_output_guardrail_results) == len(
non_streamed.tool_output_guardrail_results
)
@pytest.mark.asyncio
async def test_streamed_tool_guardrail_results_survive_handoff():
"""Tool guardrail results from a handoff turn reach the streamed result."""
model = ScriptedModel()
target = Agent(name="target", model=model)
agent = Agent(
name="source",
model=model,
tools=[_tool_with_guardrails()],
handoffs=[target],
)
model.extend(
[
[
get_function_tool_call("guarded_tool", "{}", call_id="call_1"),
get_handoff_tool_call(target),
],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="hello")
await consume_stream(result)
assert result.final_output == "done"
assert len(result.tool_input_guardrail_results) == 1
assert len(result.tool_output_guardrail_results) == 1
@pytest.mark.asyncio
async def test_streamed_interruption_reports_tool_guardrail_results():
"""An interrupted streamed turn reports the tool guardrail results it produced."""
@tool_input_guardrail
def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="input-checked")
@function_tool(name_override="plain_tool", tool_input_guardrails=[record_input])
def plain_tool() -> str:
return "plain-result"
@function_tool(name_override="approval_tool", needs_approval=True)
def approval_tool() -> str:
return "approved-result"
model, agent = make_model_and_agent(tools=[plain_tool, approval_tool])
model.enqueue(
[
get_function_tool_call("plain_tool", "{}", call_id="call_plain"),
get_function_tool_call("approval_tool", "{}", call_id="call_approval"),
]
)
result = Runner.run_streamed(agent, input="hello")
await consume_stream(result)
assert len(result.interruptions) == 1
assert len(result.tool_input_guardrail_results) == 1
assert result.tool_input_guardrail_results[0].output.output_info == "input-checked"
@pytest.mark.asyncio
async def test_streamed_tool_guardrail_results_persist_into_run_state():
"""Tool guardrail results from a streamed run round-trip through RunState."""
model, agent = make_model_and_agent(tools=[_tool_with_guardrails()])
model.extend(
[
[get_function_tool_call("guarded_tool", "{}", call_id="call_1")],
[get_text_message("done")],
]
)
result = Runner.run_streamed(agent, input="hello")
await consume_stream(result)
state = result.to_state()
assert len(state._tool_input_guardrail_results) == 1
assert len(state._tool_output_guardrail_results) == 1
@pytest.mark.asyncio
async def test_streamed_resume_tool_guardrail_results_match_non_streamed():
"""Resumed-turn accounting stays identical across execution modes.
Accumulating tool guardrail results for streamed runs must not change how a resumed turn
reports them, so this pins streamed and non-streamed resumes to the same value rather than
to a specific count.
"""
def _build() -> tuple[ScriptedModel, Agent[Any]]:
@tool_input_guardrail
def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="input-checked")
@tool_output_guardrail
def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="output-checked")
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[record_input],
tool_output_guardrails=[record_output],
)
def approval_tool() -> str:
return "approved-result"
model, agent = make_model_and_agent(tools=[approval_tool])
model.extend(
[
[get_function_tool_call("approval_tool", "{}", call_id="call_approval")],
[get_text_message("done")],
]
)
return model, agent
_, non_streamed_agent = _build()
non_streamed_first = await Runner.run(non_streamed_agent, "hello")
assert len(non_streamed_first.interruptions) == 1
non_streamed_state = non_streamed_first.to_state()
non_streamed_state.approve(non_streamed_first.interruptions[0])
non_streamed = await Runner.run(non_streamed_agent, non_streamed_state)
_, streamed_agent = _build()
streamed_first = Runner.run_streamed(streamed_agent, input="hello")
await consume_stream(streamed_first)
assert len(streamed_first.interruptions) == 1
streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first)
assert non_streamed.final_output == "done"
assert streamed.final_output == "done"
assert len(streamed.tool_input_guardrail_results) == len(
non_streamed.tool_input_guardrail_results
)
assert len(streamed.tool_output_guardrail_results) == len(
non_streamed.tool_output_guardrail_results
)
@pytest.mark.asyncio
async def test_streamed_resume_terminal_turn_reports_tool_guardrail_results():
"""A resumed streamed turn that ends the run reports its tool guardrail results.
With `tool_use_behavior="stop_on_first_tool"` the approved tool produces the final output
inside the resumed turn, so the run finalizes from the resume branch rather than from the
regular turn loop.
"""
def _build() -> tuple[ScriptedModel, Agent[Any]]:
@tool_input_guardrail
def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="input-checked")
@tool_output_guardrail
def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="output-checked")
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[record_input],
tool_output_guardrails=[record_output],
)
def approval_tool() -> str:
return "approved-result"
model = ScriptedModel()
agent = Agent(
name="TestAgent",
model=model,
tools=[approval_tool],
tool_use_behavior="stop_on_first_tool",
)
model.enqueue([get_function_tool_call("approval_tool", "{}", call_id="call_approval")])
return model, agent
_, streamed_agent = _build()
streamed_first = Runner.run_streamed(streamed_agent, input="hello")
await consume_stream(streamed_first)
assert len(streamed_first.interruptions) == 1
streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first)
assert streamed.final_output == "approved-result"
assert len(streamed.tool_input_guardrail_results) == 1
assert streamed.tool_input_guardrail_results[0].output.output_info == "input-checked"
assert len(streamed.tool_output_guardrail_results) == 1
assert streamed.tool_output_guardrail_results[0].output.output_info == "output-checked"
_, non_streamed_agent = _build()
non_streamed_first = await Runner.run(non_streamed_agent, "hello")
assert len(non_streamed_first.interruptions) == 1
non_streamed_state = non_streamed_first.to_state()
non_streamed_state.approve(non_streamed_first.interruptions[0])
non_streamed = await Runner.run(non_streamed_agent, non_streamed_state)
assert len(streamed.tool_input_guardrail_results) == len(
non_streamed.tool_input_guardrail_results
)
assert len(streamed.tool_output_guardrail_results) == len(
non_streamed.tool_output_guardrail_results
)
@pytest.mark.asyncio
async def test_streamed_resume_handoff_turn_reports_tool_guardrail_results():
"""A resumed streamed turn that hands off keeps the guardrail results it produced."""
def _build() -> tuple[ScriptedModel, Agent[Any]]:
@tool_input_guardrail
def record_input(_data: ToolInputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="input-checked")
@tool_output_guardrail
def record_output(_data: ToolOutputGuardrailData) -> ToolGuardrailFunctionOutput:
return ToolGuardrailFunctionOutput.allow(output_info="output-checked")
@function_tool(
name_override="approval_tool",
needs_approval=True,
tool_input_guardrails=[record_input],
tool_output_guardrails=[record_output],
)
def approval_tool() -> str:
return "approved-result"
model = ScriptedModel()
target = Agent(name="target", model=model)
agent = Agent(
name="TestAgent",
model=model,
tools=[approval_tool],
handoffs=[target],
)
model.extend(
[
[
get_function_tool_call("approval_tool", "{}", call_id="call_approval"),
get_handoff_tool_call(target),
],
[get_text_message("done")],
]
)
return model, agent
_, streamed_agent = _build()
streamed_first = Runner.run_streamed(streamed_agent, input="hello")
await consume_stream(streamed_first)
assert len(streamed_first.interruptions) == 1
streamed = await resume_streamed_after_first_approval(streamed_agent, streamed_first)
assert streamed.final_output == "done"
assert len(streamed.tool_input_guardrail_results) == 1
assert len(streamed.tool_output_guardrail_results) == 1
_, non_streamed_agent = _build()
non_streamed_first = await Runner.run(non_streamed_agent, "hello")
non_streamed_state = non_streamed_first.to_state()
non_streamed_state.approve(non_streamed_first.interruptions[0])
non_streamed = await Runner.run(non_streamed_agent, non_streamed_state)
assert len(streamed.tool_input_guardrail_results) == len(
non_streamed.tool_input_guardrail_results
)
assert len(streamed.tool_output_guardrail_results) == len(
non_streamed.tool_output_guardrail_results
)
@pytest.mark.asyncio
async def test_streamed_cancel_during_output_guardrail_starts_no_final_turn_write() -> None:
"""Immediate cancel() must not start a final-turn session write.
`cancel()` in its default immediate mode cancels outstanding work; `after_turn` is the mode
that finishes the turn and saves. A cancellation raised inside an in-flight output guardrail
must therefore not be treated like a guardrail error, or `stream_events()` would stay blocked
on whatever the session backend does.
"""
entered_guardrail = asyncio.Event()
never_set = asyncio.Event()
cancelled = False
tool_call_count = 0
async def parked_output_guardrail(
_context: RunContextWrapper[Any],
_agent: Agent[Any],
_output: Any,
) -> GuardrailFunctionOutput:
entered_guardrail.set()
await never_set.wait()
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=False)
class BlockingAfterCancelSession(SimpleListSession):
"""Writes before the cancel are the turn's own; any write after it would hang the stream."""
def __init__(self) -> None:
super().__init__()
self.wrote_after_cancel = False
async def add_items(self, items: list[TResponseInputItem]) -> None:
if cancelled:
self.wrote_after_cancel = True
await never_set.wait()
await super().add_items(items)
@function_tool(name_override="commit_tool")
def commit_tool() -> str:
nonlocal tool_call_count
tool_call_count += 1
return "committed-result"
model = ScriptedModel()
model.enqueue(
[
get_text_message("assistant-preamble"),
get_function_tool_call("commit_tool", "{}", call_id="call-cancel"),
]
)
agent = Agent(
name="test",
model=model,
tools=[commit_tool],
tool_use_behavior="stop_on_first_tool",
output_guardrails=[OutputGuardrail(guardrail_function=parked_output_guardrail)],
)
session = BlockingAfterCancelSession()
result = Runner.run_streamed(agent, "Use commit_tool", session=session)
async def drain() -> None:
with contextlib.suppress(asyncio.CancelledError):
async for _event in result.stream_events():
pass
drain_task = asyncio.create_task(drain())
try:
await asyncio.wait_for(entered_guardrail.wait(), timeout=1)
cancelled = True
result.cancel()
# A final-turn write here would block on never_set and hang the stream.
await asyncio.wait_for(drain_task, timeout=1)
finally:
if not drain_task.done():
drain_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await drain_task
assert session.wrote_after_cancel is False
assert tool_call_count == 1
saved_items = await session.get_items()
saved = [item.get("type") or item.get("role") for item in saved_items if isinstance(item, dict)]
assert saved == ["user"]