diff --git a/src/agents/__init__.py b/src/agents/__init__.py index 214e814d3e..23fad5baaa 100644 --- a/src/agents/__init__.py +++ b/src/agents/__init__.py @@ -117,6 +117,7 @@ from .stream_events import ( AgentUpdatedStreamEvent, RawResponsesStreamEvent, + ReasoningDeltaEvent, RunItemStreamEvent, StreamEvent, ) @@ -393,6 +394,7 @@ def enable_verbose_stdout_logging(): "RawResponsesStreamEvent", "RunItemStreamEvent", "AgentUpdatedStreamEvent", + "ReasoningDeltaEvent", "StreamEvent", "FunctionTool", "FunctionToolResult", diff --git a/src/agents/run_internal/run_loop.py b/src/agents/run_internal/run_loop.py index 3d21d89fda..ec2da1f5c4 100644 --- a/src/agents/run_internal/run_loop.py +++ b/src/agents/run_internal/run_loop.py @@ -11,7 +11,13 @@ from collections.abc import Awaitable, Callable, Mapping from typing import Any, TypeVar, cast -from openai.types.responses import Response, ResponseCompletedEvent, ResponseOutputItemDoneEvent +from openai.types.responses import Response, ResponseCompletedEvent, ResponseCreatedEvent, ResponseOutputItemDoneEvent +from openai.types.responses.response_reasoning_text_delta_event import ( + ResponseReasoningTextDeltaEvent, +) +from openai.types.responses.response_reasoning_summary_text_delta_event import ( + ResponseReasoningSummaryTextDeltaEvent, +) from openai.types.responses.response_output_item import McpCall, McpListTools from openai.types.responses.response_prompt_param import ResponsePromptParam from openai.types.responses.response_reasoning_item import ResponseReasoningItem @@ -60,6 +66,7 @@ from ..stream_events import ( AgentUpdatedStreamEvent, RawResponsesStreamEvent, + ReasoningDeltaEvent, RunItemStreamEvent, ) from ..tool import FunctionTool, Tool, dispose_resolved_computers @@ -1103,6 +1110,8 @@ async def run_single_turn_streamed( emitted_tool_call_ids: set[str] = set() emitted_reasoning_item_ids: set[str] = set() emitted_tool_search_fingerprints: set[str] = set() + # Accumulated reasoning text for ReasoningDeltaEvent snapshot field. + _reasoning_snapshot: str = "" # Precompute the lookup map used for streaming descriptions. Function tools use the same # collision-free lookup keys as runtime dispatch, including deferred top-level aliases. tool_map: dict[NamedToolLookupKey, Any] = cast( @@ -1286,6 +1295,27 @@ async def rewind_model_request() -> None: async for event in retry_stream: streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event)) + # Reset the reasoning snapshot at the start of each new response attempt + # (e.g., after a retry) so the snapshot field never contains stale text + # from a failed previous attempt. + if isinstance(event, ResponseCreatedEvent): + _reasoning_snapshot = "" + + # Emit a ReasoningDeltaEvent for reasoning/thinking deltas so consumers don't have + # to unwrap the raw event themselves. + if isinstance(event, ResponseReasoningSummaryTextDeltaEvent): + delta_text: str = event.delta or "" + _reasoning_snapshot += delta_text + streamed_result._event_queue.put_nowait( + ReasoningDeltaEvent(delta=delta_text, snapshot=_reasoning_snapshot) + ) + elif isinstance(event, ResponseReasoningTextDeltaEvent): + delta_text = event.delta or "" + _reasoning_snapshot += delta_text + streamed_result._event_queue.put_nowait( + ReasoningDeltaEvent(delta=delta_text, snapshot=_reasoning_snapshot) + ) + terminal_response: Response | None = None if isinstance(event, ResponseCompletedEvent): terminal_response = event.response diff --git a/src/agents/stream_events.py b/src/agents/stream_events.py index fcb2fe40fa..75bea41792 100644 --- a/src/agents/stream_events.py +++ b/src/agents/stream_events.py @@ -60,5 +60,30 @@ class AgentUpdatedStreamEvent: type: Literal["agent_updated_stream_event"] = "agent_updated_stream_event" -StreamEvent: TypeAlias = Union[RawResponsesStreamEvent, RunItemStreamEvent, AgentUpdatedStreamEvent] +@dataclass +class ReasoningDeltaEvent: + """Emitted when a reasoning/thinking delta is received from the model during streaming. + + This is a convenience wrapper over the low-level + ``response.reasoning_summary_text.delta`` and ``response.reasoning_text.delta`` raw + events. Both OpenAI o-series reasoning summaries and third-party + ``delta.reasoning`` fields (e.g. DeepSeek-R1 via LiteLLM) are surfaced here. + """ + + delta: str + """The incremental reasoning text fragment.""" + + snapshot: str + """The full reasoning text accumulated so far in this turn.""" + + type: Literal["reasoning_delta"] = "reasoning_delta" + """The type of the event.""" + + +StreamEvent: TypeAlias = Union[ + RawResponsesStreamEvent, + RunItemStreamEvent, + AgentUpdatedStreamEvent, + ReasoningDeltaEvent, +] """A streaming event from an agent.""" diff --git a/tests/test_reasoning_delta_stream_event.py b/tests/test_reasoning_delta_stream_event.py new file mode 100644 index 0000000000..858787e39b --- /dev/null +++ b/tests/test_reasoning_delta_stream_event.py @@ -0,0 +1,147 @@ +"""Tests for ReasoningDeltaEvent stream event (issue #825).""" + +from __future__ import annotations + +import pytest + +from agents import Agent, Runner +from agents.stream_events import ReasoningDeltaEvent, RawResponsesStreamEvent + +from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary + +from .fake_model import FakeModel +from .test_responses import get_text_message + + +def _make_reasoning_item(text: str) -> ResponseReasoningItem: + return ResponseReasoningItem( + id="rs_test", + type="reasoning", + summary=[Summary(text=text, type="summary_text")], + ) + + +@pytest.mark.asyncio +async def test_reasoning_delta_event_emitted_during_streaming() -> None: + """ReasoningDeltaEvent is emitted when the model streams a reasoning summary delta.""" + model = FakeModel() + model.set_next_output([ + _make_reasoning_item("Let me think..."), + get_text_message("Answer"), + ]) + + agent = Agent(name="A", model=model) + result = Runner.run_streamed(agent, input="hi") + + reasoning_deltas: list[ReasoningDeltaEvent] = [] + async for event in result.stream_events(): + if isinstance(event, ReasoningDeltaEvent): + reasoning_deltas.append(event) + + assert len(reasoning_deltas) >= 1 + assert all(isinstance(e.delta, str) for e in reasoning_deltas) + assert all(isinstance(e.snapshot, str) for e in reasoning_deltas) + assert all(e.type == "reasoning_delta" for e in reasoning_deltas) + + +@pytest.mark.asyncio +async def test_reasoning_delta_snapshot_accumulates() -> None: + """The snapshot field grows monotonically across delta events.""" + model = FakeModel() + model.set_next_output([ + _make_reasoning_item("Hello world"), + get_text_message("done"), + ]) + + agent = Agent(name="A", model=model) + result = Runner.run_streamed(agent, input="hi") + + snapshots: list[str] = [] + async for event in result.stream_events(): + if isinstance(event, ReasoningDeltaEvent): + snapshots.append(event.snapshot) + + # Each snapshot must be at least as long as the previous one + for i in range(1, len(snapshots)): + assert len(snapshots[i]) >= len(snapshots[i - 1]) + + # Last snapshot must contain the full reasoning text + if snapshots: + assert "Hello world" in snapshots[-1] + + +@pytest.mark.asyncio +async def test_no_reasoning_delta_event_without_reasoning() -> None: + """ReasoningDeltaEvent is not emitted when there is no reasoning in the response.""" + model = FakeModel() + model.set_next_output([get_text_message("plain text answer")]) + + agent = Agent(name="A", model=model) + result = Runner.run_streamed(agent, input="hi") + + async for event in result.stream_events(): + assert not isinstance(event, ReasoningDeltaEvent), ( + "Got unexpected ReasoningDeltaEvent for a plain text response" + ) + + +@pytest.mark.asyncio +async def test_reasoning_delta_event_type_field() -> None: + """ReasoningDeltaEvent.type is always 'reasoning_delta'.""" + model = FakeModel() + model.set_next_output([ + _make_reasoning_item("some reasoning"), + get_text_message("answer"), + ]) + + agent = Agent(name="A", model=model) + result = Runner.run_streamed(agent, input="hi") + + found = False + async for event in result.stream_events(): + if isinstance(event, ReasoningDeltaEvent): + assert event.type == "reasoning_delta" + found = True + break + assert found, "Expected at least one ReasoningDeltaEvent but none were emitted" + + +@pytest.mark.asyncio +async def test_raw_response_events_still_emitted_alongside_reasoning_delta() -> None: + """RawResponsesStreamEvent is still emitted even when ReasoningDeltaEvent is also emitted.""" + model = FakeModel() + model.set_next_output([ + _make_reasoning_item("thinking"), + get_text_message("result"), + ]) + + agent = Agent(name="A", model=model) + result = Runner.run_streamed(agent, input="hi") + + raw_events: list[RawResponsesStreamEvent] = [] + reasoning_events: list[ReasoningDeltaEvent] = [] + + async for event in result.stream_events(): + if isinstance(event, RawResponsesStreamEvent): + raw_events.append(event) + elif isinstance(event, ReasoningDeltaEvent): + reasoning_events.append(event) + + # Both types should be present + assert len(raw_events) > 0 + assert len(reasoning_events) > 0 + + +@pytest.mark.asyncio +async def test_reasoning_delta_event_importable_from_agents() -> None: + """ReasoningDeltaEvent can be imported directly from the agents package.""" + from agents import ReasoningDeltaEvent as RDE + assert RDE is ReasoningDeltaEvent + + +def test_reasoning_delta_event_dataclass() -> None: + """ReasoningDeltaEvent is a proper dataclass with expected fields.""" + event = ReasoningDeltaEvent(delta="chunk", snapshot="full chunk") + assert event.delta == "chunk" + assert event.snapshot == "full chunk" + assert event.type == "reasoning_delta" diff --git a/tests/test_stream_events.py b/tests/test_stream_events.py index f8dbd02e8d..c3b2968c12 100644 --- a/tests/test_stream_events.py +++ b/tests/test_stream_events.py @@ -339,7 +339,7 @@ async def test_complete_streaming_events(): async for event in result.stream_events(): events.append(event) - assert len(events) == 27, f"Expected 27 events but got {len(events)}" + assert len(events) == 28, f"Expected 28 events but got {len(events)}" # Event 0: agent_updated_stream_event assert events[0].type == "agent_updated_stream_event" @@ -365,93 +365,98 @@ async def test_complete_streaming_events(): assert events[5].type == "raw_response_event" assert isinstance(events[5].data, ResponseReasoningSummaryTextDeltaEvent) - # Event 6: ResponseReasoningSummaryTextDoneEvent - assert events[6].type == "raw_response_event" - assert isinstance(events[6].data, ResponseReasoningSummaryTextDoneEvent) + # Event 6: ReasoningDeltaEvent (emitted alongside the raw delta) + from agents.stream_events import ReasoningDeltaEvent + assert events[6].type == "reasoning_delta" + assert isinstance(events[6], ReasoningDeltaEvent) - # Event 7: ResponseReasoningSummaryPartDoneEvent + # Event 7: ResponseReasoningSummaryTextDoneEvent assert events[7].type == "raw_response_event" - assert isinstance(events[7].data, ResponseReasoningSummaryPartDoneEvent) + assert isinstance(events[7].data, ResponseReasoningSummaryTextDoneEvent) - # Event 8: ResponseOutputItemDoneEvent (reasoning item) + # Event 8: ResponseReasoningSummaryPartDoneEvent assert events[8].type == "raw_response_event" - assert isinstance(events[8].data, ResponseOutputItemDoneEvent) + assert isinstance(events[8].data, ResponseReasoningSummaryPartDoneEvent) - # Event 9: ReasoningItem run_item_stream_event - assert events[9].type == "run_item_stream_event" - assert events[9].name == "reasoning_item_created" - assert isinstance(events[9].item, ReasoningItem) + # Event 9: ResponseOutputItemDoneEvent (reasoning item) + assert events[9].type == "raw_response_event" + assert isinstance(events[9].data, ResponseOutputItemDoneEvent) - # Event 10: ResponseOutputItemAddedEvent (function call) - assert events[10].type == "raw_response_event" - assert isinstance(events[10].data, ResponseOutputItemAddedEvent) + # Event 10: ReasoningItem run_item_stream_event + assert events[10].type == "run_item_stream_event" + assert events[10].name == "reasoning_item_created" + assert isinstance(events[10].item, ReasoningItem) - # Event 11: ResponseFunctionCallArgumentsDeltaEvent + # Event 11: ResponseOutputItemAddedEvent (function call) assert events[11].type == "raw_response_event" - assert isinstance(events[11].data, ResponseFunctionCallArgumentsDeltaEvent) + assert isinstance(events[11].data, ResponseOutputItemAddedEvent) - # Event 12: ResponseFunctionCallArgumentsDoneEvent + # Event 12: ResponseFunctionCallArgumentsDeltaEvent assert events[12].type == "raw_response_event" - assert isinstance(events[12].data, ResponseFunctionCallArgumentsDoneEvent) + assert isinstance(events[12].data, ResponseFunctionCallArgumentsDeltaEvent) - # Event 13: ResponseOutputItemDoneEvent (function call) + # Event 13: ResponseFunctionCallArgumentsDoneEvent assert events[13].type == "raw_response_event" - assert isinstance(events[13].data, ResponseOutputItemDoneEvent) + assert isinstance(events[13].data, ResponseFunctionCallArgumentsDoneEvent) - # Event 14: ToolCallItem run_item_stream_event - assert events[14].type == "run_item_stream_event" - assert events[14].name == "tool_called" - assert isinstance(events[14].item, ToolCallItem) + # Event 14: ResponseOutputItemDoneEvent (function call) + assert events[14].type == "raw_response_event" + assert isinstance(events[14].data, ResponseOutputItemDoneEvent) - # Event 15: ResponseCompletedEvent (first turn ended) - assert events[15].type == "raw_response_event" - assert isinstance(events[15].data, ResponseCompletedEvent) + # Event 15: ToolCallItem run_item_stream_event + assert events[15].type == "run_item_stream_event" + assert events[15].name == "tool_called" + assert isinstance(events[15].item, ToolCallItem) - # Event 16: ToolCallOutputItem run_item_stream_event - assert events[16].type == "run_item_stream_event" - assert events[16].name == "tool_output" - assert isinstance(events[16].item, ToolCallOutputItem) + # Event 16: ResponseCompletedEvent (first turn ended) + assert events[16].type == "raw_response_event" + assert isinstance(events[16].data, ResponseCompletedEvent) - # Event 17: ResponseCreatedEvent (second turn started) - assert events[17].type == "raw_response_event" - assert isinstance(events[17].data, ResponseCreatedEvent) + # Event 17: ToolCallOutputItem run_item_stream_event + assert events[17].type == "run_item_stream_event" + assert events[17].name == "tool_output" + assert isinstance(events[17].item, ToolCallOutputItem) - # Event 18: ResponseInProgressEvent + # Event 18: ResponseCreatedEvent (second turn started) assert events[18].type == "raw_response_event" - assert isinstance(events[18].data, ResponseInProgressEvent) + assert isinstance(events[18].data, ResponseCreatedEvent) - # Event 19: ResponseOutputItemAddedEvent + # Event 19: ResponseInProgressEvent assert events[19].type == "raw_response_event" - assert isinstance(events[19].data, ResponseOutputItemAddedEvent) + assert isinstance(events[19].data, ResponseInProgressEvent) - # Event 20: ResponseContentPartAddedEvent + # Event 20: ResponseOutputItemAddedEvent assert events[20].type == "raw_response_event" - assert isinstance(events[20].data, ResponseContentPartAddedEvent) + assert isinstance(events[20].data, ResponseOutputItemAddedEvent) - # Event 21: ResponseTextDeltaEvent + # Event 21: ResponseContentPartAddedEvent assert events[21].type == "raw_response_event" - assert isinstance(events[21].data, ResponseTextDeltaEvent) + assert isinstance(events[21].data, ResponseContentPartAddedEvent) - # Event 22: ResponseTextDoneEvent + # Event 22: ResponseTextDeltaEvent assert events[22].type == "raw_response_event" - assert isinstance(events[22].data, ResponseTextDoneEvent) + assert isinstance(events[22].data, ResponseTextDeltaEvent) - # Event 23: ResponseContentPartDoneEvent + # Event 23: ResponseTextDoneEvent assert events[23].type == "raw_response_event" - assert isinstance(events[23].data, ResponseContentPartDoneEvent) + assert isinstance(events[23].data, ResponseTextDoneEvent) - # Event 24: ResponseOutputItemDoneEvent + # Event 24: ResponseContentPartDoneEvent assert events[24].type == "raw_response_event" - assert isinstance(events[24].data, ResponseOutputItemDoneEvent) + assert isinstance(events[24].data, ResponseContentPartDoneEvent) - # Event 25: ResponseCompletedEvent (second turn ended) + # Event 25: ResponseOutputItemDoneEvent assert events[25].type == "raw_response_event" - assert isinstance(events[25].data, ResponseCompletedEvent) + assert isinstance(events[25].data, ResponseOutputItemDoneEvent) - # Event 26: MessageOutputItem run_item_stream_event - assert events[26].type == "run_item_stream_event" - assert events[26].name == "message_output_created" - assert isinstance(events[26].item, MessageOutputItem) + # Event 26: ResponseCompletedEvent (second turn ended) + assert events[26].type == "raw_response_event" + assert isinstance(events[26].data, ResponseCompletedEvent) + + # Event 27: MessageOutputItem run_item_stream_event + assert events[27].type == "run_item_stream_event" + assert events[27].name == "message_output_created" + assert isinstance(events[27].item, MessageOutputItem) @pytest.mark.asyncio