Skip to content

Commit 8426217

Browse files
seratchcodex
andcommitted
fix: settle nested streamed agent tool outputs
Move nested streamed agent-tool output settlement back to the normal turn-resolution path by concatenating all text segments from the final assistant message before plain-text return or structured-output validation. This fixes empty or truncated outputs for successful nested streaming runs, including cases where an MCP tool failure is handled and followed by a final assistant message. Preserve caller cancellation semantics for as_tool(..., on_stream=...) by re-raising outer cancellation and cancelling the background stream-dispatch task promptly instead of converting CancelledError into a successful tool result. Add regression coverage for multi-segment plain-text settlement, multi-segment structured-output settlement, nested MCP cancellation and McpError failures followed by final output, and prompt parent cancellation while the on_stream handler is blocked. Co-authored-by: Codex <noreply@openai.com>
1 parent 05cc57c commit 8426217

3 files changed

Lines changed: 189 additions & 414 deletions

File tree

src/agents/agent.py

Lines changed: 13 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
from .exceptions import ModelBehaviorError, UserError
3030
from .guardrail import InputGuardrail, OutputGuardrail
3131
from .handoffs import Handoff
32-
from .items import ItemHelpers
3332
from .logger import logger
3433
from .mcp import MCPUtil
3534
from .model_settings import ModelSettings
@@ -119,59 +118,6 @@ def _validate_codex_tool_name_collisions(tools: list[Tool]) -> None:
119118
)
120119

121120

122-
def _recover_streamed_agent_tool_text(run_result: Any) -> str | None:
123-
new_items = getattr(run_result, "new_items", None)
124-
if isinstance(new_items, list):
125-
text = ItemHelpers.text_message_outputs(new_items)
126-
if text:
127-
return text
128-
129-
raw_responses = getattr(run_result, "raw_responses", None)
130-
if not isinstance(raw_responses, list):
131-
return None
132-
133-
recovered_chunks: list[str] = []
134-
for raw_response in raw_responses:
135-
outputs = getattr(raw_response, "output", None)
136-
if not isinstance(outputs, list):
137-
continue
138-
for output_item in outputs:
139-
chunk = ItemHelpers.extract_text(output_item)
140-
if chunk:
141-
recovered_chunks.append(chunk)
142-
143-
if not recovered_chunks:
144-
return None
145-
return "\n\n".join(recovered_chunks)
146-
147-
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-
162-
def _can_recover_cancelled_streamed_agent_tool(run_result: Any) -> bool:
163-
"""Recover text only after the nested streamed run has already reached local terminal state."""
164-
run_loop_task = getattr(run_result, "run_loop_task", None)
165-
if isinstance(run_loop_task, asyncio.Task):
166-
return (
167-
run_loop_task.done()
168-
and not run_loop_task.cancelled()
169-
and run_loop_task.exception() is None
170-
)
171-
172-
return False
173-
174-
175121
class AgentToolStreamEvent(TypedDict):
176122
"""Streaming event emitted when an agent is invoked as a tool."""
177123

@@ -843,6 +789,7 @@ async def dispatch_stream_events() -> None:
843789
break
844790

845791
dispatch_task = asyncio.create_task(dispatch_stream_events())
792+
stream_iteration_cancelled = False
846793

847794
try:
848795
from .stream_events import AgentUpdatedStreamEvent
@@ -860,16 +807,19 @@ async def dispatch_stream_events() -> None:
860807
}
861808
await event_queue.put(payload)
862809
except asyncio.CancelledError:
863-
if not _can_recover_cancelled_streamed_agent_tool(run_result_streaming):
864-
raise
865-
if not _finalize_cancelled_streamed_agent_tool_recovery(
866-
run_result_streaming
867-
):
868-
raise
810+
stream_iteration_cancelled = True
811+
raise
869812
finally:
870-
await event_queue.put(None)
871-
await event_queue.join()
872-
await dispatch_task
813+
if stream_iteration_cancelled:
814+
dispatch_task.cancel()
815+
try:
816+
await dispatch_task
817+
except asyncio.CancelledError:
818+
pass
819+
else:
820+
await event_queue.put(None)
821+
await event_queue.join()
822+
await dispatch_task
873823
run_result = run_result_streaming
874824
else:
875825
run_result = await Runner.run(

src/agents/run_internal/turn_resolution.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ async def execute_tools_and_side_effects(
615615

616616
message_items = [item for item in new_step_items if isinstance(item, MessageOutputItem)]
617617
potential_final_output_text = (
618-
ItemHelpers.extract_last_text(message_items[-1].raw_item) if message_items else None
618+
ItemHelpers.extract_text(message_items[-1].raw_item) if message_items else None
619619
)
620620

621621
if not processed_response.has_tools_or_approvals_to_run():

0 commit comments

Comments
 (0)