Skip to content

Commit 61443ca

Browse files
authored
fix: #2929 surface run-loop exceptions after stream_events() completes (#2931)
1 parent 55c8900 commit 61443ca

File tree

2 files changed

+69
-0
lines changed

2 files changed

+69
-0
lines changed

src/agents/result.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,31 @@ async def _await_run_and_cleanup() -> Any:
619619

620620
self.run_loop_task = asyncio.create_task(_await_run_and_cleanup())
621621

622+
@property
623+
def run_loop_exception(self) -> BaseException | None:
624+
"""The exception raised by the background run loop, if any.
625+
626+
When the run loop fails before producing stream events (for example during early
627+
sandbox initialisation), the exception may not be re-raised through
628+
:meth:`stream_events`. This property gives callers a reliable way to check for
629+
silent failures after consuming the stream:
630+
631+
.. code-block:: python
632+
633+
result = Runner.run_streamed(agent, "hello")
634+
async for event in result.stream_events():
635+
pass
636+
if result.run_loop_exception:
637+
raise result.run_loop_exception
638+
639+
Returns ``None`` if the run loop completed without error, has not yet finished,
640+
or was cancelled.
641+
"""
642+
task = self.run_loop_task
643+
if task is None or not task.done() or task.cancelled():
644+
return None
645+
return task.exception()
646+
622647
def cancel(self, mode: Literal["immediate", "after_turn"] = "immediate") -> None:
623648
"""Cancel the streaming run.
624649
@@ -725,6 +750,11 @@ async def stream_events(self) -> AsyncIterator[StreamEvent]:
725750
# Ensure main execution completes before cleanup to avoid race conditions
726751
# with session operations.
727752
await self._await_task_safely(self.run_loop_task)
753+
# Re-check for exceptions now that the run loop has fully settled.
754+
# _await_task_safely swallows exceptions; without this call, a run-loop
755+
# failure that races past the sentinel (e.g. early sandbox failures) would
756+
# be silently lost instead of surfaced via _stored_exception.
757+
self._check_errors()
728758
# Safely terminate all background tasks after main execution has finished.
729759
self._cleanup_tasks()
730760

tests/test_cancel_streaming.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,42 @@ async def consume_events():
230230
assert len(events) <= 1
231231
assert not block_event.is_set()
232232
assert result.is_complete
233+
234+
235+
@pytest.mark.asyncio
236+
async def test_run_loop_exception_property_is_none_on_success():
237+
"""run_loop_exception is None when the stream completes without error."""
238+
model = FakeModel()
239+
model.set_next_output([get_text_message("hello")])
240+
agent = Agent(name="A", model=model)
241+
242+
result = Runner.run_streamed(agent, input="hi")
243+
async for _ in result.stream_events():
244+
pass
245+
246+
assert result.run_loop_exception is None
247+
248+
249+
@pytest.mark.asyncio
250+
async def test_run_loop_exception_surfaced_after_stream():
251+
"""run_loop_exception is set when the run loop raises before yielding events."""
252+
253+
class BoomModel(FakeModel):
254+
async def get_response(self, *args, **kwargs):
255+
raise RuntimeError("run loop boom")
256+
257+
async def stream_response(self, *args, **kwargs):
258+
raise RuntimeError("run loop boom")
259+
yield # make this an async generator
260+
261+
agent = Agent(name="A", model=BoomModel())
262+
263+
result = Runner.run_streamed(agent, input="hi")
264+
with pytest.raises(RuntimeError, match="run loop boom"):
265+
async for _ in result.stream_events():
266+
pass
267+
268+
# Property must also expose the exception for callers who want to inspect it directly.
269+
assert result.run_loop_exception is not None
270+
assert isinstance(result.run_loop_exception, RuntimeError)
271+
assert "run loop boom" in str(result.run_loop_exception)

0 commit comments

Comments
 (0)