Skip to content

Commit 05cc57c

Browse files
committed
fix review comment
1 parent aa822d3 commit 05cc57c

4 files changed

Lines changed: 235 additions & 8 deletions

File tree

src/agents/agent.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,6 @@ def _validate_codex_tool_name_collisions(tools: list[Tool]) -> None:
120120

121121

122122
def _recover_streamed_agent_tool_text(run_result: Any) -> str | None:
123-
final_output = getattr(run_result, "final_output", None)
124-
if isinstance(final_output, str) and final_output:
125-
return final_output
126-
127123
new_items = getattr(run_result, "new_items", None)
128124
if isinstance(new_items, list):
129125
text = ItemHelpers.text_message_outputs(new_items)
@@ -140,7 +136,7 @@ def _recover_streamed_agent_tool_text(run_result: Any) -> str | None:
140136
if not isinstance(outputs, list):
141137
continue
142138
for output_item in outputs:
143-
chunk = ItemHelpers.extract_last_text(output_item)
139+
chunk = ItemHelpers.extract_text(output_item)
144140
if chunk:
145141
recovered_chunks.append(chunk)
146142

@@ -149,6 +145,20 @@ def _recover_streamed_agent_tool_text(run_result: Any) -> str | None:
149145
return "\n\n".join(recovered_chunks)
150146

151147

148+
def _finalize_cancelled_streamed_agent_tool_recovery(run_result: Any) -> bool:
149+
"""Preserve completed outputs and only backfill plain-text results when missing."""
150+
final_output = getattr(run_result, "final_output", None)
151+
if final_output is not None and not (isinstance(final_output, str) and not final_output):
152+
return True
153+
154+
recovered_text = _recover_streamed_agent_tool_text(run_result)
155+
if recovered_text is None:
156+
return final_output is not None
157+
158+
run_result.final_output = recovered_text
159+
return True
160+
161+
152162
def _can_recover_cancelled_streamed_agent_tool(run_result: Any) -> bool:
153163
"""Recover text only after the nested streamed run has already reached local terminal state."""
154164
run_loop_task = getattr(run_result, "run_loop_task", None)
@@ -852,10 +862,10 @@ async def dispatch_stream_events() -> None:
852862
except asyncio.CancelledError:
853863
if not _can_recover_cancelled_streamed_agent_tool(run_result_streaming):
854864
raise
855-
recovered_text = _recover_streamed_agent_tool_text(run_result_streaming)
856-
if not recovered_text:
865+
if not _finalize_cancelled_streamed_agent_tool_recovery(
866+
run_result_streaming
867+
):
857868
raise
858-
run_result_streaming.final_output = recovered_text
859869
finally:
860870
await event_queue.put(None)
861871
await event_queue.join()

src/agents/items.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,19 @@ def extract_last_text(cls, message: TResponseOutputItem) -> str | None:
675675

676676
return None
677677

678+
@classmethod
679+
def extract_text(cls, message: TResponseOutputItem) -> str | None:
680+
"""Extracts all text content from a message, if any. Ignores refusals."""
681+
if not isinstance(message, ResponseOutputMessage):
682+
return None
683+
684+
text = ""
685+
for content_item in message.content:
686+
if isinstance(content_item, ResponseOutputText):
687+
text += content_item.text
688+
689+
return text or None
690+
678691
@classmethod
679692
def input_to_new_input_list(
680693
cls, input: str | list[TResponseInputItem]

tests/test_agent_as_tool.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1758,6 +1758,189 @@ async def on_stream(payload: AgentToolStreamEvent) -> None:
17581758
assert received_events[0]["event"] == stream_event
17591759

17601760

1761+
@pytest.mark.asyncio
1762+
async def test_agent_as_tool_streaming_recovers_all_text_chunks_from_raw_responses_on_cancellation(
1763+
monkeypatch: pytest.MonkeyPatch,
1764+
) -> None:
1765+
agent = Agent(name="streamer")
1766+
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
1767+
1768+
class DummyStreamingResult:
1769+
def __init__(self) -> None:
1770+
self.final_output = ""
1771+
self.current_agent = agent
1772+
self.new_items: list[Any] = []
1773+
self.raw_responses = [
1774+
ModelResponse(
1775+
output=[
1776+
ResponseOutputMessage(
1777+
id="msg_recover",
1778+
role="assistant",
1779+
status="completed",
1780+
type="message",
1781+
content=[
1782+
ResponseOutputText(
1783+
annotations=[],
1784+
text="first ",
1785+
type="output_text",
1786+
logprobs=[],
1787+
),
1788+
ResponseOutputText(
1789+
annotations=[],
1790+
text="second",
1791+
type="output_text",
1792+
logprobs=[],
1793+
),
1794+
],
1795+
)
1796+
],
1797+
usage=Usage(),
1798+
response_id="resp_nested",
1799+
)
1800+
]
1801+
self.run_loop_task = asyncio.create_task(asyncio.sleep(0))
1802+
1803+
async def stream_events(self):
1804+
yield stream_event
1805+
raise asyncio.CancelledError("stream-cancelled")
1806+
1807+
streaming_result = DummyStreamingResult()
1808+
await streaming_result.run_loop_task
1809+
1810+
def fake_run_streamed(
1811+
cls,
1812+
starting_agent,
1813+
input,
1814+
*,
1815+
context,
1816+
max_turns,
1817+
hooks,
1818+
run_config,
1819+
previous_response_id,
1820+
auto_previous_response_id=False,
1821+
conversation_id,
1822+
session,
1823+
):
1824+
return streaming_result
1825+
1826+
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
1827+
raise AssertionError("Runner.run should not be called when on_stream is provided.")
1828+
1829+
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
1830+
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
1831+
1832+
async def on_stream(payload: AgentToolStreamEvent) -> None:
1833+
del payload
1834+
1835+
tool_call = ResponseFunctionToolCall(
1836+
id="call_cancelled_multi_chunk",
1837+
arguments='{"input": "recover"}',
1838+
call_id="call-cancelled-multi-chunk",
1839+
name="stream_tool",
1840+
type="function_call",
1841+
)
1842+
1843+
tool = agent.as_tool(
1844+
tool_name="stream_tool",
1845+
tool_description="Streams events",
1846+
on_stream=on_stream,
1847+
)
1848+
1849+
tool_context = ToolContext(
1850+
context=None,
1851+
tool_name="stream_tool",
1852+
tool_call_id=tool_call.call_id,
1853+
tool_arguments=tool_call.arguments,
1854+
tool_call=tool_call,
1855+
)
1856+
output = await tool.on_invoke_tool(tool_context, '{"input": "recover"}')
1857+
1858+
assert output == "first second"
1859+
1860+
1861+
@pytest.mark.asyncio
1862+
async def test_agent_as_tool_streaming_preserves_structured_output_on_cancellation(
1863+
monkeypatch: pytest.MonkeyPatch,
1864+
) -> None:
1865+
class StructuredOutput(BaseModel):
1866+
answer: str
1867+
1868+
agent = Agent(name="streamer", output_type=StructuredOutput)
1869+
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
1870+
1871+
class DummyStreamingResult:
1872+
def __init__(self) -> None:
1873+
self.final_output = StructuredOutput(answer="structured")
1874+
self.current_agent = agent
1875+
self.new_items: list[Any] = []
1876+
self.raw_responses = [
1877+
ModelResponse(
1878+
output=[get_text_message('{"answer":"text-json"}')],
1879+
usage=Usage(),
1880+
response_id="resp_nested",
1881+
)
1882+
]
1883+
self.run_loop_task = asyncio.create_task(asyncio.sleep(0))
1884+
1885+
async def stream_events(self):
1886+
yield stream_event
1887+
raise asyncio.CancelledError("stream-cancelled")
1888+
1889+
streaming_result = DummyStreamingResult()
1890+
await streaming_result.run_loop_task
1891+
1892+
def fake_run_streamed(
1893+
cls,
1894+
starting_agent,
1895+
input,
1896+
*,
1897+
context,
1898+
max_turns,
1899+
hooks,
1900+
run_config,
1901+
previous_response_id,
1902+
auto_previous_response_id=False,
1903+
conversation_id,
1904+
session,
1905+
):
1906+
return streaming_result
1907+
1908+
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
1909+
raise AssertionError("Runner.run should not be called when on_stream is provided.")
1910+
1911+
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
1912+
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
1913+
1914+
async def on_stream(payload: AgentToolStreamEvent) -> None:
1915+
del payload
1916+
1917+
tool_call = ResponseFunctionToolCall(
1918+
id="call_cancelled_structured",
1919+
arguments='{"input": "recover"}',
1920+
call_id="call-cancelled-structured",
1921+
name="stream_tool",
1922+
type="function_call",
1923+
)
1924+
1925+
tool = agent.as_tool(
1926+
tool_name="stream_tool",
1927+
tool_description="Streams events",
1928+
on_stream=on_stream,
1929+
)
1930+
1931+
tool_context = ToolContext(
1932+
context=None,
1933+
tool_name="stream_tool",
1934+
tool_call_id=tool_call.call_id,
1935+
tool_arguments=tool_call.arguments,
1936+
tool_call=tool_call,
1937+
)
1938+
output = await tool.on_invoke_tool(tool_context, '{"input": "recover"}')
1939+
1940+
assert output == StructuredOutput(answer="structured")
1941+
assert not isinstance(output, str)
1942+
1943+
17611944
@pytest.mark.asyncio
17621945
async def test_agent_as_tool_streaming_reraises_caller_cancellation_with_live_run(
17631946
monkeypatch: pytest.MonkeyPatch,

tests/test_items_helpers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,27 @@ def test_extract_last_text_returns_text_only() -> None:
107107
assert ItemHelpers.extract_last_text(message2) is None
108108

109109

110+
def test_extract_text_concatenates_all_text_segments() -> None:
111+
first_text = ResponseOutputText(annotations=[], text="part1", type="output_text", logprobs=[])
112+
second_text = ResponseOutputText(annotations=[], text="part2", type="output_text", logprobs=[])
113+
refusal = ResponseOutputRefusal(refusal="no", type="refusal")
114+
message = make_message([first_text, refusal, second_text])
115+
116+
assert ItemHelpers.extract_text(message) == "part1part2"
117+
assert (
118+
ItemHelpers.extract_text(
119+
ResponseFunctionToolCall(
120+
id="tool123",
121+
arguments="{}",
122+
call_id="call123",
123+
name="func",
124+
type="function_call",
125+
)
126+
)
127+
is None
128+
)
129+
130+
110131
def test_input_to_new_input_list_from_string() -> None:
111132
result = ItemHelpers.input_to_new_input_list("hi")
112133
# Should wrap the string into a list with a single dict containing content and user role.

0 commit comments

Comments
 (0)