Skip to content

Commit 63831e7

Browse files
Ubuntuadityasingh2400
authored andcommitted
feat(streaming): emit ReasoningDeltaEvent for reasoning/thinking deltas (#825)
Add a new ReasoningDeltaEvent to StreamEvent so callers can react to reasoning/thinking tokens in real time without unpacking low-level raw response events. The event is emitted whenever a ResponseReasoningSummaryTextDeltaEvent (o-series extended thinking via the Responses API) or a ResponseReasoningTextDeltaEvent (third-party models like DeepSeek-R1 via LiteLLM) passes through the stream. The underlying RawResponsesStreamEvent is still emitted as well, so nothing breaks for consumers that already inspect raw events. Fields: delta - the incremental text fragment from this chunk snapshot - full accumulated reasoning text so far in this turn type - always 'reasoning_delta' Closes #825
1 parent ba889de commit 63831e7

5 files changed

Lines changed: 253 additions & 56 deletions

File tree

src/agents/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@
117117
from .stream_events import (
118118
AgentUpdatedStreamEvent,
119119
RawResponsesStreamEvent,
120+
ReasoningDeltaEvent,
120121
RunItemStreamEvent,
121122
StreamEvent,
122123
)
@@ -432,6 +433,7 @@ def enable_verbose_stdout_logging():
432433
"RawResponsesStreamEvent",
433434
"RunItemStreamEvent",
434435
"AgentUpdatedStreamEvent",
436+
"ReasoningDeltaEvent",
435437
"StreamEvent",
436438
"FunctionTool",
437439
"FunctionToolResult",

src/agents/run_internal/run_loop.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
ResponseOutputItemDoneEvent,
1919
)
2020
from openai.types.responses.response_output_item import McpCall, McpListTools, ResponseOutputItem
21+
from openai.types.responses.response_reasoning_summary_text_delta_event import (
22+
ResponseReasoningSummaryTextDeltaEvent,
23+
)
24+
from openai.types.responses.response_reasoning_text_delta_event import (
25+
ResponseReasoningTextDeltaEvent,
26+
)
2127
from openai.types.responses.response_prompt_param import ResponsePromptParam
2228
from openai.types.responses.response_reasoning_item import ResponseReasoningItem
2329

@@ -67,6 +73,7 @@
6773
from ..stream_events import (
6874
AgentUpdatedStreamEvent,
6975
RawResponsesStreamEvent,
76+
ReasoningDeltaEvent,
7077
RunItemStreamEvent,
7178
)
7279
from ..tool import (
@@ -1273,6 +1280,8 @@ async def raise_if_input_guardrail_tripwire_known() -> None:
12731280
emitted_tool_call_ids: set[str] = set()
12741281
emitted_reasoning_item_ids: set[str] = set()
12751282
emitted_tool_search_fingerprints: set[str] = set()
1283+
# Accumulated reasoning text for ReasoningDeltaEvent snapshot field.
1284+
_reasoning_snapshot: str = ""
12761285
# Precompute the lookup map used for streaming descriptions. Function tools use the same
12771286
# collision-free lookup keys as runtime dispatch, including deferred top-level aliases.
12781287
tool_map: dict[NamedToolLookupKey, Any] = cast(
@@ -1476,6 +1485,21 @@ async def rewind_model_request() -> None:
14761485
async for event in retry_stream:
14771486
streamed_result._event_queue.put_nowait(RawResponsesStreamEvent(data=event))
14781487

1488+
# Emit a ReasoningDeltaEvent for reasoning/thinking deltas so consumers don't have
1489+
# to unwrap the raw event themselves.
1490+
if isinstance(event, ResponseReasoningSummaryTextDeltaEvent):
1491+
delta_text: str = event.delta or ""
1492+
_reasoning_snapshot += delta_text
1493+
streamed_result._event_queue.put_nowait(
1494+
ReasoningDeltaEvent(delta=delta_text, snapshot=_reasoning_snapshot)
1495+
)
1496+
elif isinstance(event, ResponseReasoningTextDeltaEvent):
1497+
delta_text = event.delta or ""
1498+
_reasoning_snapshot += delta_text
1499+
streamed_result._event_queue.put_nowait(
1500+
ReasoningDeltaEvent(delta=delta_text, snapshot=_reasoning_snapshot)
1501+
)
1502+
14791503
terminal_response: Response | None = None
14801504
is_completed_event = False
14811505
if isinstance(event, ResponseCompletedEvent):

src/agents/stream_events.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,27 @@ class AgentUpdatedStreamEvent:
5858
type: Literal["agent_updated_stream_event"] = "agent_updated_stream_event"
5959

6060

61-
StreamEvent: TypeAlias = RawResponsesStreamEvent | RunItemStreamEvent | AgentUpdatedStreamEvent
61+
@dataclass
62+
class ReasoningDeltaEvent:
63+
"""Emitted when a reasoning/thinking delta is received from the model during streaming.
64+
65+
This is a convenience wrapper over the low-level
66+
``response.reasoning_summary_text.delta`` and ``response.reasoning_text.delta`` raw
67+
events. Both OpenAI o-series reasoning summaries and third-party
68+
``delta.reasoning`` fields (e.g. DeepSeek-R1 via LiteLLM) are surfaced here.
69+
"""
70+
71+
delta: str
72+
"""The incremental reasoning text fragment."""
73+
74+
snapshot: str
75+
"""The full reasoning text accumulated so far in this turn."""
76+
77+
type: Literal["reasoning_delta"] = "reasoning_delta"
78+
"""The type of the event."""
79+
80+
81+
StreamEvent: TypeAlias = (
82+
RawResponsesStreamEvent | RunItemStreamEvent | AgentUpdatedStreamEvent | ReasoningDeltaEvent
83+
)
6284
"""A streaming event from an agent."""
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Tests for ReasoningDeltaEvent stream event (issue #825)."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from agents import Agent, Runner
8+
from agents.stream_events import ReasoningDeltaEvent, RawResponsesStreamEvent
9+
10+
from openai.types.responses.response_reasoning_item import ResponseReasoningItem, Summary
11+
12+
from .fake_model import FakeModel
13+
from .test_responses import get_text_message
14+
15+
16+
def _make_reasoning_item(text: str) -> ResponseReasoningItem:
17+
return ResponseReasoningItem(
18+
id="rs_test",
19+
type="reasoning",
20+
summary=[Summary(text=text, type="summary_text")],
21+
)
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_reasoning_delta_event_emitted_during_streaming() -> None:
26+
"""ReasoningDeltaEvent is emitted when the model streams a reasoning summary delta."""
27+
model = FakeModel()
28+
model.set_next_output([
29+
_make_reasoning_item("Let me think..."),
30+
get_text_message("Answer"),
31+
])
32+
33+
agent = Agent(name="A", model=model)
34+
result = Runner.run_streamed(agent, input="hi")
35+
36+
reasoning_deltas: list[ReasoningDeltaEvent] = []
37+
async for event in result.stream_events():
38+
if isinstance(event, ReasoningDeltaEvent):
39+
reasoning_deltas.append(event)
40+
41+
assert len(reasoning_deltas) >= 1
42+
assert all(isinstance(e.delta, str) for e in reasoning_deltas)
43+
assert all(isinstance(e.snapshot, str) for e in reasoning_deltas)
44+
assert all(e.type == "reasoning_delta" for e in reasoning_deltas)
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_reasoning_delta_snapshot_accumulates() -> None:
49+
"""The snapshot field grows monotonically across delta events."""
50+
model = FakeModel()
51+
model.set_next_output([
52+
_make_reasoning_item("Hello world"),
53+
get_text_message("done"),
54+
])
55+
56+
agent = Agent(name="A", model=model)
57+
result = Runner.run_streamed(agent, input="hi")
58+
59+
snapshots: list[str] = []
60+
async for event in result.stream_events():
61+
if isinstance(event, ReasoningDeltaEvent):
62+
snapshots.append(event.snapshot)
63+
64+
# Each snapshot must be at least as long as the previous one
65+
for i in range(1, len(snapshots)):
66+
assert len(snapshots[i]) >= len(snapshots[i - 1])
67+
68+
# Last snapshot must contain the full reasoning text
69+
if snapshots:
70+
assert "Hello world" in snapshots[-1]
71+
72+
73+
@pytest.mark.asyncio
74+
async def test_no_reasoning_delta_event_without_reasoning() -> None:
75+
"""ReasoningDeltaEvent is not emitted when there is no reasoning in the response."""
76+
model = FakeModel()
77+
model.set_next_output([get_text_message("plain text answer")])
78+
79+
agent = Agent(name="A", model=model)
80+
result = Runner.run_streamed(agent, input="hi")
81+
82+
async for event in result.stream_events():
83+
assert not isinstance(event, ReasoningDeltaEvent), (
84+
"Got unexpected ReasoningDeltaEvent for a plain text response"
85+
)
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_reasoning_delta_event_type_field() -> None:
90+
"""ReasoningDeltaEvent.type is always 'reasoning_delta'."""
91+
model = FakeModel()
92+
model.set_next_output([
93+
_make_reasoning_item("some reasoning"),
94+
get_text_message("answer"),
95+
])
96+
97+
agent = Agent(name="A", model=model)
98+
result = Runner.run_streamed(agent, input="hi")
99+
100+
async for event in result.stream_events():
101+
if isinstance(event, ReasoningDeltaEvent):
102+
assert event.type == "reasoning_delta"
103+
break
104+
105+
106+
@pytest.mark.asyncio
107+
async def test_raw_response_events_still_emitted_alongside_reasoning_delta() -> None:
108+
"""RawResponsesStreamEvent is still emitted even when ReasoningDeltaEvent is also emitted."""
109+
model = FakeModel()
110+
model.set_next_output([
111+
_make_reasoning_item("thinking"),
112+
get_text_message("result"),
113+
])
114+
115+
agent = Agent(name="A", model=model)
116+
result = Runner.run_streamed(agent, input="hi")
117+
118+
raw_events: list[RawResponsesStreamEvent] = []
119+
reasoning_events: list[ReasoningDeltaEvent] = []
120+
121+
async for event in result.stream_events():
122+
if isinstance(event, RawResponsesStreamEvent):
123+
raw_events.append(event)
124+
elif isinstance(event, ReasoningDeltaEvent):
125+
reasoning_events.append(event)
126+
127+
# Both types should be present
128+
assert len(raw_events) > 0
129+
assert len(reasoning_events) > 0
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_reasoning_delta_event_importable_from_agents() -> None:
134+
"""ReasoningDeltaEvent can be imported directly from the agents package."""
135+
from agents import ReasoningDeltaEvent as RDE
136+
assert RDE is ReasoningDeltaEvent
137+
138+
139+
def test_reasoning_delta_event_dataclass() -> None:
140+
"""ReasoningDeltaEvent is a proper dataclass with expected fields."""
141+
event = ReasoningDeltaEvent(delta="chunk", snapshot="full chunk")
142+
assert event.delta == "chunk"
143+
assert event.snapshot == "full chunk"
144+
assert event.type == "reasoning_delta"

0 commit comments

Comments
 (0)