Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions src/agents/agent_output.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import abc
from dataclasses import dataclass
from typing import Any, get_args, get_origin
from typing import Any, cast, get_args, get_origin

from pydantic import BaseModel, TypeAdapter
from typing_extensions import TypedDict

from .exceptions import ModelBehaviorError, UserError
from .exceptions import (
ModelBehaviorError,
UserError,
_detach_data_redacted_error_traceback,
_is_error_data_redacted,
_raise_data_redacted_error,
)
from .strict_schema import ensure_strict_json_schema
from .tracing import SpanError
from .util import _error_tracing, _json
Expand Down Expand Up @@ -137,12 +143,24 @@ def validate_json(self, json_str: str) -> Any:
"""Validate a JSON string against the output type. Returns the validated object, or raises
a `ModelBehaviorError` if the JSON is invalid.
"""
validated = _json.validate_json(
json_str,
self._type_adapter,
partial=False,
strict=True if self._strict_json_schema else None,
)
redacted_error: ModelBehaviorError | None = None
try:
validated = _json.validate_json(
json_str,
self._type_adapter,
partial=False,
strict=True if self._strict_json_schema else None,
)
except ModelBehaviorError as error:
if not _is_error_data_redacted(error):
raise
_detach_data_redacted_error_traceback(error)
redacted_error = error

if redacted_error is not None:
self = cast(Any, None)
json_str = "<redacted>"
_raise_data_redacted_error(redacted_error)
if self._is_wrapped:
if not isinstance(validated, dict):
_error_tracing.attach_error_to_current_span(
Expand Down
28 changes: 27 additions & 1 deletion src/agents/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

import traceback
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NoReturn

if TYPE_CHECKING:
from .agent import Agent
Expand All @@ -19,6 +20,8 @@
from .util._pretty_print import pretty_print_run_error_details

_DRAIN_STREAM_EVENTS_ATTR = "_agents_drain_queued_stream_events"
_DATA_REDACTED_ATTR = "_agents_data_redacted"
_DATA_REDACTED_ERROR_MESSAGE = "Error details are redacted."


def _mark_error_to_drain_stream_events(error: Exception) -> None:
Expand All @@ -29,6 +32,29 @@ def _should_drain_stream_events_before_raising(error: Exception) -> bool:
return bool(getattr(error, _DRAIN_STREAM_EVENTS_ATTR, False))


def _mark_error_data_redacted(error: Exception) -> None:
setattr(error, _DATA_REDACTED_ATTR, True)


def _is_error_data_redacted(error: Exception) -> bool:
return bool(getattr(error, _DATA_REDACTED_ATTR, False))


def _clear_data_redacted_error_traceback(error: Exception) -> None:
if _is_error_data_redacted(error) and error.__traceback__ is not None:
traceback.clear_frames(error.__traceback__)


def _detach_data_redacted_error_traceback(error: Exception) -> None:
if _is_error_data_redacted(error):
error.__traceback__ = None


def _raise_data_redacted_error(error: Exception) -> NoReturn:
"""Raise a detached redacted error from a frame that owns no payload data."""
raise error from None


@dataclass
class RunErrorDetails:
"""Data collected from an agent run when an exception occurs."""
Expand Down
35 changes: 32 additions & 3 deletions src/agents/handoffs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@
import weakref
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field, replace as dataclasses_replace
from functools import partial
from typing import TYPE_CHECKING, Any, Generic, TypeAlias, cast, overload

from pydantic import TypeAdapter
from typing_extensions import TypeVar

from ..exceptions import ModelBehaviorError, UserError
from ..exceptions import (
ModelBehaviorError,
UserError,
_detach_data_redacted_error_traceback,
_is_error_data_redacted,
_raise_data_redacted_error,
)
from ..items import RunItem, TResponseInputItem
from ..run_context import RunContextWrapper, TContext
from ..strict_schema import ensure_strict_json_schema
Expand Down Expand Up @@ -39,6 +46,27 @@
OnHandoffWithoutInput = Callable[[RunContextWrapper[Any]], Any]


async def _invoke_handoff_with_redaction(
invoke_handoff: Callable[[RunContextWrapper[Any], str | None], Awaitable[TAgent]],
ctx: RunContextWrapper[Any],
input_json: str | None = None,
) -> TAgent:
redacted_error: ModelBehaviorError | None = None
try:
return await invoke_handoff(ctx, input_json)
except ModelBehaviorError as error:
if not _is_error_data_redacted(error):
raise
_detach_data_redacted_error_traceback(error)
redacted_error = error

invoke_handoff = cast(Any, None)
ctx = cast(Any, None)
input_json = "<redacted>"
assert redacted_error is not None
_raise_data_redacted_error(redacted_error)


@dataclass(frozen=True)
class HandoffInputData:
input_history: str | tuple[TResponseInputItem, ...]
Expand Down Expand Up @@ -282,7 +310,7 @@ def handoff(
if len(sig.parameters) != 1:
raise UserError("on_handoff must take one argument: context")

async def _invoke_handoff(
async def _invoke_handoff_impl(
ctx: RunContextWrapper[Any], input_json: str | None = None
) -> Agent[TContext]:
if input_type is not None and type_adapter is not None:
Expand All @@ -300,6 +328,7 @@ async def _invoke_handoff(
type_adapter=type_adapter,
partial=False,
strict=True,
contains_tool_data=True,
)
input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff)
result = input_func(ctx, validated_input)
Expand Down Expand Up @@ -334,7 +363,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) -
tool_name=tool_name,
tool_description=tool_description,
input_json_schema=input_json_schema,
on_invoke_handoff=_invoke_handoff,
on_invoke_handoff=partial(_invoke_handoff_with_redaction, _invoke_handoff_impl),
input_filter=input_filter,
nest_handoff_history=nest_handoff_history,
agent_name=agent.name,
Expand Down
13 changes: 9 additions & 4 deletions src/agents/realtime/handoffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import inspect
from collections.abc import Callable, Iterable
from functools import partial
from typing import TYPE_CHECKING, Any, cast, overload

from pydantic import TypeAdapter
from typing_extensions import TypeVar

from ..exceptions import ModelBehaviorError, UserError
from ..handoffs import Handoff
from ..exceptions import (
ModelBehaviorError,
UserError,
)
from ..handoffs import Handoff, _invoke_handoff_with_redaction
from ..run_context import RunContextWrapper, TContext
from ..strict_schema import ensure_strict_json_schema
from ..tracing.spans import SpanError
Expand Down Expand Up @@ -146,7 +150,7 @@ def realtime_handoff(
if len(sig.parameters) != 1:
raise UserError("on_handoff must take one argument: context")

async def _invoke_handoff(
async def _invoke_handoff_impl(
ctx: RunContextWrapper[Any], input_json: str | None = None
) -> RealtimeAgent[TContext]:
if input_type is not None and type_adapter is not None:
Expand All @@ -164,6 +168,7 @@ async def _invoke_handoff(
type_adapter=type_adapter,
partial=False,
strict=True,
contains_tool_data=True,
)
input_func = cast(OnHandoffWithInput[THandoffInput], on_handoff)
result = input_func(ctx, validated_input)
Expand Down Expand Up @@ -196,7 +201,7 @@ async def _is_enabled(ctx: RunContextWrapper[Any], agent_base: AgentBase[Any]) -
tool_name=tool_name,
tool_description=tool_description,
input_json_schema=input_json_schema,
on_invoke_handoff=_invoke_handoff,
on_invoke_handoff=partial(_invoke_handoff_with_redaction, _invoke_handoff_impl),
input_filter=None, # Not supported for RealtimeAgent handoffs
agent_name=agent.name,
is_enabled=_is_enabled if callable(is_enabled) else is_enabled,
Expand Down
48 changes: 41 additions & 7 deletions src/agents/realtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@
get_function_tool_namespace,
)
from ..agent import Agent
from ..exceptions import ToolInputGuardrailTripwireTriggered, UserError
from ..exceptions import (
ModelBehaviorError,
ToolInputGuardrailTripwireTriggered,
UserError,
_clear_data_redacted_error_traceback,
_detach_data_redacted_error_traceback,
_is_error_data_redacted,
_raise_data_redacted_error,
)
from ..handoffs import Handoff
from ..items import ToolApprovalItem
from ..logger import (
Expand Down Expand Up @@ -304,7 +312,16 @@ async def __aiter__(self) -> AsyncIterator[RealtimeSessionEvent]:
if self._stored_exception is not None:
# Clean up resources before raising
await self.close()
raise self._stored_exception
stored_exception = self._stored_exception
if isinstance(stored_exception, Exception) and _is_error_data_redacted(
stored_exception
):
_detach_data_redacted_error_traceback(stored_exception)
# Do not retain the session or the previously yielded raw event in the
# traceback frame that exposes a redacted error to the caller.
self = cast(Any, None)
event = cast(Any, None)
raise stored_exception

self._event_iterator_waiters += 1
try:
Expand Down Expand Up @@ -398,7 +415,22 @@ async def on_event(self, event: RealtimeModelEvent) -> None:
handle_kwargs: dict[str, Any] = {"agent_snapshot": agent_snapshot}
if dispatch_snapshot is not None:
handle_kwargs["dispatch_snapshot"] = dispatch_snapshot
await self._handle_tool_call(event, **handle_kwargs)
redacted_error: ModelBehaviorError | None = None
try:
await self._handle_tool_call(event, **handle_kwargs)
except ModelBehaviorError as error:
if not _is_error_data_redacted(error):
raise
_detach_data_redacted_error_traceback(error)
redacted_error = error

if redacted_error is not None:
self = cast(Any, None)
event = cast(Any, None)
agent_snapshot = cast(Any, None)
dispatch_snapshot = cast(Any, None)
handle_kwargs = {}
_raise_data_redacted_error(redacted_error)
elif event.type == "audio":
if event.response_id not in self._interrupted_response_ids:
await self._put_event(
Expand Down Expand Up @@ -1625,14 +1657,16 @@ def _enqueue_tool_call_task(
def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
self._tool_call_tasks.discard(task)

if self._closing or self._closed:
self._consume_task_result(task)
return

if task.cancelled():
return

exception = task.exception()
if isinstance(exception, ModelBehaviorError):
_clear_data_redacted_error_traceback(exception)

if self._closing or self._closed:
return

if exception is None:
return

Expand Down
32 changes: 27 additions & 5 deletions src/agents/result.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
InputGuardrailTripwireTriggered,
MaxTurnsExceeded,
RunErrorDetails,
_detach_data_redacted_error_traceback,
_is_error_data_redacted,
_should_drain_stream_events_before_raising,
)
from .guardrail import InputGuardrailResult, OutputGuardrailResult
Expand Down Expand Up @@ -729,7 +731,10 @@ def run_loop_exception(self) -> BaseException | None:
task = self.run_loop_task
if task is None or not task.done() or task.cancelled():
return None
return task.exception()
error = task.exception()
if isinstance(error, Exception) and _is_error_data_redacted(error):
_detach_data_redacted_error_traceback(error)
return error

def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None:
"""Cancel the streaming run.
Expand Down Expand Up @@ -927,8 +932,16 @@ def register_current_consumer() -> None:
self._drain_event_queue()
self._drain_input_guardrail_queue()

if self._stored_exception:
raise self._stored_exception
stored_exception = self._stored_exception
if stored_exception:
if _is_error_data_redacted(stored_exception):
_detach_data_redacted_error_traceback(stored_exception)
# The streaming result retains caller-visible run data. Drop the local reference
# before raising so the redacted exception cannot retain it through this frame.
self = cast(Any, None)
registered_consumer_task = None
item = cast(Any, None)
raise stored_exception

def _create_error_details(self) -> RunErrorDetails | None:
"""Return a `RunErrorDetails` object considering the current attributes of the class.
Expand Down Expand Up @@ -974,15 +987,23 @@ def _check_errors(self):
if not self.run_loop_task.cancelled():
run_impl_exc = self.run_loop_task.exception()
if run_impl_exc and isinstance(run_impl_exc, Exception):
if isinstance(run_impl_exc, AgentsException) and run_impl_exc.run_data is None:
if (
isinstance(run_impl_exc, AgentsException)
and run_impl_exc.run_data is None
and not _is_error_data_redacted(run_impl_exc)
):
run_impl_exc.run_data = self._create_error_details()
self._stored_exception = run_impl_exc

if self._input_guardrails_task and self._input_guardrails_task.done():
if not self._input_guardrails_task.cancelled():
in_guard_exc = self._input_guardrails_task.exception()
if in_guard_exc and isinstance(in_guard_exc, Exception):
if isinstance(in_guard_exc, AgentsException) and in_guard_exc.run_data is None:
if (
isinstance(in_guard_exc, AgentsException)
and in_guard_exc.run_data is None
and not _is_error_data_redacted(in_guard_exc)
):
in_guard_exc.run_data = self._create_error_details()
self._stored_exception = in_guard_exc

Expand All @@ -993,6 +1014,7 @@ def _check_errors(self):
if (
isinstance(out_guard_exc, AgentsException)
and out_guard_exc.run_data is None
and not _is_error_data_redacted(out_guard_exc)
):
out_guard_exc.run_data = self._create_error_details()
self._stored_exception = out_guard_exc
Expand Down
Loading