Skip to content
Open
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
123 changes: 112 additions & 11 deletions src/agents/realtime/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
from ..logger import logger
from ..run_config import ToolErrorFormatterArgs
from ..run_context import RunContextWrapper, TContext
from ..tool import DEFAULT_APPROVAL_REJECTION_MESSAGE, FunctionTool, invoke_function_tool
from ..tool import (
DEFAULT_APPROVAL_REJECTION_MESSAGE,
FunctionTool,
default_tool_error_function,
invoke_function_tool,
maybe_invoke_function_tool_failure_error_function,
)
from ..tool_context import ToolContext
from ..util._approvals import evaluate_needs_approval_setting
from .agent import RealtimeAgent
Expand Down Expand Up @@ -114,9 +120,15 @@ class _PendingToolOutput:


class _PendingToolOutputSendError(RuntimeError):
def __init__(self, call_id: str, cause: BaseException) -> None:
def __init__(
self,
call_id: str,
cause: BaseException,
original_error: BaseException | None = None,
) -> None:
super().__init__(str(cause))
self.call_id = call_id
self.original_error = original_error


class RealtimeSession(RealtimeModelListener):
Expand Down Expand Up @@ -714,6 +726,62 @@ async def reject_tool_call(
finally:
self._finish_tool_call(call_id, mark_completed=mark_completed)

async def _send_function_tool_failure_output(
self,
event: RealtimeModelToolCallEvent,
*,
tool: FunctionTool,
tool_context: ToolContext[Any],
agent: RealtimeAgent,
error: Exception,
) -> bool:
output = await maybe_invoke_function_tool_failure_error_function(
function_tool=tool,
context=tool_context,
error=error,
)
if output is None:
return False

try:
await self._send_tool_output_completion(
_PendingToolOutput(
tool_call=event,
output=output,
start_response=True,
tool_end_event=RealtimeToolEnd(
info=self._event_info,
tool=tool,
output=output,
agent=agent,
arguments=event.arguments,
),
)
)
except _PendingToolOutputSendError as send_error:
send_error.original_error = error
raise
Comment on lines +761 to +763
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve original errors on synchronous send failures

When async_tool_calls is disabled (or approval calls await _handle_tool_call directly), a transient send_event failure while reporting a tool exception makes this helper re-raise _PendingToolOutputSendError; since _on_tool_call_task_done is not involved, original_error is never consumed and the app sees send failed instead of the original tool error. Fresh evidence beyond the existing async comments: original_error is only read in _on_tool_call_task_done, which the synchronous path bypasses.

Useful? React with 👍 / 👎.

return True

async def _send_handoff_failure_output(
self,
event: RealtimeModelToolCallEvent,
*,
tool_context: ToolContext[Any],
error: Exception,
) -> None:
try:
await self._send_tool_output_completion(
_PendingToolOutput(
tool_call=event,
output=default_tool_error_function(tool_context, error),
start_response=True,
)
)
except _PendingToolOutputSendError as send_error:
send_error.original_error = error
raise

async def _handle_tool_call(
self,
event: RealtimeModelToolCallEvent,
Expand Down Expand Up @@ -773,11 +841,22 @@ async def _handle_tool_call(
tool_arguments=event.arguments,
agent=agent,
)
result = await invoke_function_tool(
function_tool=func_tool,
context=tool_context,
arguments=event.arguments,
)
try:
result = await invoke_function_tool(
function_tool=func_tool,
context=tool_context,
arguments=event.arguments,
)
except Exception as exc:
if await self._send_function_tool_failure_output(
event,
tool=func_tool,
tool_context=tool_context,
agent=agent,
error=exc,
):
mark_completed = True
Comment on lines +851 to +858
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the original tool failure if output sending fails

When the model send for this synthesized failure output fails, _send_tool_output_completion raises _PendingToolOutputSendError from inside the except block, replacing the original tool exception. In the async tool-call path _on_tool_call_task_done treats that wrapper as a retryable send failure and returns without storing or surfacing the original tool error; when the same call id is retried it only flushes the cached output and the app never gets the expected RealtimeError/exception for the failed tool. This affects transient send_event failures while reporting a tool exception, so the new failure-output path does not always keep the existing local exception behavior.

Useful? React with 👍 / 👎.

raise

await self._send_tool_output_completion(
_PendingToolOutput(
Expand Down Expand Up @@ -806,11 +885,20 @@ async def _handle_tool_call(
)

# Execute the handoff to get the new agent
result = await handoff.on_invoke_handoff(self._context_wrapper, event.arguments)
if not isinstance(result, RealtimeAgent):
raise UserError(
f"Handoff {handoff.tool_name} returned invalid result: {type(result)}"
try:
result = await handoff.on_invoke_handoff(self._context_wrapper, event.arguments)
if not isinstance(result, RealtimeAgent):
raise UserError(
f"Handoff {handoff.tool_name} returned invalid result: {type(result)}"
)
except Exception as exc:
await self._send_handoff_failure_output(
event,
tool_context=tool_context,
error=exc,
)
Comment on lines +895 to 899
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve handoff failures when output sending fails

For handoff failures, if _send_tool_output_completion raises here (for example, a transient send_event failure while sending this synthesized error output), this await replaces the original handoff exception with _PendingToolOutputSendError; in the async task path _on_tool_call_task_done treats that wrapper as retryable and returns without storing or emitting the handoff failure, and a later duplicate call just flushes the cached output. That means applications can miss the handoff exception even though the model eventually receives the failure output.

Useful? React with 👍 / 👎.

mark_completed = True
raise

# Store previous agent for event
previous_agent = agent
Expand Down Expand Up @@ -1203,6 +1291,19 @@ def _on_tool_call_task_done(self, task: asyncio.Task[Any]) -> None:
)
)
)
original_error = exception.original_error
if original_error is not None:
logger.exception("Realtime tool call task failed", exc_info=original_error)
if self._stored_exception is None:
self._stored_exception = original_error
asyncio.create_task(
self._put_event(
RealtimeError(
info=self._event_info,
error={"message": f"Tool call task failed: {original_error}"},
)
)
)
return

logger.exception("Realtime tool call task failed", exc_info=exception)
Expand Down
Loading