Skip to content

Commit ccfaca2

Browse files
elainegan-openaicodex
authored andcommitted
Recover streamed agent-tool text on cancellation
Co-authored-by: Codex <noreply@openai.com>
1 parent 05dc068 commit ccfaca2

2 files changed

Lines changed: 131 additions & 10 deletions

File tree

src/agents/agent.py

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from .exceptions import ModelBehaviorError, UserError
3030
from .guardrail import InputGuardrail, OutputGuardrail
3131
from .handoffs import Handoff
32+
from .items import ItemHelpers
3233
from .logger import logger
3334
from .mcp import MCPUtil
3435
from .model_settings import ModelSettings
@@ -118,6 +119,36 @@ def _validate_codex_tool_name_collisions(tools: list[Tool]) -> None:
118119
)
119120

120121

122+
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+
127+
new_items = getattr(run_result, "new_items", None)
128+
if isinstance(new_items, list):
129+
text = ItemHelpers.text_message_outputs(new_items)
130+
if text:
131+
return text
132+
133+
raw_responses = getattr(run_result, "raw_responses", None)
134+
if not isinstance(raw_responses, list):
135+
return None
136+
137+
recovered_chunks: list[str] = []
138+
for raw_response in raw_responses:
139+
outputs = getattr(raw_response, "output", None)
140+
if not isinstance(outputs, list):
141+
continue
142+
for output_item in outputs:
143+
text = ItemHelpers.extract_last_text(output_item)
144+
if text:
145+
recovered_chunks.append(text)
146+
147+
if not recovered_chunks:
148+
return None
149+
return "\n\n".join(recovered_chunks)
150+
151+
121152
class AgentToolStreamEvent(TypedDict):
122153
"""Streaming event emitted when an agent is invoked as a tool."""
123154

@@ -794,16 +825,24 @@ async def dispatch_stream_events() -> None:
794825
from .stream_events import AgentUpdatedStreamEvent
795826

796827
current_agent = run_result_streaming.current_agent
797-
async for event in run_result_streaming.stream_events():
798-
if isinstance(event, AgentUpdatedStreamEvent):
799-
current_agent = event.new_agent
800-
801-
payload: AgentToolStreamEvent = {
802-
"event": event,
803-
"agent": current_agent,
804-
"tool_call": context.tool_call,
805-
}
806-
await event_queue.put(payload)
828+
try:
829+
async for event in run_result_streaming.stream_events():
830+
if isinstance(event, AgentUpdatedStreamEvent):
831+
current_agent = event.new_agent
832+
833+
payload: AgentToolStreamEvent = {
834+
"event": event,
835+
"agent": current_agent,
836+
"tool_call": context.tool_call,
837+
}
838+
await event_queue.put(payload)
839+
except asyncio.CancelledError:
840+
recovered_text = _recover_streamed_agent_tool_text(
841+
run_result_streaming
842+
)
843+
if not recovered_text:
844+
raise
845+
run_result_streaming.final_output = recovered_text
807846
finally:
808847
await event_queue.put(None)
809848
await event_queue.join()

tests/test_agent_as_tool.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
FunctionTool,
1818
MessageOutputItem,
1919
ModelBehaviorError,
20+
ModelResponse,
2021
RunConfig,
2122
RunContextWrapper,
2223
RunHooks,
@@ -27,6 +28,7 @@
2728
SessionSettings,
2829
ToolApprovalItem,
2930
TResponseInputItem,
31+
Usage,
3032
tool_namespace,
3133
)
3234
from agents.agent_tool_input import StructuredToolInputBuilderOptions
@@ -39,6 +41,7 @@
3941
from agents.run_state import _build_agent_map
4042
from agents.stream_events import AgentUpdatedStreamEvent, RawResponsesStreamEvent
4143
from agents.tool_context import ToolContext
44+
from tests.test_responses import get_text_message
4245
from tests.utils.hitl import make_function_tool_call
4346

4447

@@ -1671,6 +1674,85 @@ async def on_stream(payload: AgentToolStreamEvent) -> None:
16711674
assert callbacks == stream_events
16721675

16731676

1677+
@pytest.mark.asyncio
1678+
async def test_agent_as_tool_streaming_recovers_text_from_raw_responses_on_cancellation(
1679+
monkeypatch: pytest.MonkeyPatch,
1680+
) -> None:
1681+
agent = Agent(name="streamer")
1682+
stream_event = RawResponsesStreamEvent(data=cast(Any, {"type": "response_started"}))
1683+
1684+
class DummyStreamingResult:
1685+
def __init__(self) -> None:
1686+
self.final_output = ""
1687+
self.current_agent = agent
1688+
self.new_items: list[Any] = []
1689+
self.raw_responses = [
1690+
ModelResponse(
1691+
output=[get_text_message("Recovered nested summary")],
1692+
usage=Usage(),
1693+
response_id="resp_nested",
1694+
)
1695+
]
1696+
1697+
async def stream_events(self):
1698+
yield stream_event
1699+
raise asyncio.CancelledError("stream-cancelled")
1700+
1701+
def fake_run_streamed(
1702+
cls,
1703+
starting_agent,
1704+
input,
1705+
*,
1706+
context,
1707+
max_turns,
1708+
hooks,
1709+
run_config,
1710+
previous_response_id,
1711+
auto_previous_response_id=False,
1712+
conversation_id,
1713+
session,
1714+
):
1715+
return DummyStreamingResult()
1716+
1717+
async def unexpected_run(*args: Any, **kwargs: Any) -> None:
1718+
raise AssertionError("Runner.run should not be called when on_stream is provided.")
1719+
1720+
monkeypatch.setattr(Runner, "run_streamed", classmethod(fake_run_streamed))
1721+
monkeypatch.setattr(Runner, "run", classmethod(unexpected_run))
1722+
1723+
received_events: list[AgentToolStreamEvent] = []
1724+
1725+
async def on_stream(payload: AgentToolStreamEvent) -> None:
1726+
received_events.append(payload)
1727+
1728+
tool_call = ResponseFunctionToolCall(
1729+
id="call_cancelled",
1730+
arguments='{"input": "recover"}',
1731+
call_id="call-cancelled",
1732+
name="stream_tool",
1733+
type="function_call",
1734+
)
1735+
1736+
tool = agent.as_tool(
1737+
tool_name="stream_tool",
1738+
tool_description="Streams events",
1739+
on_stream=on_stream,
1740+
)
1741+
1742+
tool_context = ToolContext(
1743+
context=None,
1744+
tool_name="stream_tool",
1745+
tool_call_id=tool_call.call_id,
1746+
tool_arguments=tool_call.arguments,
1747+
tool_call=tool_call,
1748+
)
1749+
output = await tool.on_invoke_tool(tool_context, '{"input": "recover"}')
1750+
1751+
assert output == "Recovered nested summary"
1752+
assert len(received_events) == 1
1753+
assert received_events[0]["event"] == stream_event
1754+
1755+
16741756
@pytest.mark.asyncio
16751757
async def test_agent_as_tool_streaming_extractor_can_access_agent_tool_invocation(
16761758
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)