2894 lines
100 KiB
Python
2894 lines
100 KiB
Python
"""Error-path logging must not leak model/tool payloads when data logging is disabled.
|
|
|
|
The exception attached to a ``SpanError`` is already redacted based on the tracing
|
|
flag, but the sibling ``logger.error`` calls used to log the raw exception (and, for
|
|
tool actions, the full traceback) unconditionally. These tests lock in that those log
|
|
statements honor ``_debug.DONT_LOG_MODEL_DATA`` / ``_debug.DONT_LOG_TOOL_DATA``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import pickle
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import warnings
|
|
from logging.handlers import QueueHandler
|
|
from pathlib import Path
|
|
from queue import SimpleQueue
|
|
from typing import Any, Literal, cast
|
|
from unittest.mock import patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from openai import AsyncOpenAI
|
|
from pydantic import BaseModel, SkipValidation, ValidationError
|
|
|
|
import agents._debug as _debug
|
|
from agents import (
|
|
Agent,
|
|
GuardrailFunctionOutput,
|
|
InputGuardrail,
|
|
ModelBehaviorError,
|
|
ModelSettings,
|
|
ModelTracing,
|
|
OpenAIResponsesModel,
|
|
OutputGuardrail,
|
|
RunConfig,
|
|
RunContextWrapper,
|
|
RunErrorHandlerInput,
|
|
RunErrorHandlerResult,
|
|
Runner,
|
|
RunResultStreaming,
|
|
UserError,
|
|
function_tool,
|
|
handoff,
|
|
trace,
|
|
)
|
|
from agents.agent_output import AgentOutputSchema
|
|
from agents.exceptions import (
|
|
BaseExceptionGroup,
|
|
_detach_data_redacted_error_traceback,
|
|
_mark_error_data_redacted,
|
|
)
|
|
from agents.logger import (
|
|
log_model_action_debug,
|
|
log_model_action_error,
|
|
log_model_action_warning,
|
|
log_model_and_tool_action_debug,
|
|
log_model_and_tool_action_error,
|
|
log_model_and_tool_action_warning,
|
|
log_model_and_tool_data_warning,
|
|
log_tool_action_debug,
|
|
log_tool_action_error as log_shared_tool_action_error,
|
|
log_tool_action_warning,
|
|
)
|
|
from agents.realtime import RealtimeAgent, realtime_handoff
|
|
from agents.run import AgentRunner
|
|
from agents.run_internal.run_loop import _safe_redacted_persistence_error
|
|
from agents.run_internal.tool_execution import (
|
|
log_tool_action_error,
|
|
resolve_approval_rejection_message,
|
|
)
|
|
from agents.run_state import _deserialize_items
|
|
from agents.testing import ScriptedModel
|
|
from agents.tool_context import ToolContext
|
|
from agents.tracing.processor_interface import TracingProcessor
|
|
from agents.tracing.provider import SynchronousMultiTracingProcessor
|
|
from agents.tracing.spans import Span
|
|
from agents.tracing.traces import Trace
|
|
|
|
from .test_responses import get_function_tool_call, get_text_message
|
|
from .utils.simple_session import SimpleListSession
|
|
|
|
_SECRET = "super secret prompt content"
|
|
|
|
|
|
class _RecordingHandler(logging.Handler):
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.records: list[logging.LogRecord] = []
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
self.records.append(record)
|
|
|
|
|
|
class _HostileException(Exception):
|
|
def __str__(self) -> str:
|
|
raise AssertionError("redacted logging inspected __str__")
|
|
|
|
def __repr__(self) -> str:
|
|
raise AssertionError("redacted logging inspected __repr__")
|
|
|
|
def __getattribute__(self, name: str):
|
|
if name in {"__class__", "__traceback__"}:
|
|
raise AssertionError(f"redacted logging inspected {name}")
|
|
return super().__getattribute__(name)
|
|
|
|
|
|
class _HostileAttributeWriteException(Exception):
|
|
def __setattr__(self, name: str, value: Any) -> None:
|
|
raise RuntimeError("redacted handling mutated the handler exception")
|
|
|
|
|
|
class _DirectBaseException(BaseException):
|
|
pass
|
|
|
|
|
|
class _HostileClassBaseException(BaseException):
|
|
@property
|
|
def __class__(self) -> type[object]:
|
|
raise RuntimeError("hostile class descriptor secret")
|
|
|
|
|
|
class _HybridCancelledError(asyncio.CancelledError, Exception):
|
|
pass
|
|
|
|
|
|
class _TruthinessException(Exception):
|
|
def __init__(self, *, truthy: bool) -> None:
|
|
super().__init__("diagnostic failure")
|
|
self.truthy = truthy
|
|
self.bool_calls = 0
|
|
|
|
def __bool__(self) -> bool:
|
|
self.bool_calls += 1
|
|
if self.truthy:
|
|
raise AssertionError("logging inspected exception truthiness")
|
|
return False
|
|
|
|
|
|
class _HostileValue:
|
|
def __str__(self) -> str:
|
|
raise AssertionError("redacted logging inspected __str__")
|
|
|
|
def __repr__(self) -> str:
|
|
raise AssertionError("redacted logging inspected __repr__")
|
|
|
|
|
|
class _FailingTracingProcessor(TracingProcessor):
|
|
def __init__(self) -> None:
|
|
self.str_calls = 0
|
|
self.lock = threading.Lock()
|
|
|
|
def __str__(self) -> str:
|
|
self.str_calls += 1
|
|
return "SECRET_TRACE_PROCESSOR_ID"
|
|
|
|
def _fail(self) -> None:
|
|
raise ValueError(_SECRET)
|
|
|
|
def on_trace_start(self, trace: Trace) -> None:
|
|
self._fail()
|
|
|
|
def on_trace_end(self, trace: Trace) -> None:
|
|
self._fail()
|
|
|
|
def on_span_start(self, span: Span[Any]) -> None:
|
|
self._fail()
|
|
|
|
def on_span_end(self, span: Span[Any]) -> None:
|
|
self._fail()
|
|
|
|
def shutdown(self) -> None:
|
|
self._fail()
|
|
|
|
def force_flush(self) -> None:
|
|
self._fail()
|
|
|
|
|
|
def _emit_shared_error_for_location(test_logger, helper) -> None:
|
|
helper(test_logger, "Fixed operational message", ValueError("failure"))
|
|
|
|
|
|
def _emit_tool_execution_error_for_location() -> None:
|
|
log_tool_action_error("Fixed operational message", ValueError("failure"))
|
|
|
|
|
|
def _emit_data_warning_for_location(test_logger) -> None:
|
|
log_model_and_tool_data_warning(
|
|
test_logger,
|
|
"Fixed operational warning",
|
|
diagnostic_message="Warning for %s",
|
|
diagnostic_args=lambda: ("diagnostic-value",),
|
|
)
|
|
|
|
|
|
def _responses_model() -> OpenAIResponsesModel:
|
|
return OpenAIResponsesModel(
|
|
model="test-model",
|
|
openai_client=AsyncOpenAI(
|
|
api_key="test",
|
|
http_client=httpx.AsyncClient(trust_env=False),
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
async def test_get_response_error_redacts_exception_from_logs(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = _responses_model()
|
|
|
|
async def raise_fetch(*args, **kwargs):
|
|
raise ValueError(_SECRET)
|
|
|
|
monkeypatch.setattr(model, "_fetch_response", raise_fetch)
|
|
|
|
with patch("agents.models.openai_responses.logger") as mock_logger:
|
|
with trace(workflow_name="test"):
|
|
with pytest.raises(ValueError):
|
|
await model.get_response(
|
|
"instr",
|
|
"input",
|
|
ModelSettings(),
|
|
[],
|
|
None,
|
|
[],
|
|
ModelTracing.ENABLED,
|
|
previous_response_id=None,
|
|
)
|
|
|
|
mock_logger.error.assert_called_once()
|
|
logged = str(mock_logger.error.call_args)
|
|
assert _SECRET not in logged
|
|
assert "ValueError" not in logged
|
|
assert "Error getting response" in logged
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
async def test_get_response_error_logs_exception_when_model_data_enabled(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
model = _responses_model()
|
|
|
|
async def raise_fetch(*args, **kwargs):
|
|
raise ValueError(_SECRET)
|
|
|
|
monkeypatch.setattr(model, "_fetch_response", raise_fetch)
|
|
|
|
with patch("agents.models.openai_responses.logger") as mock_logger:
|
|
with trace(workflow_name="test"):
|
|
with pytest.raises(ValueError):
|
|
await model.get_response(
|
|
"instr",
|
|
"input",
|
|
ModelSettings(),
|
|
[],
|
|
None,
|
|
[],
|
|
ModelTracing.ENABLED,
|
|
previous_response_id=None,
|
|
)
|
|
|
|
mock_logger.error.assert_called_once()
|
|
assert _SECRET in str(mock_logger.error.call_args)
|
|
|
|
|
|
@pytest.mark.allow_call_model_methods
|
|
@pytest.mark.asyncio
|
|
async def test_stream_response_error_redacts_exception_from_logs(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = _responses_model()
|
|
|
|
async def raise_fetch(*args, **kwargs):
|
|
raise ValueError(_SECRET)
|
|
|
|
monkeypatch.setattr(model, "_fetch_response", raise_fetch)
|
|
|
|
with patch("agents.models.openai_responses.logger") as mock_logger:
|
|
with trace(workflow_name="test"):
|
|
with pytest.raises(ValueError):
|
|
async for _ in model.stream_response(
|
|
"instr",
|
|
"input",
|
|
ModelSettings(),
|
|
[],
|
|
None,
|
|
[],
|
|
ModelTracing.ENABLED,
|
|
previous_response_id=None,
|
|
):
|
|
pass
|
|
|
|
mock_logger.error.assert_called_once()
|
|
logged = str(mock_logger.error.call_args)
|
|
assert _SECRET not in logged
|
|
assert "ValueError" not in logged
|
|
assert "Error streaming response" in logged
|
|
|
|
|
|
def test_log_tool_action_error_redacts_by_default(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
|
|
with patch("agents.run_internal.tool_execution.logger") as mock_logger:
|
|
log_tool_action_error("Shell executor failed", ValueError("rm -rf /secret/path"))
|
|
|
|
mock_logger.error.assert_called_once()
|
|
assert mock_logger.error.call_args.args == ("%s", "Shell executor failed")
|
|
# No traceback either, since it can embed the same sensitive data.
|
|
assert mock_logger.error.call_args.kwargs.get("exc_info") in (None, False)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("helper", "model_flag", "tool_flag"),
|
|
[
|
|
(log_model_action_error, True, False),
|
|
(log_model_action_debug, True, False),
|
|
(log_model_action_warning, True, False),
|
|
(log_tool_action_debug, False, True),
|
|
(log_shared_tool_action_error, False, True),
|
|
(log_tool_action_warning, False, True),
|
|
(log_model_and_tool_action_error, True, False),
|
|
(log_model_and_tool_action_error, False, True),
|
|
(log_model_and_tool_action_debug, True, False),
|
|
(log_model_and_tool_action_warning, False, True),
|
|
],
|
|
)
|
|
def test_shared_error_helpers_do_not_inspect_or_attach_redacted_exceptions(
|
|
monkeypatch,
|
|
helper,
|
|
model_flag: bool,
|
|
tool_flag: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_flag)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_flag)
|
|
test_logger = logging.Logger("sensitive-logging-redacted")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
hostile = _HostileException()
|
|
|
|
helper(test_logger, "Fixed operational message", hostile)
|
|
|
|
assert len(handler.records) == 1
|
|
record = handler.records[0]
|
|
assert record.msg == "%s"
|
|
assert record.args == ("Fixed operational message",)
|
|
assert record.exc_info is None
|
|
assert record.exc_text is None
|
|
assert hostile not in record.__dict__.values()
|
|
assert logging.Formatter().format(record) == "Fixed operational message"
|
|
|
|
|
|
def test_shared_error_helper_preserves_diagnostics_when_enabled(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-diagnostic")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
error = ValueError(_SECRET)
|
|
|
|
log_shared_tool_action_error(test_logger, "Tool failed", error)
|
|
|
|
record = handler.records[0]
|
|
assert isinstance(record.args, tuple)
|
|
assert error in record.args
|
|
assert record.exc_info is not None
|
|
assert record.exc_info[1] is error
|
|
assert _SECRET in logging.Formatter().format(record)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"helper",
|
|
[log_shared_tool_action_error, log_tool_action_warning],
|
|
)
|
|
@pytest.mark.parametrize("truthy", [False, True], ids=["falsey", "hostile_bool"])
|
|
def test_shared_error_helpers_do_not_evaluate_exception_truthiness(
|
|
monkeypatch,
|
|
helper,
|
|
truthy: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-exception-truthiness")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
error = _TruthinessException(truthy=truthy)
|
|
|
|
try:
|
|
raise error
|
|
except _TruthinessException:
|
|
helper(test_logger, "Tool failed", error)
|
|
|
|
record = handler.records[0]
|
|
assert error.bool_calls == 0
|
|
assert record.exc_info is not None
|
|
assert record.exc_info[0] is type(error)
|
|
assert record.exc_info[1] is error
|
|
assert record.exc_info[2] is error.__traceback__
|
|
|
|
|
|
@pytest.mark.parametrize("redacted", [True, False])
|
|
def test_shared_error_helper_conditionally_attaches_diagnostic_extra(
|
|
monkeypatch, redacted: bool
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted)
|
|
test_logger = logging.Logger("sensitive-logging-diagnostic-extra")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
extra_calls = 0
|
|
|
|
def diagnostic_extra() -> dict[str, object]:
|
|
nonlocal extra_calls
|
|
extra_calls += 1
|
|
return {"sandbox_id": _SECRET}
|
|
|
|
log_tool_action_warning(
|
|
test_logger,
|
|
"Tool failed",
|
|
ValueError("failure"),
|
|
diagnostic_extra=diagnostic_extra,
|
|
)
|
|
|
|
record = handler.records[0]
|
|
assert extra_calls == (0 if redacted else 1)
|
|
assert ("openai_agents_diagnostic_context" in record.__dict__) is not redacted
|
|
if not redacted:
|
|
assert record.__dict__["openai_agents_diagnostic_context"] == {"sandbox_id": _SECRET}
|
|
|
|
|
|
def test_shared_error_helper_ignores_diagnostic_extra_failure(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-diagnostic-extra-failure")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
error = RuntimeError("original failure")
|
|
|
|
def diagnostic_extra() -> dict[str, object]:
|
|
raise AttributeError("metadata failure")
|
|
|
|
log_tool_action_warning(
|
|
test_logger,
|
|
"Tool failed",
|
|
error,
|
|
diagnostic_extra=diagnostic_extra,
|
|
)
|
|
|
|
record = handler.records[0]
|
|
assert "openai_agents_diagnostic_context" not in record.__dict__
|
|
assert record.exc_info is not None
|
|
assert record.exc_info[1] is error
|
|
assert "original failure" in logging.Formatter().format(record)
|
|
|
|
|
|
def test_shared_data_warning_does_not_inspect_or_attach_redacted_arguments(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-data-warning")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
hostile = _HostileValue()
|
|
|
|
log_model_and_tool_data_warning(
|
|
test_logger,
|
|
"Fixed operational warning",
|
|
diagnostic_message="Warning for %s",
|
|
diagnostic_args=lambda: (hostile,),
|
|
)
|
|
|
|
assert len(handler.records) == 1
|
|
record = handler.records[0]
|
|
assert record.msg == "Fixed operational warning"
|
|
assert record.args == ()
|
|
assert record.exc_info is None
|
|
assert record.exc_text is None
|
|
assert hostile not in record.__dict__.values()
|
|
assert logging.Formatter().format(record) == "Fixed operational warning"
|
|
|
|
|
|
def test_shared_data_warning_preserves_diagnostics_when_enabled(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-data-warning")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
diagnostic_value = "diagnostic-agent"
|
|
|
|
log_model_and_tool_data_warning(
|
|
test_logger,
|
|
"Fixed operational warning",
|
|
diagnostic_message="Warning for %s",
|
|
diagnostic_args=lambda: (diagnostic_value,),
|
|
)
|
|
|
|
assert len(handler.records) == 1
|
|
record = handler.records[0]
|
|
assert record.msg == "Warning for %s"
|
|
assert record.args == (diagnostic_value,)
|
|
assert logging.Formatter().format(record) == "Warning for diagnostic-agent"
|
|
|
|
|
|
def test_shared_data_warning_falls_back_when_diagnostic_arguments_fail(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
test_logger = logging.Logger("sensitive-logging-data-warning")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
|
|
def diagnostic_args() -> tuple[object, ...]:
|
|
raise RuntimeError("SECRET_DIAGNOSTIC_ARGUMENT_FAILURE")
|
|
|
|
log_model_and_tool_data_warning(
|
|
test_logger,
|
|
"Fixed operational warning",
|
|
diagnostic_message="Warning for %s",
|
|
diagnostic_args=diagnostic_args,
|
|
)
|
|
|
|
assert len(handler.records) == 1
|
|
record = handler.records[0]
|
|
assert record.msg == "Fixed operational warning"
|
|
assert record.args == ()
|
|
assert record.exc_info is None
|
|
assert "SECRET_DIAGNOSTIC_ARGUMENT_FAILURE" not in logging.Formatter().format(record)
|
|
|
|
|
|
def test_shared_data_warning_preserves_direct_caller_location(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
test_logger = logging.Logger("sensitive-logging-data-warning-location")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
|
|
_emit_data_warning_for_location(test_logger)
|
|
|
|
record = handler.records[0]
|
|
assert Path(record.pathname).resolve() == Path(__file__).resolve()
|
|
assert record.funcName == "_emit_data_warning_for_location"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_redacted", "tool_redacted"),
|
|
[
|
|
(True, False),
|
|
(False, True),
|
|
(True, True),
|
|
(False, False),
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("scenario", "secrets", "redacted_message"),
|
|
[
|
|
(
|
|
"missing_agent",
|
|
("SECRET_AGENT_NAME",),
|
|
"Agent not found, skipping item",
|
|
),
|
|
(
|
|
"missing_agent_field",
|
|
("SECRET_ITEM_TYPE",),
|
|
"Item missing agent field, skipping",
|
|
),
|
|
(
|
|
"missing_handoff_agents",
|
|
("SECRET_SOURCE_AGENT", "SECRET_TARGET_AGENT"),
|
|
"Skipping handoff output item: could not resolve agents",
|
|
),
|
|
],
|
|
)
|
|
def test_run_state_deserialization_warnings_follow_both_data_policies(
|
|
monkeypatch,
|
|
model_redacted: bool,
|
|
tool_redacted: bool,
|
|
scenario: str,
|
|
secrets: tuple[str, ...],
|
|
redacted_message: str,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
|
|
known_agent = Agent(name="KnownAgent")
|
|
if scenario == "missing_agent":
|
|
item_data = {
|
|
"type": "message_output_item",
|
|
"agent": secrets[0],
|
|
"raw_item": {},
|
|
}
|
|
elif scenario == "missing_agent_field":
|
|
item_data = {
|
|
"type": secrets[0],
|
|
"raw_item": {},
|
|
}
|
|
else:
|
|
item_data = {
|
|
"type": "handoff_output_item",
|
|
"agent": "KnownAgent",
|
|
"source_agent": secrets[0],
|
|
"target_agent": secrets[1],
|
|
"raw_item": {},
|
|
}
|
|
|
|
test_logger = logging.Logger("sensitive-logging-run-state")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
with patch("agents.run_state.logger", test_logger):
|
|
result = _deserialize_items([item_data], {"KnownAgent": known_agent})
|
|
|
|
assert result == []
|
|
assert len(handler.records) == 1
|
|
record = handler.records[0]
|
|
rendered = logging.Formatter().format(record)
|
|
redacted = model_redacted or tool_redacted
|
|
if redacted:
|
|
assert record.msg == redacted_message
|
|
assert record.args == ()
|
|
assert record.exc_info is None
|
|
assert record.exc_text is None
|
|
assert rendered == redacted_message
|
|
for secret in secrets:
|
|
assert secret not in rendered
|
|
assert secret not in record.__dict__.values()
|
|
else:
|
|
for secret in secrets:
|
|
assert secret in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"operation",
|
|
[
|
|
"on_trace_start",
|
|
"on_trace_end",
|
|
"on_span_start",
|
|
"on_span_end",
|
|
"force_flush",
|
|
"shutdown",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
("model_redacted", "tool_redacted"),
|
|
[(True, False), (False, True), (False, False)],
|
|
)
|
|
def test_trace_processor_failure_identity_follows_both_data_policies(
|
|
monkeypatch,
|
|
operation: str,
|
|
model_redacted: bool,
|
|
tool_redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
|
|
test_logger = logging.Logger("sensitive-logging-trace-processor", level=logging.DEBUG)
|
|
test_logger.propagate = False
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
failing = _FailingTracingProcessor()
|
|
multi = SynchronousMultiTracingProcessor()
|
|
multi.add_tracing_processor(failing)
|
|
|
|
with patch("agents.tracing.provider.logger", test_logger):
|
|
if operation.startswith(("on_trace", "on_span")):
|
|
getattr(multi, operation)(object())
|
|
else:
|
|
getattr(multi, operation)()
|
|
|
|
record = next(record for record in handler.records if record.levelno == logging.ERROR)
|
|
redacted = model_redacted or tool_redacted
|
|
if redacted:
|
|
assert "openai_agents_diagnostic_context" not in record.__dict__
|
|
assert failing not in record.__dict__.values()
|
|
assert record.exc_info is None
|
|
assert _SECRET not in logging.Formatter().format(record)
|
|
assert failing.str_calls == 0
|
|
else:
|
|
processor_identity = record.__dict__["openai_agents_diagnostic_context"]["trace_processor"]
|
|
assert isinstance(processor_identity, str)
|
|
assert type(failing).__module__ in processor_identity
|
|
assert type(failing).__qualname__ in processor_identity
|
|
assert f"{id(failing):x}" in processor_identity
|
|
prepared = QueueHandler(SimpleQueue()).prepare(record)
|
|
pickle.dumps(prepared)
|
|
assert record.exc_info is not None
|
|
assert record.exc_info[1] is not None
|
|
assert _SECRET in logging.Formatter().format(record)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"helper",
|
|
[log_shared_tool_action_error, log_tool_action_warning],
|
|
)
|
|
def test_shared_error_helpers_preserve_direct_caller_location(monkeypatch, helper) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
test_logger = logging.Logger("sensitive-logging-location")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
|
|
_emit_shared_error_for_location(test_logger, helper)
|
|
|
|
record = handler.records[0]
|
|
assert Path(record.pathname).resolve() == Path(__file__).resolve()
|
|
assert record.funcName == "_emit_shared_error_for_location"
|
|
|
|
|
|
def test_tool_execution_error_helper_preserves_external_caller_location(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
test_logger = logging.Logger("sensitive-logging-wrapped-location")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
|
|
with patch("agents.run_internal.tool_execution.logger", test_logger):
|
|
_emit_tool_execution_error_for_location()
|
|
|
|
record = handler.records[0]
|
|
assert Path(record.pathname).resolve() == Path(__file__).resolve()
|
|
assert record.funcName == "_emit_tool_execution_error_for_location"
|
|
|
|
|
|
def test_shared_error_helper_drops_exception_chains_and_notes(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
test_logger = logging.Logger("sensitive-logging-chain")
|
|
handler = _RecordingHandler()
|
|
test_logger.addHandler(handler)
|
|
cause = ValueError(f"{_SECRET} cause")
|
|
error = RuntimeError(f"{_SECRET} outer")
|
|
error.__cause__ = cause
|
|
if hasattr(error, "add_note"):
|
|
error.add_note(f"{_SECRET} note")
|
|
else:
|
|
error.__notes__ = [f"{_SECRET} note"]
|
|
|
|
log_model_action_error(test_logger, "Model failed", error)
|
|
|
|
record = handler.records[0]
|
|
assert record.exc_info is None
|
|
assert record.exc_text is None
|
|
assert error not in record.__dict__.values()
|
|
assert _SECRET not in logging.Formatter().format(record)
|
|
|
|
|
|
def test_log_tool_action_error_logs_full_when_tool_data_enabled(monkeypatch) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
with patch("agents.run_internal.tool_execution.logger") as mock_logger:
|
|
log_tool_action_error("Shell executor failed", ValueError("rm -rf /secret/path"))
|
|
|
|
mock_logger.error.assert_called_once()
|
|
logged = str(mock_logger.error.call_args)
|
|
assert "/secret/path" in logged
|
|
exc_info = mock_logger.error.call_args.kwargs.get("exc_info")
|
|
assert isinstance(exc_info, tuple)
|
|
assert exc_info[0] is ValueError
|
|
assert isinstance(exc_info[1], ValueError)
|
|
assert exc_info[2] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_rejection_formatter_error_redacts_exception(monkeypatch, caplog) -> None:
|
|
caplog.set_level(logging.DEBUG)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
|
|
def boom(_args):
|
|
raise ValueError("formatter blew up SECRET_FMT_123")
|
|
|
|
tool_name = "SECRET_FORMATTER_TOOL_NAME"
|
|
result = await resolve_approval_rejection_message(
|
|
context_wrapper=RunContextWrapper(context=None),
|
|
run_config=RunConfig(tool_error_formatter=boom),
|
|
tool_type="function",
|
|
tool_name=tool_name,
|
|
call_id="call_1",
|
|
)
|
|
|
|
assert isinstance(result, str) and result
|
|
record = next(
|
|
record for record in caplog.records if "Tool error formatter failed" in record.getMessage()
|
|
)
|
|
assert record.msg == "%s"
|
|
assert record.args == ("Tool error formatter failed",)
|
|
assert record.exc_info is None
|
|
assert "openai_agents_diagnostic_context" not in record.__dict__
|
|
assert tool_name not in caplog.text
|
|
assert "SECRET_FMT_123" not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_approval_rejection_formatter_error_logs_full_when_enabled(
|
|
monkeypatch, caplog
|
|
) -> None:
|
|
caplog.set_level(logging.DEBUG)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
def boom(_args):
|
|
raise ValueError("formatter blew up SECRET_FMT_123")
|
|
|
|
tool_name = "diagnostic_tool"
|
|
await resolve_approval_rejection_message(
|
|
context_wrapper=RunContextWrapper(context=None),
|
|
run_config=RunConfig(tool_error_formatter=boom),
|
|
tool_type="function",
|
|
tool_name=tool_name,
|
|
call_id="call_1",
|
|
)
|
|
|
|
record = next(
|
|
record for record in caplog.records if "Tool error formatter failed" in record.getMessage()
|
|
)
|
|
assert record.__dict__["openai_agents_diagnostic_context"] == {"tool_name": tool_name}
|
|
assert record.exc_info is not None
|
|
assert "SECRET_FMT_123" in caplog.text
|
|
|
|
|
|
_TOOL_ARGUMENT_SECRET = "SECRET_TOOL_ARGUMENT_123"
|
|
|
|
|
|
def _requires_integer_argument(value: int) -> str:
|
|
return str(value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_validation_error_redacts_payload_when_tool_data_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
tool = function_tool(_requires_integer_argument, failure_error_function=None)
|
|
payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}'
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload),
|
|
payload,
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert str(error) == f"Invalid JSON input for tool {tool.name}"
|
|
assert _TOOL_ARGUMENT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_validation_error_preserves_diagnostics_when_tool_data_enabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
tool = function_tool(_requires_integer_argument, failure_error_function=None)
|
|
payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}'
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload),
|
|
payload,
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert _TOOL_ARGUMENT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
|
|
|
|
class _AgentToolParameters(BaseModel):
|
|
value: int
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_tool_validation_error_redacts_payload_when_tool_data_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
tool = Agent(name="worker").as_tool(
|
|
tool_name="worker_tool",
|
|
tool_description="Runs the worker agent.",
|
|
parameters=_AgentToolParameters,
|
|
failure_error_function=None,
|
|
)
|
|
payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}'
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload),
|
|
payload,
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert str(error) == f"Invalid JSON input for tool {tool.name}"
|
|
assert _TOOL_ARGUMENT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_tool_validation_error_preserves_diagnostics_when_tool_data_enabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
tool = Agent(name="worker").as_tool(
|
|
tool_name="worker_tool",
|
|
tool_description="Runs the worker agent.",
|
|
parameters=_AgentToolParameters,
|
|
failure_error_function=None,
|
|
)
|
|
payload = f'{{"value": "{_TOOL_ARGUMENT_SECRET}"}}'
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments=payload),
|
|
payload,
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert _TOOL_ARGUMENT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
|
|
|
|
_TOOL_OUTPUT_SECRET = "SECRET_TOOL_OUTPUT_123"
|
|
|
|
|
|
class _IntegerOutput(BaseModel):
|
|
value: int
|
|
|
|
|
|
def _returns_wrong_typed_output(ignored: str = "") -> Any:
|
|
# The declared output type expects an ``int`` for ``value``; returning the secret string
|
|
# instead triggers output validation, whose ValidationError repr embeds the raw output value.
|
|
return {"value": _TOOL_OUTPUT_SECRET}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_output_validation_error_redacts_payload_when_tool_data_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
tool = function_tool(
|
|
_returns_wrong_typed_output,
|
|
name_override="output_tool",
|
|
output_type=_IntegerOutput,
|
|
failure_error_function=None,
|
|
)
|
|
|
|
with pytest.raises(UserError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments="{}"),
|
|
"{}",
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert _TOOL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _TOOL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_function_tool_output_validation_error_preserves_diagnostics_when_enabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
tool = function_tool(
|
|
_returns_wrong_typed_output,
|
|
name_override="output_tool",
|
|
output_type=_IntegerOutput,
|
|
failure_error_function=None,
|
|
)
|
|
|
|
with pytest.raises(UserError) as exc_info:
|
|
await tool.on_invoke_tool(
|
|
ToolContext(None, tool_name=tool.name, tool_call_id="1", tool_arguments="{}"),
|
|
"{}",
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert _TOOL_OUTPUT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
|
|
|
|
_MODEL_OUTPUT_SECRET = "SECRET_MODEL_OUTPUT_123"
|
|
_SENSITIVE_SCHEMA_SECRET = "SENSITIVE_HANDOFF_SCHEMA_SECRET_4207"
|
|
_SENSITIVE_OUTPUT_SCHEMA_SECRET = "SENSITIVE_OUTPUT_SCHEMA_SECRET_4207"
|
|
_SensitiveHandoffInput = Literal["SENSITIVE_HANDOFF_SCHEMA_SECRET_4207"]
|
|
_SensitiveOutput = Literal["SENSITIVE_OUTPUT_SCHEMA_SECRET_4207"]
|
|
|
|
|
|
class _RequiredOutput(BaseModel):
|
|
answer: str
|
|
count: int
|
|
|
|
|
|
class _PermissiveFallbackOutput(BaseModel):
|
|
payload: SkipValidation[str]
|
|
count: int
|
|
|
|
|
|
def _assert_secret_absent_from_agents_traceback(
|
|
error: BaseException,
|
|
secret: str,
|
|
*,
|
|
require_agents_frames: bool = True,
|
|
) -> None:
|
|
traceback_exception = traceback.TracebackException.from_exception(error, capture_locals=True)
|
|
agents_source = (Path(__file__).parents[1] / "src" / "agents").resolve()
|
|
agents_frames = [
|
|
frame
|
|
for frame in traceback_exception.stack
|
|
if Path(frame.filename).resolve().is_relative_to(agents_source)
|
|
]
|
|
if require_agents_frames:
|
|
assert agents_frames
|
|
for frame in agents_frames:
|
|
assert secret not in "".join((frame.locals or {}).values())
|
|
|
|
|
|
def _agents_traceback_frame_locals(error: BaseException) -> list[dict[str, Any]]:
|
|
agents_source = (Path(__file__).parents[1] / "src" / "agents").resolve()
|
|
frame_locals: list[dict[str, Any]] = []
|
|
traceback_object = error.__traceback__
|
|
while traceback_object is not None:
|
|
if (
|
|
Path(traceback_object.tb_frame.f_code.co_filename)
|
|
.resolve()
|
|
.is_relative_to(agents_source)
|
|
):
|
|
frame_locals.append(traceback_object.tb_frame.f_locals)
|
|
traceback_object = traceback_object.tb_next
|
|
return frame_locals
|
|
|
|
|
|
def _assert_handoff_closure_absent_from_traceback(
|
|
error: BaseException,
|
|
*,
|
|
callback: Any,
|
|
schema_secret: str,
|
|
) -> None:
|
|
for frame_locals in _agents_traceback_frame_locals(error):
|
|
for value in frame_locals.values():
|
|
closure = getattr(value, "__closure__", None)
|
|
if closure is None:
|
|
continue
|
|
closure_values = [cell.cell_contents for cell in closure]
|
|
assert all(item is not callback for item in closure_values)
|
|
assert schema_secret not in repr(closure_values)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_redacted", "tool_redacted", "expected_redacted"),
|
|
[
|
|
(True, False, True),
|
|
(False, True, False),
|
|
],
|
|
)
|
|
def test_output_schema_validation_error_follows_model_data_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
model_redacted: bool,
|
|
tool_redacted: bool,
|
|
expected_redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
|
|
error = exc_info.value
|
|
if expected_redacted:
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
else:
|
|
assert _MODEL_OUTPUT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
assert any(
|
|
frame_locals.get("json_str") == payload
|
|
for frame_locals in _agents_traceback_frame_locals(error)
|
|
)
|
|
|
|
|
|
def test_output_schema_redaction_survives_trace_attachment_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
with patch(
|
|
"agents.util._json.attach_error_to_current_span",
|
|
side_effect=RuntimeError("trace attachment failed"),
|
|
):
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
|
|
error = exc_info.value
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
|
|
|
|
def test_output_schema_redaction_omits_sensitive_schema_metadata(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
output_type = cast(type[Any], _SensitiveOutput)
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
AgentOutputSchema(output_type).validate_json('"invalid"')
|
|
|
|
error = exc_info.value
|
|
assert _SENSITIVE_OUTPUT_SCHEMA_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_SENSITIVE_OUTPUT_SCHEMA_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_redacted", "tool_redacted", "expected_redacted"),
|
|
[
|
|
(True, False, True),
|
|
(False, True, True),
|
|
(True, True, True),
|
|
(False, False, False),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_handoff_input_validation_error_follows_mixed_data_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
model_redacted: bool,
|
|
tool_redacted: bool,
|
|
expected_redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
target = Agent(name="target")
|
|
handoff_calls = 0
|
|
|
|
async def on_handoff(_ctx: RunContextWrapper[Any], _input: _RequiredOutput) -> None:
|
|
nonlocal handoff_calls
|
|
handoff_calls += 1
|
|
|
|
handoff_obj = handoff(target, input_type=_RequiredOutput, on_handoff=on_handoff)
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), payload)
|
|
|
|
assert handoff_calls == 0
|
|
error = exc_info.value
|
|
if expected_redacted:
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
else:
|
|
assert _MODEL_OUTPUT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
assert any(
|
|
frame_locals.get("input_json") == payload
|
|
for frame_locals in _agents_traceback_frame_locals(error)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handoff_redaction_omits_sensitive_schema_metadata(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
target = Agent(name="target")
|
|
handoff_calls = 0
|
|
|
|
async def on_handoff(_ctx: RunContextWrapper[Any], _input: Any) -> None:
|
|
nonlocal handoff_calls
|
|
handoff_calls += 1
|
|
|
|
handoff_obj = handoff(
|
|
target,
|
|
input_type=cast(type[Any], _SensitiveHandoffInput),
|
|
on_handoff=on_handoff,
|
|
)
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), '"invalid"')
|
|
|
|
error = exc_info.value
|
|
assert handoff_calls == 0
|
|
assert _SENSITIVE_SCHEMA_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_SENSITIVE_SCHEMA_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
_assert_handoff_closure_absent_from_traceback(
|
|
error,
|
|
callback=on_handoff,
|
|
schema_secret=_SENSITIVE_SCHEMA_SECRET,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_redaction_omits_sensitive_schema_metadata(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", True)
|
|
target = RealtimeAgent(name="target")
|
|
handoff_calls = 0
|
|
|
|
async def on_handoff(_ctx: RunContextWrapper[Any], _input: Any) -> None:
|
|
nonlocal handoff_calls
|
|
handoff_calls += 1
|
|
|
|
handoff_obj = realtime_handoff(
|
|
target,
|
|
input_type=cast(type[Any], _SensitiveHandoffInput),
|
|
on_handoff=on_handoff,
|
|
)
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), '"invalid"')
|
|
|
|
error = exc_info.value
|
|
assert handoff_calls == 0
|
|
assert _SENSITIVE_SCHEMA_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_SENSITIVE_SCHEMA_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
_assert_handoff_closure_absent_from_traceback(
|
|
error,
|
|
callback=on_handoff,
|
|
schema_secret=_SENSITIVE_SCHEMA_SECRET,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model_redacted", "tool_redacted", "expected_redacted"),
|
|
[
|
|
(True, False, True),
|
|
(False, True, True),
|
|
(True, True, True),
|
|
(False, False, False),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_realtime_handoff_input_validation_error_follows_mixed_data_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
model_redacted: bool,
|
|
tool_redacted: bool,
|
|
expected_redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", model_redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", tool_redacted)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
target = RealtimeAgent(name="target")
|
|
handoff_calls = 0
|
|
|
|
async def on_handoff(_ctx: RunContextWrapper[Any], _input: _RequiredOutput) -> None:
|
|
nonlocal handoff_calls
|
|
handoff_calls += 1
|
|
|
|
handoff_obj = realtime_handoff(target, input_type=_RequiredOutput, on_handoff=on_handoff)
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await handoff_obj.on_invoke_handoff(RunContextWrapper(None), payload)
|
|
|
|
assert handoff_calls == 0
|
|
error = exc_info.value
|
|
if expected_redacted:
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
else:
|
|
assert _MODEL_OUTPUT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
assert any(
|
|
frame_locals.get("input_json") == payload
|
|
for frame_locals in _agents_traceback_frame_locals(error)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_surfaces_redacted_output_validation_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
model = ScriptedModel()
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')])
|
|
session = SimpleListSession(
|
|
session_id="redacted-run",
|
|
history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await Runner.run(agent, _MODEL_OUTPUT_SECRET, session=session)
|
|
|
|
error = exc_info.value
|
|
frame_locals = _agents_traceback_frame_locals(error)
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
)
|
|
assert all(session is not value for frame in frame_locals for value in frame.values())
|
|
|
|
|
|
def test_run_sync_surfaces_redacted_output_validation_error_without_runner_data(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
model = ScriptedModel()
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')])
|
|
session = SimpleListSession(
|
|
session_id="redacted-run-sync",
|
|
history=[{"role": "user", "content": _MODEL_OUTPUT_SECRET}],
|
|
)
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
Runner.run_sync(agent, _MODEL_OUTPUT_SECRET, session=session)
|
|
|
|
error = exc_info.value
|
|
frame_locals = _agents_traceback_frame_locals(error)
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
)
|
|
assert all(session is not value for frame in frame_locals for value in frame.values())
|
|
|
|
|
|
def test_run_sync_preserves_redacted_hybrid_cancellation_catchability(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
hybrid_error = _HybridCancelledError("RUN_SYNC_HYBRID_SECRET")
|
|
_mark_error_data_redacted(hybrid_error)
|
|
|
|
async def raise_hybrid_error(*_args: Any, **_kwargs: Any) -> Any:
|
|
raise hybrid_error
|
|
|
|
monkeypatch.setattr(AgentRunner, "run", raise_hybrid_error)
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
AgentRunner().run_sync(Agent(name="A"), "RUN_SYNC_INPUT_SECRET")
|
|
|
|
error = exc_info.value
|
|
assert isinstance(error, asyncio.CancelledError)
|
|
assert isinstance(error, Exception)
|
|
assert str(error) == ""
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, "RUN_SYNC_HYBRID_SECRET")
|
|
_assert_secret_absent_from_agents_traceback(error, "RUN_SYNC_INPUT_SECRET")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_preserves_diagnostic_wrapper_traceback_locals(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
diagnostic_input = "DIAGNOSTIC_RUNNER_INPUT_SECRET"
|
|
model = ScriptedModel(steps=[[get_text_message('{"answer": "missing count"}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
session = SimpleListSession(session_id="diagnostic-runner")
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await Runner.run(agent, diagnostic_input, session=session)
|
|
|
|
error = exc_info.value
|
|
frame_locals = _agents_traceback_frame_locals(error)
|
|
assert any(frame.get("input") == diagnostic_input for frame in frame_locals)
|
|
assert any(frame.get("session") is session for frame in frame_locals)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_redaction_boundary_defers_inner_coroutine_until_task_starts(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
start_streaming_called = False
|
|
|
|
async def inner_streaming() -> None:
|
|
return None
|
|
|
|
def start_streaming_factory(**_kwargs: Any) -> Any:
|
|
nonlocal start_streaming_called
|
|
start_streaming_called = True
|
|
return inner_streaming()
|
|
|
|
monkeypatch.setattr("agents.run.start_streaming", start_streaming_factory)
|
|
result = AgentRunner().run_streamed(Agent(name="A", model=ScriptedModel()), "go")
|
|
assert result.run_loop_task is not None
|
|
|
|
result.run_loop_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await result.run_loop_task
|
|
|
|
assert not start_streaming_called
|
|
|
|
|
|
def test_run_sync_preserves_diagnostic_wrapper_traceback_locals(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
diagnostic_input = "DIAGNOSTIC_RUNNER_SYNC_INPUT_SECRET"
|
|
model = ScriptedModel(steps=[[get_text_message('{"answer": "missing count"}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
session = SimpleListSession(session_id="diagnostic-runner-sync")
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
Runner.run_sync(agent, diagnostic_input, session=session)
|
|
|
|
error = exc_info.value
|
|
frame_locals = _agents_traceback_frame_locals(error)
|
|
assert any(frame.get("input") == diagnostic_input for frame in frame_locals)
|
|
assert any(frame.get("session") is session for frame in frame_locals)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_run_surfaces_redacted_output_validation_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
model = ScriptedModel()
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
model.enqueue([get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')])
|
|
result = Runner.run_streamed(agent, "go")
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
|
|
error = exc_info.value
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.parametrize("redacted", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_run_loop_exception_follows_model_data_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
result = Runner.run_streamed(agent, "go")
|
|
|
|
assert result.run_loop_task is not None
|
|
while not result.run_loop_task.done():
|
|
await asyncio.sleep(0)
|
|
|
|
error = result.run_loop_exception
|
|
assert isinstance(error, ModelBehaviorError)
|
|
if redacted:
|
|
assert error.run_data is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
else:
|
|
assert _MODEL_OUTPUT_SECRET in str(error)
|
|
assert isinstance(error.__cause__, ValidationError)
|
|
assert any(
|
|
_MODEL_OUTPUT_SECRET in repr(frame_locals)
|
|
for frame_locals in _agents_traceback_frame_locals(error)
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_output_guardrail_omits_run_data_from_redacted_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
model = ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]])
|
|
agent = Agent(
|
|
name="A",
|
|
model=model,
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
result = Runner.run_streamed(agent, "go")
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
|
|
error = exc_info.value
|
|
assert error.run_data is None
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.parametrize("redacted", [False, True])
|
|
@pytest.mark.parametrize("persistence_failure", ["error", "cancelled"])
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_session_error_after_output_guardrail_respects_redaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
redacted: bool,
|
|
persistence_failure: Literal["error", "cancelled"],
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
guardrail_failed = False
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
class FailingFinalTurnSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
cause = RuntimeError(f"session save cause: {_MODEL_OUTPUT_SECRET}")
|
|
if persistence_failure == "cancelled":
|
|
raise asyncio.CancelledError(
|
|
f"session save cancelled: {_MODEL_OUTPUT_SECRET}"
|
|
) from cause
|
|
raise LookupError(f"session save failed: {_MODEL_OUTPUT_SECRET}") from cause
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
caplog.set_level(logging.ERROR, logger="openai.agents")
|
|
model = ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]])
|
|
agent = Agent(
|
|
name="A",
|
|
model=model,
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
result = Runner.run_streamed(agent, "go", session=FailingFinalTurnSession())
|
|
run_loop_callback_errors: list[BaseException] = []
|
|
run_loop_done = asyncio.Event()
|
|
|
|
if redacted and persistence_failure == "cancelled":
|
|
assert result.run_loop_task is not None
|
|
|
|
def inspect_run_loop_task(task: asyncio.Task[Any]) -> None:
|
|
try:
|
|
task.result()
|
|
except BaseException as error:
|
|
run_loop_callback_errors.append(error)
|
|
finally:
|
|
run_loop_done.set()
|
|
|
|
result.run_loop_task.add_done_callback(inspect_run_loop_task)
|
|
expected_error_type = (
|
|
asyncio.CancelledError
|
|
if persistence_failure == "cancelled"
|
|
else UserError
|
|
if redacted
|
|
else LookupError
|
|
)
|
|
expected_message = (
|
|
"Error details are redacted."
|
|
if redacted
|
|
else "session save cancelled"
|
|
if persistence_failure == "cancelled"
|
|
else "session save failed"
|
|
)
|
|
|
|
with pytest.raises(expected_error_type, match=expected_message) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
|
|
error = exc_info.value
|
|
guardrail_error = error.__context__
|
|
|
|
if redacted:
|
|
assert guardrail_error is None
|
|
assert error.__cause__ is None
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
for record in caplog.records:
|
|
assert _MODEL_OUTPUT_SECRET not in repr(record.__dict__)
|
|
assert _MODEL_OUTPUT_SECRET not in logging.Formatter().format(record)
|
|
assert record.exc_info is None
|
|
else:
|
|
assert isinstance(guardrail_error, ModelBehaviorError)
|
|
assert error.__cause__ is not None
|
|
assert _MODEL_OUTPUT_SECRET in str(error.__cause__)
|
|
assert _MODEL_OUTPUT_SECRET in str(guardrail_error)
|
|
assert any(
|
|
_MODEL_OUTPUT_SECRET in repr(frame) for frame in _agents_traceback_frame_locals(error)
|
|
)
|
|
|
|
if persistence_failure == "cancelled":
|
|
assert result._stored_exception is error
|
|
assert result.run_loop_exception is None
|
|
if redacted:
|
|
await asyncio.wait_for(run_loop_done.wait(), timeout=1)
|
|
assert run_loop_callback_errors == []
|
|
else:
|
|
assert result.run_loop_exception is error
|
|
if redacted:
|
|
assert error.__traceback__ is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_session_hostile_error_after_redacted_output_guardrail_is_replaced(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
persistence_secret = "HOSTILE_SESSION_FAILURE_SECRET"
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
guardrail_failed = False
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
class FailingFinalTurnSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
raise _HostileAttributeWriteException(persistence_secret)
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
agent = Agent(
|
|
name="A",
|
|
model=ScriptedModel(steps=[[get_text_message(_MODEL_OUTPUT_SECRET)]]),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
result = Runner.run_streamed(agent, "go", session=FailingFinalTurnSession())
|
|
|
|
with pytest.raises(UserError, match="Error details are redacted.") as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
|
|
error = exc_info.value
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
assert persistence_secret not in str(error)
|
|
_assert_secret_absent_from_agents_traceback(error, persistence_secret)
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_blocked_terminal_tool_session_failure_is_data_redacted(
|
|
streamed: bool,
|
|
) -> None:
|
|
tool_output_secret = "BLOCKED_TERMINAL_TOOL_OUTPUT_SECRET"
|
|
persistence_secret = "BLOCKED_TERMINAL_SESSION_FAILURE_SECRET"
|
|
|
|
@function_tool(name_override="terminal_tool")
|
|
def terminal_tool() -> str:
|
|
return tool_output_secret
|
|
|
|
def reject_output(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
return GuardrailFunctionOutput(output_info=None, tripwire_triggered=True)
|
|
|
|
class FailingBlockedSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if any(
|
|
type(item) is dict and item.get("type") == "function_call_output" for item in items
|
|
):
|
|
error = LookupError(f"session save failed: {persistence_secret}")
|
|
error.run_data = items # type: ignore[attr-defined]
|
|
raise error
|
|
await super().add_items(items)
|
|
|
|
agent = Agent(
|
|
name="test",
|
|
model=ScriptedModel(
|
|
steps=[[get_function_tool_call("terminal_tool", "{}", call_id="terminal-call")]]
|
|
),
|
|
tools=[terminal_tool],
|
|
tool_use_behavior="stop_on_first_tool",
|
|
output_guardrails=[OutputGuardrail(guardrail_function=reject_output)],
|
|
)
|
|
session = FailingBlockedSession()
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(agent, "run terminal tool", session=session)
|
|
with pytest.raises(UserError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(UserError) as exc_info:
|
|
await Runner.run(agent, "run terminal tool", session=session)
|
|
|
|
error = exc_info.value
|
|
assert str(error) == "Error details are redacted."
|
|
assert error.run_data is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
for secret in (tool_output_secret, persistence_secret):
|
|
assert secret not in repr(error)
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
secret,
|
|
require_agents_frames=False,
|
|
)
|
|
|
|
|
|
def _persistence_failure(
|
|
kind: Literal["exception", "cancelled", "direct_base", "exception_group", "group"],
|
|
secret: str,
|
|
) -> BaseException:
|
|
if kind == "exception":
|
|
return LookupError(f"session save failed: {secret}")
|
|
if kind == "cancelled":
|
|
return asyncio.CancelledError(f"session save cancelled: {secret}")
|
|
if kind == "direct_base":
|
|
return _DirectBaseException(f"session save aborted: {secret}")
|
|
if kind == "exception_group":
|
|
return BaseExceptionGroup(
|
|
f"session save exception group: {secret}",
|
|
[RuntimeError(f"session save child: {secret}")],
|
|
)
|
|
return BaseExceptionGroup(
|
|
f"session save group: {secret}",
|
|
[
|
|
RuntimeError(f"session save child: {secret}"),
|
|
asyncio.CancelledError(f"session save cancelled child: {secret}"),
|
|
],
|
|
)
|
|
|
|
|
|
def _exception_graph(error: BaseException) -> list[BaseException]:
|
|
graph: list[BaseException] = []
|
|
pending = [error]
|
|
seen: set[int] = set()
|
|
while pending:
|
|
current = pending.pop()
|
|
if id(current) in seen:
|
|
continue
|
|
seen.add(id(current))
|
|
graph.append(current)
|
|
if isinstance(current, BaseExceptionGroup):
|
|
pending.extend(current.exceptions)
|
|
if current.__cause__ is not None:
|
|
pending.append(current.__cause__)
|
|
if current.__context__ is not None:
|
|
pending.append(current.__context__)
|
|
return graph
|
|
|
|
|
|
def _assert_secret_absent_from_value_graph(value: Any, secret: str) -> None:
|
|
pending = [value]
|
|
seen: set[int] = set()
|
|
while pending:
|
|
current = pending.pop()
|
|
if id(current) in seen:
|
|
continue
|
|
seen.add(id(current))
|
|
|
|
if isinstance(current, str):
|
|
assert secret not in current
|
|
elif isinstance(current, bytes):
|
|
assert secret.encode() not in current
|
|
elif isinstance(current, BaseExceptionGroup):
|
|
assert secret not in current.message
|
|
pending.extend(current.exceptions)
|
|
pending.extend(current.args)
|
|
elif isinstance(current, BaseException):
|
|
assert secret not in str(current)
|
|
assert secret not in repr(current)
|
|
pending.extend(current.args)
|
|
elif type(current) is dict:
|
|
pending.extend(current.keys())
|
|
pending.extend(current.values())
|
|
elif type(current) in {list, tuple, set, frozenset}:
|
|
pending.extend(current)
|
|
else:
|
|
assert secret not in repr(current)
|
|
|
|
|
|
@pytest.mark.parametrize("redacted", [False, True])
|
|
@pytest.mark.parametrize(
|
|
"failure_kind",
|
|
["exception", "cancelled", "direct_base", "exception_group", "base_group"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_sandbox_cleanup_wrapper_preserves_redaction_boundary(
|
|
redacted: bool,
|
|
failure_kind: Literal["exception", "cancelled", "direct_base", "exception_group", "base_group"],
|
|
) -> None:
|
|
secret = f"SANDBOX_WRAPPER_SECRET_{failure_kind}"
|
|
if failure_kind == "exception":
|
|
error: BaseException = RuntimeError()
|
|
elif failure_kind == "cancelled":
|
|
error = asyncio.CancelledError()
|
|
elif failure_kind == "direct_base":
|
|
error = _DirectBaseException()
|
|
elif failure_kind == "exception_group":
|
|
error = BaseExceptionGroup("Error details are redacted.", [RuntimeError()])
|
|
else:
|
|
error = BaseExceptionGroup(
|
|
"Error details are redacted.",
|
|
[asyncio.CancelledError()],
|
|
)
|
|
|
|
async def original_task() -> None:
|
|
payload = secret
|
|
assert payload
|
|
try:
|
|
raise error
|
|
except BaseException as caught:
|
|
if redacted:
|
|
_mark_error_data_redacted(caught)
|
|
_detach_data_redacted_error_traceback(caught)
|
|
payload = None
|
|
raise
|
|
|
|
async def cleanup() -> None:
|
|
return None
|
|
|
|
result = RunResultStreaming(
|
|
input=secret,
|
|
new_items=[],
|
|
raw_responses=[],
|
|
final_output=None,
|
|
input_guardrail_results=[],
|
|
output_guardrail_results=[],
|
|
tool_input_guardrail_results=[],
|
|
tool_output_guardrail_results=[],
|
|
context_wrapper=RunContextWrapper(context=None),
|
|
current_agent=Agent(name="test"),
|
|
current_turn=0,
|
|
max_turns=1,
|
|
_current_agent_output_schema=None,
|
|
trace=None,
|
|
)
|
|
result._sandbox_cleanup = cleanup
|
|
result.run_loop_task = asyncio.create_task(original_task())
|
|
result.ensure_sandbox_cleanup_on_completion()
|
|
assert result.run_loop_task is not None
|
|
|
|
callback_frame_locals: list[dict[str, Any]] = []
|
|
task_done = asyncio.Event()
|
|
|
|
def inspect_public_task(task: asyncio.Task[Any]) -> None:
|
|
try:
|
|
task.result()
|
|
except BaseException as caught:
|
|
callback_frame_locals.extend(_agents_traceback_frame_locals(caught))
|
|
finally:
|
|
task_done.set()
|
|
|
|
result.run_loop_task.add_done_callback(inspect_public_task)
|
|
await asyncio.wait_for(task_done.wait(), timeout=1)
|
|
|
|
if redacted:
|
|
for frame_locals in callback_frame_locals:
|
|
_assert_secret_absent_from_value_graph(frame_locals, secret)
|
|
else:
|
|
if failure_kind == "cancelled" and sys.version_info < (3, 11):
|
|
assert callback_frame_locals == []
|
|
else:
|
|
assert callback_frame_locals
|
|
assert any(secret in repr(frame) for frame in callback_frame_locals)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.parametrize("failure_kind", ["exception", "cancelled", "direct_base", "group"])
|
|
@pytest.mark.asyncio
|
|
async def test_max_turns_recovery_session_failure_preserves_complete_redaction_boundary(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
streamed: bool,
|
|
failure_kind: Literal["exception", "cancelled", "direct_base", "group"],
|
|
) -> None:
|
|
persistence_secret = "MAX_TURNS_SESSION_FAILURE_SECRET"
|
|
fallback_secret = "MAX_TURNS_HANDLER_OUTPUT_SECRET"
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
guardrail_failed = False
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
class FailingMaxTurnsSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
raise _persistence_failure(failure_kind, persistence_secret)
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
caplog.set_level(logging.ERROR, logger="openai.agents")
|
|
agent = Agent(
|
|
name="A",
|
|
model=ScriptedModel(),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
session = FailingMaxTurnsSession()
|
|
|
|
captured_error: BaseException | None = None
|
|
try:
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: fallback_secret},
|
|
)
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: fallback_secret},
|
|
)
|
|
except BaseException as error:
|
|
captured_error = error
|
|
else: # pragma: no cover
|
|
raise AssertionError("the session failure must propagate")
|
|
|
|
assert captured_error is not None
|
|
error = captured_error
|
|
if failure_kind == "exception":
|
|
assert isinstance(error, UserError)
|
|
elif failure_kind == "cancelled":
|
|
assert isinstance(error, asyncio.CancelledError)
|
|
elif failure_kind == "direct_base":
|
|
assert type(error) is BaseException
|
|
else:
|
|
assert isinstance(error, BaseExceptionGroup)
|
|
assert not isinstance(error, Exception)
|
|
assert {type(child) for child in error.exceptions} == {
|
|
UserError,
|
|
asyncio.CancelledError,
|
|
}
|
|
|
|
error_graph = _exception_graph(error)
|
|
assert error_graph
|
|
for current in error_graph:
|
|
assert current.__cause__ is None
|
|
assert current.__context__ is None
|
|
assert persistence_secret not in str(current)
|
|
assert persistence_secret not in repr(current)
|
|
assert fallback_secret not in str(current)
|
|
assert fallback_secret not in repr(current)
|
|
assert _MODEL_OUTPUT_SECRET not in str(current)
|
|
assert _MODEL_OUTPUT_SECRET not in repr(current)
|
|
_assert_secret_absent_from_agents_traceback(
|
|
current,
|
|
persistence_secret,
|
|
require_agents_frames=False,
|
|
)
|
|
_assert_secret_absent_from_agents_traceback(
|
|
current,
|
|
fallback_secret,
|
|
require_agents_frames=False,
|
|
)
|
|
_assert_secret_absent_from_agents_traceback(
|
|
current,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=False,
|
|
)
|
|
|
|
for record in caplog.records:
|
|
rendered_record = logging.Formatter().format(record)
|
|
record_state = repr(record.__dict__)
|
|
for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET):
|
|
assert secret not in rendered_record
|
|
assert secret not in record_state
|
|
assert record.exc_info is None
|
|
|
|
|
|
def _direct_agent_runner_redaction_case(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"],
|
|
) -> tuple[Agent[Any], SimpleListSession, str, tuple[str, str, str, str]]:
|
|
persistence_secret = f"DIRECT_AGENT_RUNNER_PERSISTENCE_SECRET_{failure_kind}"
|
|
fallback_secret = f"DIRECT_AGENT_RUNNER_FALLBACK_SECRET_{failure_kind}"
|
|
input_secret = f"DIRECT_AGENT_RUNNER_INPUT_SECRET_{failure_kind}"
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
guardrail_failed = False
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
class FailingSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
raise _persistence_failure(failure_kind, persistence_secret)
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
agent = Agent(
|
|
name="A",
|
|
model=ScriptedModel(),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
secrets = (persistence_secret, fallback_secret, input_secret, _MODEL_OUTPUT_SECRET)
|
|
return agent, FailingSession(), input_secret, secrets
|
|
|
|
|
|
def _assert_direct_agent_runner_redaction_boundary(
|
|
error: BaseException,
|
|
secrets: tuple[str, str, str, str],
|
|
) -> None:
|
|
for current in _exception_graph(error):
|
|
assert current.__cause__ is None
|
|
assert current.__context__ is None
|
|
for secret in secrets:
|
|
assert secret not in str(current)
|
|
assert secret not in repr(current)
|
|
for frame_locals in _agents_traceback_frame_locals(current):
|
|
_assert_secret_absent_from_value_graph(frame_locals, secret)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure_kind",
|
|
["cancelled", "direct_base", "exception_group", "group"],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_agent_runner_run_detaches_all_marked_recovery_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"],
|
|
) -> None:
|
|
agent, session, input_secret, secrets = _direct_agent_runner_redaction_case(
|
|
monkeypatch, failure_kind
|
|
)
|
|
|
|
with pytest.raises(BaseException) as exc_info:
|
|
await AgentRunner().run(
|
|
agent,
|
|
input_secret,
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: secrets[1]},
|
|
)
|
|
|
|
_assert_direct_agent_runner_redaction_boundary(exc_info.value, secrets)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"failure_kind",
|
|
["cancelled", "direct_base", "exception_group", "group"],
|
|
)
|
|
def test_agent_runner_run_sync_detaches_all_marked_recovery_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
failure_kind: Literal["cancelled", "direct_base", "exception_group", "group"],
|
|
) -> None:
|
|
agent, session, input_secret, secrets = _direct_agent_runner_redaction_case(
|
|
monkeypatch, failure_kind
|
|
)
|
|
|
|
with pytest.raises(BaseException) as exc_info:
|
|
AgentRunner().run_sync(
|
|
agent,
|
|
input_secret,
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: secrets[1]},
|
|
)
|
|
|
|
_assert_direct_agent_runner_redaction_boundary(exc_info.value, secrets)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_max_turns_recovery_deep_exception_group_preserves_redaction_boundary(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
streamed: bool,
|
|
) -> None:
|
|
persistence_secret = "DEEP_MAX_TURNS_SESSION_FAILURE_SECRET"
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
guardrail_failed = False
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
class DeepGroupSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
error: BaseException = asyncio.CancelledError(persistence_secret)
|
|
for _ in range(1200):
|
|
error = BaseExceptionGroup(persistence_secret, [error])
|
|
raise error
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
caplog.set_level(logging.ERROR, logger="openai.agents")
|
|
agent = Agent(
|
|
name="A",
|
|
model=ScriptedModel(),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
session = DeepGroupSession()
|
|
|
|
captured_error: BaseException | None = None
|
|
try:
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: "fallback"},
|
|
)
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: "fallback"},
|
|
)
|
|
except BaseException as error:
|
|
captured_error = error
|
|
else: # pragma: no cover
|
|
raise AssertionError("the deep exception group must propagate")
|
|
|
|
assert isinstance(captured_error, BaseExceptionGroup)
|
|
error_graph = _exception_graph(captured_error)
|
|
assert len(error_graph) == 1201
|
|
for current in error_graph:
|
|
assert current.__cause__ is None
|
|
assert current.__context__ is None
|
|
if isinstance(current, BaseExceptionGroup):
|
|
assert current.message == "Error details are redacted."
|
|
else:
|
|
assert isinstance(current, asyncio.CancelledError)
|
|
assert persistence_secret not in str(current)
|
|
assert persistence_secret not in repr(current)
|
|
for frame_locals in _agents_traceback_frame_locals(current):
|
|
_assert_secret_absent_from_value_graph(frame_locals, persistence_secret)
|
|
_assert_secret_absent_from_value_graph(frame_locals, _MODEL_OUTPUT_SECRET)
|
|
|
|
for record in caplog.records:
|
|
assert persistence_secret not in logging.Formatter().format(record)
|
|
assert persistence_secret not in repr(record.__dict__)
|
|
assert _MODEL_OUTPUT_SECRET not in logging.Formatter().format(record)
|
|
assert _MODEL_OUTPUT_SECRET not in repr(record.__dict__)
|
|
assert record.exc_info is None
|
|
|
|
|
|
@pytest.mark.parametrize("redacted", [False, True])
|
|
@pytest.mark.parametrize("failure_kind", ["direct_base", "group"])
|
|
@pytest.mark.asyncio
|
|
async def test_max_turns_run_loop_exception_follows_redaction_policy_for_base_exceptions(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
redacted: bool,
|
|
failure_kind: Literal["direct_base", "group"],
|
|
) -> None:
|
|
persistence_secret = "RUN_LOOP_EXCEPTION_PERSISTENCE_SECRET"
|
|
fallback_secret = "RUN_LOOP_EXCEPTION_FALLBACK_SECRET"
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
guardrail_failed = False
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
|
|
class FailingMaxTurnsSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
raise _persistence_failure(failure_kind, persistence_secret)
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
result = Runner.run_streamed(
|
|
Agent(
|
|
name="A",
|
|
model=ScriptedModel(),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
),
|
|
"go",
|
|
max_turns=0,
|
|
session=FailingMaxTurnsSession(),
|
|
error_handlers={"max_turns": lambda data: fallback_secret},
|
|
)
|
|
assert result.run_loop_task is not None
|
|
callback_frame_locals: list[dict[str, Any]] = []
|
|
run_loop_done = asyncio.Event()
|
|
|
|
def inspect_run_loop_task(task: asyncio.Task[Any]) -> None:
|
|
try:
|
|
task.result()
|
|
except BaseException as error:
|
|
callback_frame_locals.extend(_agents_traceback_frame_locals(error))
|
|
finally:
|
|
run_loop_done.set()
|
|
|
|
result.run_loop_task.add_done_callback(inspect_run_loop_task)
|
|
await asyncio.wait_for(run_loop_done.wait(), timeout=1)
|
|
|
|
error = result.run_loop_exception
|
|
assert error is not None
|
|
frame_locals = _agents_traceback_frame_locals(error)
|
|
if redacted:
|
|
for traceback_locals in callback_frame_locals + frame_locals:
|
|
for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET):
|
|
_assert_secret_absent_from_value_graph(traceback_locals, secret)
|
|
for current in _exception_graph(error):
|
|
assert current.__cause__ is None
|
|
assert current.__context__ is None
|
|
if isinstance(current, BaseExceptionGroup):
|
|
assert current.message == "Error details are redacted."
|
|
else:
|
|
for secret in (persistence_secret, fallback_secret, _MODEL_OUTPUT_SECRET):
|
|
assert secret not in str(current)
|
|
assert secret not in repr(current)
|
|
else:
|
|
assert callback_frame_locals
|
|
assert any(fallback_secret in repr(frame) for frame in callback_frame_locals)
|
|
assert frame_locals
|
|
assert any(fallback_secret in repr(frame) for frame in frame_locals)
|
|
assert persistence_secret in str(error)
|
|
|
|
try:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
except BaseException as streamed_error:
|
|
assert streamed_error is error
|
|
else: # pragma: no cover
|
|
raise AssertionError("the session failure must propagate through the stream")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("source", "expected_type"),
|
|
[
|
|
(asyncio.CancelledError("secret"), asyncio.CancelledError),
|
|
(GeneratorExit("secret"), GeneratorExit),
|
|
(KeyboardInterrupt("secret"), KeyboardInterrupt),
|
|
(SystemExit("secret"), SystemExit),
|
|
(_DirectBaseException("secret"), BaseException),
|
|
],
|
|
)
|
|
def test_safe_redacted_persistence_error_preserves_process_control_semantics(
|
|
source: BaseException,
|
|
expected_type: type[BaseException],
|
|
) -> None:
|
|
safe_error = _safe_redacted_persistence_error(source)
|
|
|
|
assert type(safe_error) is expected_type
|
|
assert "secret" not in str(safe_error)
|
|
assert "secret" not in repr(safe_error)
|
|
assert safe_error.__cause__ is None
|
|
assert safe_error.__context__ is None
|
|
assert safe_error.__traceback__ is None
|
|
|
|
|
|
def test_safe_redacted_persistence_error_snapshots_linked_group_topology() -> None:
|
|
linked_group = BaseExceptionGroup(
|
|
"linked group secret",
|
|
[KeyboardInterrupt("process-control secret")],
|
|
)
|
|
first_child = RuntimeError("first child secret")
|
|
first_child.__context__ = linked_group
|
|
source = BaseExceptionGroup("root group secret", [first_child, linked_group])
|
|
|
|
safe_error = _safe_redacted_persistence_error(source)
|
|
|
|
assert isinstance(safe_error, BaseExceptionGroup)
|
|
assert len(safe_error.exceptions) == 2
|
|
assert isinstance(safe_error.exceptions[0], UserError)
|
|
safe_linked_group = safe_error.exceptions[1]
|
|
assert isinstance(safe_linked_group, BaseExceptionGroup)
|
|
assert len(safe_linked_group.exceptions) == 1
|
|
assert type(safe_linked_group.exceptions[0]) is KeyboardInterrupt
|
|
assert "secret" not in repr(safe_error)
|
|
|
|
|
|
def test_safe_redacted_persistence_error_preserves_provider_group_catch_category() -> None:
|
|
class ProviderBaseExceptionGroup(BaseExceptionGroup):
|
|
pass
|
|
|
|
direct_source = ProviderBaseExceptionGroup(
|
|
"direct provider secret",
|
|
[RuntimeError("direct leaf secret")],
|
|
)
|
|
nested_source = BaseExceptionGroup(
|
|
"root group secret",
|
|
[
|
|
KeyboardInterrupt("process-control secret"),
|
|
ProviderBaseExceptionGroup(
|
|
"nested provider secret",
|
|
[RuntimeError("nested leaf secret")],
|
|
),
|
|
],
|
|
)
|
|
|
|
direct = _safe_redacted_persistence_error(direct_source)
|
|
nested = _safe_redacted_persistence_error(nested_source)
|
|
|
|
assert isinstance(direct, BaseExceptionGroup)
|
|
assert not isinstance(direct, Exception)
|
|
assert len(direct.exceptions) == 1
|
|
assert isinstance(direct.exceptions[0], UserError)
|
|
assert isinstance(nested, BaseExceptionGroup)
|
|
assert not isinstance(nested, Exception)
|
|
assert len(nested.exceptions) == 2
|
|
nested_provider = nested.exceptions[1]
|
|
assert isinstance(nested_provider, BaseExceptionGroup)
|
|
assert not isinstance(nested_provider, Exception)
|
|
assert len(nested_provider.exceptions) == 1
|
|
assert isinstance(nested_provider.exceptions[0], UserError)
|
|
assert "secret" not in repr(direct)
|
|
assert "secret" not in repr(nested)
|
|
|
|
|
|
def test_safe_redacted_persistence_error_snapshots_linked_system_exit_code() -> None:
|
|
system_exit = SystemExit(7)
|
|
first_child = RuntimeError("first child secret")
|
|
first_child.__context__ = system_exit
|
|
source = BaseExceptionGroup("root group secret", [first_child, system_exit])
|
|
|
|
safe_error = _safe_redacted_persistence_error(source)
|
|
|
|
assert isinstance(safe_error, BaseExceptionGroup)
|
|
assert isinstance(safe_error.exceptions[0], UserError)
|
|
safe_system_exit = safe_error.exceptions[1]
|
|
assert type(safe_system_exit) is SystemExit
|
|
assert safe_system_exit.code == 7
|
|
assert safe_system_exit.args == (7,)
|
|
assert "secret" not in repr(safe_error)
|
|
|
|
|
|
def test_safe_redacted_persistence_error_avoids_hostile_class_descriptor() -> None:
|
|
source = _HostileClassBaseException("persistence secret")
|
|
|
|
safe_error = _safe_redacted_persistence_error(source)
|
|
|
|
assert type(safe_error) is BaseException
|
|
assert safe_error.__cause__ is None
|
|
assert safe_error.__context__ is None
|
|
assert "secret" not in str(safe_error)
|
|
assert "secret" not in repr(safe_error)
|
|
|
|
|
|
def test_safe_redacted_persistence_error_preserves_hybrid_cancellation() -> None:
|
|
direct_source = _HybridCancelledError("direct secret")
|
|
grouped_source = BaseExceptionGroup("group secret", [_HybridCancelledError("child secret")])
|
|
direct = _safe_redacted_persistence_error(direct_source)
|
|
grouped = _safe_redacted_persistence_error(grouped_source)
|
|
|
|
assert isinstance(direct, asyncio.CancelledError)
|
|
assert isinstance(direct, Exception) is isinstance(direct_source, Exception)
|
|
assert str(direct) == "Error details are redacted."
|
|
assert isinstance(grouped, BaseExceptionGroup)
|
|
assert isinstance(grouped, Exception) is isinstance(grouped_source, Exception)
|
|
assert len(grouped.exceptions) == 1
|
|
assert isinstance(grouped.exceptions[0], asyncio.CancelledError)
|
|
assert isinstance(grouped.exceptions[0], Exception)
|
|
assert "secret" not in repr(grouped)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_max_turns_recovery_session_failure_preserves_diagnostic_context(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
persistence_secret = "DIAGNOSTIC_MAX_TURNS_SESSION_SECRET"
|
|
guardrail_secret = "DIAGNOSTIC_MAX_TURNS_GUARDRAIL_SECRET"
|
|
guardrail_failed = False
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
|
|
class FailingMaxTurnsSession(SimpleListSession):
|
|
async def add_items(self, items: list[Any]) -> None:
|
|
if guardrail_failed:
|
|
raise LookupError(persistence_secret)
|
|
await super().add_items(items)
|
|
|
|
def output_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_agent_output: Any,
|
|
) -> GuardrailFunctionOutput:
|
|
nonlocal guardrail_failed
|
|
guardrail_failed = True
|
|
raise RuntimeError(guardrail_secret)
|
|
|
|
agent = Agent(
|
|
name="A",
|
|
model=ScriptedModel(),
|
|
output_guardrails=[OutputGuardrail(guardrail_function=output_guardrail)],
|
|
)
|
|
session = FailingMaxTurnsSession()
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: "fallback"},
|
|
)
|
|
with pytest.raises(LookupError, match=persistence_secret) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(LookupError, match=persistence_secret) as exc_info:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
max_turns=0,
|
|
session=session,
|
|
error_handlers={"max_turns": lambda data: "fallback"},
|
|
)
|
|
|
|
guardrail_error = exc_info.value.__context__
|
|
assert isinstance(guardrail_error, RuntimeError)
|
|
assert guardrail_secret in str(guardrail_error)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamed_input_guardrail_omits_run_data_from_redacted_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", False)
|
|
payload = f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}'
|
|
|
|
def input_guardrail(
|
|
_context: RunContextWrapper[Any],
|
|
_agent: Agent[Any],
|
|
_input: str | list[Any],
|
|
) -> GuardrailFunctionOutput:
|
|
AgentOutputSchema(_RequiredOutput).validate_json(payload)
|
|
raise AssertionError("validation should fail") # pragma: no cover
|
|
|
|
model = ScriptedModel(steps=[[get_text_message("unused")]])
|
|
agent = Agent(
|
|
name="A",
|
|
model=model,
|
|
input_guardrails=[InputGuardrail(guardrail_function=input_guardrail)],
|
|
)
|
|
result = Runner.run_streamed(agent, _MODEL_OUTPUT_SECRET)
|
|
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
|
|
error = exc_info.value
|
|
assert error.run_data is None
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_receives_detached_redacted_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
retained_errors: list[ModelBehaviorError] = []
|
|
|
|
def recover(data: RunErrorHandlerInput[None]) -> _RequiredOutput:
|
|
assert isinstance(data.error, ModelBehaviorError)
|
|
retained_errors.append(data.error)
|
|
return _RequiredOutput(answer="safe", count=1)
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": recover},
|
|
)
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": recover},
|
|
)
|
|
|
|
assert len(retained_errors) == 1
|
|
error = retained_errors[0]
|
|
assert error.__traceback__ is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_invalid_fallback_preserves_redaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
fallback_secret = "INVALID_HANDLER_FALLBACK_SECRET"
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
|
|
def invalid_fallback(_data: RunErrorHandlerInput[None]) -> dict[str, str]:
|
|
return {"answer": fallback_secret}
|
|
|
|
with warnings.catch_warnings(record=True) as caught_warnings:
|
|
warnings.simplefilter("always")
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": invalid_fallback},
|
|
)
|
|
with pytest.raises(UserError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(UserError) as exc_info:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": invalid_fallback},
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert not caught_warnings
|
|
assert str(error) == "Error details are redacted."
|
|
assert error.run_data is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
_assert_secret_absent_from_agents_traceback(error, fallback_secret)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.parametrize("redacted", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_fallback_serialization_follows_redaction_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
redacted: bool,
|
|
) -> None:
|
|
fallback_secret = "PERMISSIVE_HANDLER_FALLBACK_SECRET"
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
model = ScriptedModel(
|
|
steps=[[get_text_message(f'{{"payload": "{_MODEL_OUTPUT_SECRET}", "count": "invalid"}}')]]
|
|
)
|
|
agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput)
|
|
|
|
def permissive_fallback(_data: RunErrorHandlerInput[None]) -> _PermissiveFallbackOutput:
|
|
return _PermissiveFallbackOutput(
|
|
payload=cast(Any, {"secret": fallback_secret}),
|
|
count=1,
|
|
)
|
|
|
|
with warnings.catch_warnings(record=True) as caught_warnings:
|
|
warnings.simplefilter("always")
|
|
if streamed:
|
|
streaming_result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": permissive_fallback},
|
|
)
|
|
async for _ in streaming_result.stream_events():
|
|
pass
|
|
actual_final_output = streaming_result.final_output
|
|
else:
|
|
run_result = await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": permissive_fallback},
|
|
)
|
|
actual_final_output = run_result.final_output
|
|
|
|
rendered_warnings = "\n".join(str(warning.message) for warning in caught_warnings)
|
|
if redacted:
|
|
assert not caught_warnings
|
|
else:
|
|
assert fallback_secret in rendered_warnings
|
|
assert actual_final_output == _PermissiveFallbackOutput(
|
|
payload=cast(Any, {"secret": fallback_secret}),
|
|
count=1,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("streamed", "include_in_history", "redacted"),
|
|
[
|
|
(False, True, True),
|
|
(False, False, True),
|
|
(True, True, True),
|
|
(True, False, True),
|
|
(False, True, False),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_empty_final_output_handler_fallback_serialization_follows_redaction_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
include_in_history: bool,
|
|
redacted: bool,
|
|
) -> None:
|
|
fallback_secret = "EMPTY_HANDLER_FALLBACK_SECRET"
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
model = ScriptedModel(steps=[[]])
|
|
agent = Agent(name="A", model=model, output_type=_PermissiveFallbackOutput)
|
|
|
|
def permissive_fallback(_data: RunErrorHandlerInput[None]) -> RunErrorHandlerResult:
|
|
return RunErrorHandlerResult(
|
|
final_output=_PermissiveFallbackOutput(
|
|
payload=cast(Any, {"secret": fallback_secret}),
|
|
count=1,
|
|
),
|
|
include_in_history=include_in_history,
|
|
)
|
|
|
|
with warnings.catch_warnings(record=True) as caught_warnings:
|
|
warnings.simplefilter("always")
|
|
if streamed:
|
|
streaming_result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": permissive_fallback},
|
|
)
|
|
async for _ in streaming_result.stream_events():
|
|
pass
|
|
actual_final_output = streaming_result.final_output
|
|
else:
|
|
run_result = await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": permissive_fallback},
|
|
)
|
|
actual_final_output = run_result.final_output
|
|
|
|
rendered_warnings = "\n".join(str(warning.message) for warning in caught_warnings)
|
|
if redacted:
|
|
assert not caught_warnings
|
|
else:
|
|
assert fallback_secret in rendered_warnings
|
|
assert actual_final_output == _PermissiveFallbackOutput(
|
|
payload=cast(Any, {"secret": fallback_secret}),
|
|
count=1,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_failure_preserves_redaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
|
|
def fail(data: RunErrorHandlerInput[None]) -> None:
|
|
raise RuntimeError(repr(data.run_data.raw_responses))
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
with pytest.raises(UserError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(UserError) as exc_info:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert str(error) == "Error details are redacted."
|
|
assert error.run_data is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_hostile_failure_preserves_redaction(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
handler_secret = "HOSTILE_HANDLER_FAILURE_SECRET"
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", True)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
|
|
def fail(_data: RunErrorHandlerInput[None]) -> None:
|
|
raise _HostileAttributeWriteException(handler_secret)
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
with pytest.raises(UserError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(UserError) as exc_info:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
|
|
error = exc_info.value
|
|
assert str(error) == "Error details are redacted."
|
|
assert error.run_data is None
|
|
assert error.__cause__ is None
|
|
assert error.__context__ is None
|
|
_assert_secret_absent_from_agents_traceback(error, _MODEL_OUTPUT_SECRET)
|
|
_assert_secret_absent_from_agents_traceback(error, handler_secret)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_final_output_handler_failure_preserves_diagnostic_context(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", False)
|
|
model = ScriptedModel(steps=[[get_text_message(f'{{"answer": "{_MODEL_OUTPUT_SECRET}"}}')]])
|
|
agent = Agent(name="A", model=model, output_type=_RequiredOutput)
|
|
|
|
def fail(_data: RunErrorHandlerInput[None]) -> None:
|
|
raise RuntimeError("handler failed")
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
with pytest.raises(RuntimeError, match="handler failed") as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(RuntimeError, match="handler failed") as exc_info:
|
|
await Runner.run(
|
|
agent,
|
|
"go",
|
|
error_handlers={"invalid_final_output": fail},
|
|
)
|
|
|
|
validation_error = exc_info.value.__context__
|
|
assert isinstance(validation_error, ModelBehaviorError)
|
|
assert _MODEL_OUTPUT_SECRET in str(validation_error)
|
|
assert isinstance(validation_error.__cause__, ValidationError)
|
|
|
|
|
|
@pytest.mark.parametrize("streamed", [False, True])
|
|
@pytest.mark.parametrize("redacted", [False, True])
|
|
@pytest.mark.asyncio
|
|
async def test_multiturn_output_validation_error_run_data_follows_redaction_policy(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
streamed: bool,
|
|
redacted: bool,
|
|
) -> None:
|
|
monkeypatch.setattr(_debug, "DONT_LOG_MODEL_DATA", redacted)
|
|
monkeypatch.setattr(_debug, "DONT_LOG_TOOL_DATA", redacted)
|
|
|
|
@function_tool
|
|
def record_value(value: str) -> str:
|
|
return "recorded"
|
|
|
|
model = ScriptedModel()
|
|
model.extend(
|
|
[
|
|
[
|
|
get_function_tool_call(
|
|
record_value.name,
|
|
json.dumps({"value": _MODEL_OUTPUT_SECRET}),
|
|
)
|
|
],
|
|
[get_text_message('{"answer": "missing count"}')],
|
|
]
|
|
)
|
|
agent = Agent(
|
|
name="A",
|
|
model=model,
|
|
tools=[record_value],
|
|
output_type=_RequiredOutput,
|
|
)
|
|
|
|
if streamed:
|
|
result = Runner.run_streamed(agent, "go")
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
async for _ in result.stream_events():
|
|
pass
|
|
else:
|
|
with pytest.raises(ModelBehaviorError) as exc_info:
|
|
await Runner.run(agent, "go")
|
|
|
|
error = exc_info.value
|
|
if redacted:
|
|
assert error.run_data is None
|
|
assert _MODEL_OUTPUT_SECRET not in str(error)
|
|
_assert_secret_absent_from_agents_traceback(
|
|
error,
|
|
_MODEL_OUTPUT_SECRET,
|
|
require_agents_frames=streamed,
|
|
)
|
|
else:
|
|
assert error.run_data is not None
|
|
assert error.run_data.raw_responses
|
|
assert error.run_data.new_items
|