Skip to content

Commit e76d591

Browse files
elainegan-openaicodex
authored andcommitted
Preserve caller cancellation in agent tool streaming
Only recover streamed nested-agent text once the nested run is already locally terminal, and add a regression that caller cancellation still re-raises even if raw responses already contain text. Co-authored-by: Codex <noreply@openai.com>
1 parent ccfaca2 commit e76d591

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

src/agents/agent.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ def _recover_streamed_agent_tool_text(run_result: Any) -> str | None:
149149
return "\n\n".join(recovered_chunks)
150150

151151

152+
def _can_recover_cancelled_streamed_agent_tool(run_result: Any) -> bool:
153+
"""Recover text only after the nested streamed run has already reached local terminal state."""
154+
if getattr(run_result, "is_complete", False):
155+
return True
156+
157+
run_loop_task = getattr(run_result, "run_loop_task", None)
158+
if isinstance(run_loop_task, asyncio.Task):
159+
return run_loop_task.done()
160+
161+
return False
162+
163+
152164
class AgentToolStreamEvent(TypedDict):
153165
"""Streaming event emitted when an agent is invoked as a tool."""
154166

@@ -837,6 +849,8 @@ async def dispatch_stream_events() -> None:
837849
}
838850
await event_queue.put(payload)
839851
except asyncio.CancelledError:
852+
if not _can_recover_cancelled_streamed_agent_tool(run_result_streaming):
853+
raise
840854
recovered_text = _recover_streamed_agent_tool_text(
841855
run_result_streaming
842856
)

tests/test_agent_as_tool.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import contextlib
45
import dataclasses
56
import json
67
from typing import Any, cast
@@ -1693,6 +1694,7 @@ def __init__(self) -> None:
16931694
response_id="resp_nested",
16941695
)
16951696
]
1697+
self.is_complete = True
16961698

16971699
async def stream_events(self):
16981700
yield stream_event
@@ -1753,6 +1755,89 @@ async def on_stream(payload: AgentToolStreamEvent) -> None:
17531755
assert received_events[0]["event"] == stream_event
17541756

17551757

1758+
@pytest.mark.asyncio
1759+
async def test_agent_as_tool_streaming_reraises_caller_cancellation_with_live_run(
1760+
monkeypatch: pytest.MonkeyPatch,
1761+
) -> None:
1762+
agent = Agent(name="streamer")
1763+
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
1764+
1765+
class DummyStreamingResult:
1766+
def __init__(self) -> None:
1767+
self.final_output = ""
1768+
self.current_agent = agent
1769+
self.new_items: list[Any] = []
1770+
self.raw_responses = [
1771+
ModelResponse(
1772+
output=[get_text_message("Recovered nested summary")],
1773+
usage=Usage(),
1774+
response_id="resp_nested",
1775+
)
1776+
]
1777+
self.is_complete = False
1778+
self.run_loop_task = asyncio.create_task(asyncio.sleep(60))
1779+
1780+
async def stream_events(self):
1781+
yield stream_event
1782+
raise asyncio.CancelledError("stream-cancelled")
1783+
1784+
streaming_result = DummyStreamingResult()
1785+
1786+
def fake_run_streamed(
1787+
cls,
1788+
starting_agent,
1789+
input,
1790+
*,
1791+
context,
1792+
max_turns,
1793+
hooks,
1794+
run_config,
1795+
previous_response_id,
1796+
auto_previous_response_id=False,
1797+
conversation_id,
1798+
session,
1799+
):
1800+
return streaming_result
1801+
1802+
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
1803+
raise AssertionError("Runner.run should not be called when on_stream is provided.")
1804+
1805+
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
1806+
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
1807+
1808+
async def on_stream(payload: AgentToolStreamEvent) -> None:
1809+
del payload
1810+
1811+
tool_call = ResponseFunctionToolCall(
1812+
id="call_cancelled_live",
1813+
arguments='{"input": "recover"}',
1814+
call_id="call-cancelled-live",
1815+
name="stream_tool",
1816+
type="function_call",
1817+
)
1818+
1819+
tool = agent.as_tool(
1820+
tool_name="stream_tool",
1821+
tool_description="Streams events",
1822+
on_stream=on_stream,
1823+
)
1824+
1825+
tool_context = ToolContext(
1826+
context=None,
1827+
tool_name="stream_tool",
1828+
tool_call_id=tool_call.call_id,
1829+
tool_arguments=tool_call.arguments,
1830+
tool_call=tool_call,
1831+
)
1832+
try:
1833+
with pytest.raises(asyncio.CancelledError, match="stream-cancelled"):
1834+
await tool.on_invoke_tool(tool_context, '{"input": "recover"}')
1835+
finally:
1836+
streaming_result.run_loop_task.cancel()
1837+
with contextlib.suppress(asyncio.CancelledError):
1838+
await streaming_result.run_loop_task
1839+
1840+
17561841
@pytest.mark.asyncio
17571842
async def test_agent_as_tool_streaming_extractor_can_access_agent_tool_invocation(
17581843
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)