Skip to content

Commit 7443efe

Browse files
authored
feat: Add reasoning content to streaming chunk (#9777)
* Add reasoning content to streaming chunk * Add reno * Update print_streaming_chunk
1 parent 443101e commit 7443efe

4 files changed

Lines changed: 109 additions & 16 deletions

File tree

haystack/components/generators/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ def print_streaming_chunk(chunk: StreamingChunk) -> None:
6262
print("[ASSISTANT]\n", flush=True, end="")
6363
print(chunk.content, flush=True, end="")
6464

65+
## Reasoning content streaming
66+
# Print the reasoning content of the chunk (from ChatGenerator)
67+
if chunk.reasoning:
68+
if chunk.start:
69+
print("[REASONING]\n", flush=True, end="")
70+
print(chunk.reasoning.reasoning_text, flush=True, end="")
71+
6572
# End of LLM assistant message so we add two new lines
6673
# This ensures spacing between multiple LLM messages (e.g. Agent) or multiple Tool Call Results
6774
if chunk.finish_reason is not None:

haystack/dataclasses/streaming_chunk.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Awaitable, Callable, Literal, Optional, Union, overload
77

88
from haystack.core.component import Component
9-
from haystack.dataclasses.chat_message import ToolCallResult
9+
from haystack.dataclasses.chat_message import ReasoningContent, ToolCallResult
1010
from haystack.utils.asynchronous import is_callable_async_compatible
1111

1212
# Type alias for standard finish_reason values following OpenAI's convention
@@ -114,6 +114,8 @@ class StreamingChunk:
114114
:param finish_reason: An optional value indicating the reason the generation finished.
115115
Standard values follow OpenAI's convention: "stop", "length", "tool_calls", "content_filter",
116116
plus Haystack-specific value "tool_call_results".
117+
:param reasoning: An optional ReasoningContent object representing the reasoning content associated
118+
with the message chunk.
117119
"""
118120

119121
content: str
@@ -124,19 +126,20 @@ class StreamingChunk:
124126
tool_call_result: Optional[ToolCallResult] = field(default=None)
125127
start: bool = field(default=False)
126128
finish_reason: Optional[FinishReason] = field(default=None)
129+
reasoning: Optional[ReasoningContent] = field(default=None)
127130

128131
def __post_init__(self):
129-
fields_set = sum(bool(x) for x in (self.content, self.tool_calls, self.tool_call_result))
132+
fields_set = sum(bool(x) for x in (self.content, self.tool_calls, self.tool_call_result, self.reasoning))
130133
if fields_set > 1:
131134
raise ValueError(
132-
"Only one of `content`, `tool_call`, or `tool_call_result` may be set in a StreamingChunk. "
135+
"Only one of `content`, `tool_call`, `tool_call_result` or `reasoning` may be set in a StreamingChunk. "
133136
f"Got content: '{self.content}', tool_call: '{self.tool_calls}', "
134-
f"tool_call_result: '{self.tool_call_result}'"
137+
f"tool_call_result: '{self.tool_call_result}', reasoning: '{self.reasoning}'."
135138
)
136139

137140
# NOTE: We don't enforce this for self.content otherwise it would be a breaking change
138-
if (self.tool_calls or self.tool_call_result) and self.index is None:
139-
raise ValueError("If `tool_call`, or `tool_call_result` is set, `index` must also be set.")
141+
if (self.tool_calls or self.tool_call_result or self.reasoning) and self.index is None:
142+
raise ValueError("If `tool_call`, `tool_call_result` or `reasoning` is set, `index` must also be set.")
140143

141144
def to_dict(self) -> dict[str, Any]:
142145
"""
@@ -153,6 +156,7 @@ def to_dict(self) -> dict[str, Any]:
153156
"tool_call_result": self.tool_call_result.to_dict() if self.tool_call_result else None,
154157
"start": self.start,
155158
"finish_reason": self.finish_reason,
159+
"reasoning": self.reasoning.to_dict() if self.reasoning else None,
156160
}
157161

158162
@classmethod
@@ -177,6 +181,7 @@ def from_dict(cls, data: dict[str, Any]) -> "StreamingChunk":
177181
else None,
178182
start=data.get("start", False),
179183
finish_reason=data.get("finish_reason"),
184+
reasoning=ReasoningContent.from_dict(data["reasoning"]) if data.get("reasoning") else None,
180185
)
181186

182187

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
features:
3+
- |
4+
Add a `reasoning` field to `StreamingChunk` that optionally takes in a `ReasoningContent` dataclass. This is to allow a structured way to pass reasoning contents to streaming chunks.

test/dataclasses/test_streaming_chunk.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55
import pytest
66

77
from haystack import Pipeline, component
8-
from haystack.dataclasses import ComponentInfo, FinishReason, StreamingChunk, ToolCall, ToolCallDelta, ToolCallResult
8+
from haystack.dataclasses import (
9+
ComponentInfo,
10+
ReasoningContent,
11+
StreamingChunk,
12+
ToolCall,
13+
ToolCallDelta,
14+
ToolCallResult,
15+
)
916

1017

1118
@component
@@ -75,17 +82,30 @@ def test_create_chunk_with_content_and_tool_call_result():
7582
)
7683

7784

85+
def test_create_chunk_with_content_and_reasoning():
86+
with pytest.raises(ValueError, match="Only one of `content`, `tool_call`, `tool_call_result`"):
87+
StreamingChunk(
88+
content="Test content", meta={"key": "value"}, reasoning=ReasoningContent(reasoning_text="thinking")
89+
)
90+
91+
92+
def test_reasoning_and_no_index():
93+
with pytest.raises(
94+
ValueError, match="If `tool_call`, `tool_call_result` or `reasoning` is set, `index` must also be set."
95+
):
96+
StreamingChunk(content="", meta={"key": "value"}, reasoning=ReasoningContent(reasoning_text="thinking"))
97+
98+
7899
def test_component_info_from_component():
79-
component = ExampleComponent()
80-
component_info = ComponentInfo.from_component(component)
100+
component_info = ComponentInfo.from_component(ExampleComponent())
81101
assert component_info.type == "test_streaming_chunk.ExampleComponent"
82102

83103

84104
def test_component_info_from_component_with_name_from_pipeline():
85105
pipeline = Pipeline()
86-
component = ExampleComponent()
87-
pipeline.add_component("pipeline_component", component)
88-
component_info = ComponentInfo.from_component(component)
106+
comp = ExampleComponent()
107+
pipeline.add_component("pipeline_component", comp)
108+
component_info = ComponentInfo.from_component(comp)
89109
assert component_info.type == "test_streaming_chunk.ExampleComponent"
90110
assert component_info.name == "pipeline_component"
91111

@@ -139,8 +159,7 @@ def test_finish_reason_tool_call_results():
139159

140160
def test_to_dict_tool_call_result():
141161
"""Test the to_dict method for StreamingChunk with tool_call_result."""
142-
component = ExampleComponent()
143-
component_info = ComponentInfo.from_component(component)
162+
component_info = ComponentInfo.from_component(ExampleComponent())
144163
tool_call_result = ToolCallResult(
145164
result="output", origin=ToolCall(id="123", tool_name="test_tool", arguments={"arg1": "value1"}), error=False
146165
)
@@ -165,12 +184,12 @@ def test_to_dict_tool_call_result():
165184
assert d["tool_call_result"]["origin"]["id"] == "123"
166185
assert d["tool_call_result"]["origin"]["arguments"]["arg1"] == "value1"
167186
assert d["finish_reason"] == "tool_call_results"
187+
assert d["reasoning"] is None
168188

169189

170190
def test_to_dict_tool_calls():
171191
"""Test the to_dict method for StreamingChunk with tool_calls."""
172-
component = ExampleComponent()
173-
component_info = ComponentInfo.from_component(component)
192+
component_info = ComponentInfo.from_component(ExampleComponent())
174193
tool_calls = [
175194
ToolCallDelta(id="123", tool_name="test_tool", arguments='{"arg1": "value1"}', index=0),
176195
ToolCallDelta(id="456", tool_name="another_tool", arguments='{"arg2": "value2"}', index=1),
@@ -197,6 +216,34 @@ def test_to_dict_tool_calls():
197216
assert d["tool_calls"][1]["id"] == "456"
198217
assert d["tool_calls"][1]["index"] == 1
199218
assert d["finish_reason"] == "tool_calls"
219+
assert d["reasoning"] is None
220+
221+
222+
def test_to_dict_reasoning():
223+
"""Test the to_dict method for StreamingChunk with reasoning."""
224+
component_info = ComponentInfo.from_component(ExampleComponent())
225+
reasoning = ReasoningContent(reasoning_text="thinking", extra={"step": 1})
226+
227+
chunk = StreamingChunk(
228+
content="",
229+
meta={"key": "value"},
230+
index=0,
231+
component_info=component_info,
232+
reasoning=reasoning,
233+
finish_reason="stop",
234+
)
235+
236+
d = chunk.to_dict()
237+
238+
assert d["content"] == ""
239+
assert d["meta"] == {"key": "value"}
240+
assert d["index"] == 0
241+
assert d["component_info"]["type"] == "test_streaming_chunk.ExampleComponent"
242+
assert d["reasoning"]["reasoning_text"] == "thinking"
243+
assert d["reasoning"]["extra"]["step"] == 1
244+
assert d["finish_reason"] == "stop"
245+
assert d["tool_calls"] is None
246+
assert d["tool_call_result"] is None
200247

201248

202249
def test_from_dict_tool_call_result():
@@ -227,6 +274,7 @@ def test_from_dict_tool_call_result():
227274
assert chunk.tool_call_result.result == "output"
228275
assert chunk.tool_call_result.error is False
229276
assert chunk.tool_call_result.origin.id == "123"
277+
assert chunk.reasoning is None
230278

231279

232280
def test_from_dict_tool_calls():
@@ -253,3 +301,32 @@ def test_from_dict_tool_calls():
253301
assert chunk.tool_calls[0].tool_name == "test_tool"
254302
assert chunk.tool_calls[0].index == 0
255303
assert chunk.finish_reason == "tool_calls"
304+
assert chunk.reasoning is None
305+
306+
307+
def test_from_dict_reasoning():
308+
"""Test the from_dict method for StreamingChunk with reasoning."""
309+
component_info = {"type": "test_streaming_chunk.ExampleComponent", "name": "test_component"}
310+
reasoning = {"reasoning_text": "thinking", "extra": {"step": 1}}
311+
312+
data = {
313+
"content": "",
314+
"meta": {"key": "value"},
315+
"index": 0,
316+
"component_info": component_info,
317+
"reasoning": reasoning,
318+
"finish_reason": "stop",
319+
}
320+
321+
chunk = StreamingChunk.from_dict(data)
322+
323+
assert chunk.content == ""
324+
assert chunk.meta == {"key": "value"}
325+
assert chunk.index == 0
326+
assert chunk.component_info.type == "test_streaming_chunk.ExampleComponent"
327+
assert chunk.component_info.name == "test_component"
328+
assert chunk.reasoning.reasoning_text == "thinking"
329+
assert chunk.reasoning.extra["step"] == 1
330+
assert chunk.finish_reason == "stop"
331+
assert chunk.tool_calls is None
332+
assert chunk.tool_call_result is None

0 commit comments

Comments
 (0)