Skip to content

Commit 4e67d8c

Browse files
authored
Add Stream.replay_message for id-preserving message replaying (#185)
This allows Stream to preserve the message id. I made the old thing, replay_message_events, private. I might move it into Stream also in a follow-up but didn't want to make this one too noisy.
1 parent 63ad8c5 commit 4e67d8c

5 files changed

Lines changed: 141 additions & 38 deletions

File tree

examples/temporal-direct/main.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,10 @@ async def loop(
153153

154154
# 2. Wrap the complete message in a synthetic stream so we can
155155
# drive the rest of the loop with ToolRunner — same shape as
156-
# the default loop. ``replay_message_events`` is the framework
157-
# helper that decomposes a complete ``Message`` back into the
158-
# events a streaming adapter would have produced.
156+
# the default loop. ``Stream.replay_message`` replays events
157+
# from llm_msg.
159158
async with (
160-
ai.Stream(ai.events.replay_message_events(llm_msg)) as stream,
159+
ai.Stream.replay_message(llm_msg) as stream,
161160
ai.ToolRunner() as tr,
162161
):
163162
async for event in ai.util.merge(stream, tr.events()):

src/ai/models/core/api.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,53 @@ def __init__(
135135
# rather than look like a normal end of turn.
136136
self._ended = False
137137

138+
@classmethod
139+
def replay_message(
140+
cls,
141+
message: types.messages.Message,
142+
*,
143+
output_type: type[StreamOutputT] | None = None,
144+
) -> Stream[StreamOutputT]:
145+
"""Synthesize stream events for ``msg``.
146+
147+
Use when you have a complete ``Message`` from a non-streaming source —
148+
e.g., the result of a Temporal activity, a cached LLM response, or an
149+
offline test fixture — and want to feed it through code that consumes
150+
an async event stream (``ai.Stream``, ``ai.ToolRunner``, custom loops
151+
that mirror the default loop's shape, etc.)::
152+
153+
async with ai.Stream.replay_message(msg) as stream:
154+
async with ai.ToolRunner() as tr:
155+
async for event in ai.util.merge(stream, tr.events()):
156+
...
157+
158+
Each part is emitted as the start/delta/end triple a streaming adapter
159+
would have produced, in part order, bracketed by ``StreamStart`` and
160+
``StreamEnd``. The full body of text/reasoning/tool-args is sent as a
161+
single delta — the granularity of the model's original chunking is
162+
not recoverable from a complete message.
163+
164+
Each part's ``provider_metadata`` (and the message's) rides on its end
165+
event, mirroring the real adapters, so a rebuilt turn keeps it --
166+
reasoning signatures included, which must survive to replay the turn
167+
back to the provider.
168+
169+
Parts with no model-layer event analog — ``ToolResultPart``,
170+
``HookPart`` — are skipped silently; they are agent-layer concerns
171+
and never appear on the model stream.
172+
173+
``stream.message`` keeps ``message``'s id; the parts are rebuilt
174+
from the stream.
175+
"""
176+
seed = types.messages.Message(
177+
id=message.id, role=message.role, parts=[]
178+
)
179+
return cls(
180+
types.events._replay_message_events(message),
181+
seed_message=seed,
182+
output_type=output_type,
183+
)
184+
138185
async def aclose(self) -> None:
139186
await self._gen.aclose()
140187

src/ai/types/events.py

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -183,47 +183,24 @@ class HookResolution(BaseEvent):
183183
]
184184

185185

186-
async def replay_message_events(
186+
async def _replay_message_events(
187187
msg: messages.Message,
188188
) -> AsyncGenerator[Event]:
189-
"""Synthesize stream events for ``msg``.
190-
191-
Use when you have a complete ``Message`` from a non-streaming source —
192-
e.g., the result of a Temporal activity, a cached LLM response, or an
193-
offline test fixture — and want to feed it through code that consumes
194-
an async event stream (``ai.Stream``, ``ai.ToolRunner``, custom loops
195-
that mirror the default loop's shape, etc.)::
196-
197-
async with ai.Stream(ai.events.replay_message_events(msg)) as stream:
198-
async with ai.ToolRunner() as tr:
199-
async for event in ai.util.merge(stream, tr.events()):
200-
...
201-
202-
Each part is emitted as the start/delta/end triple a streaming adapter
203-
would have produced, in part order, bracketed by ``StreamStart`` and
204-
``StreamEnd``. The full body of text/reasoning/tool-args is sent as a
205-
single delta — the granularity of the model's original chunking is
206-
not recoverable from a complete message.
207-
208-
Parts with no model-layer event analog — ``ToolResultPart``,
209-
``HookPart`` — are skipped silently; they are agent-layer concerns
210-
and never appear on the model stream.
211-
"""
189+
"""Synthesize stream events for ``msg``."""
190+
# See Stream.replay_message
212191
yield StreamStart()
213192
for part in msg.parts:
214193
if isinstance(part, messages.TextPart):
215194
yield TextStart(block_id=part.id)
216195
if part.text:
217196
yield TextDelta(block_id=part.id, chunk=part.text)
218-
yield TextEnd(block_id=part.id)
197+
yield TextEnd(
198+
block_id=part.id, provider_metadata=part.provider_metadata
199+
)
219200
elif isinstance(part, messages.ReasoningPart):
220201
yield ReasoningStart(block_id=part.id)
221202
if part.text:
222203
yield ReasoningDelta(block_id=part.id, chunk=part.text)
223-
# Carry the signature (and any other reasoning metadata) on the
224-
# end event, mirroring how the real adapters emit it -- otherwise
225-
# a replayed-then-rebuilt turn loses its signature and can't be
226-
# replayed to the provider.
227204
yield ReasoningEnd(
228205
block_id=part.id,
229206
provider_metadata=part.provider_metadata,
@@ -238,7 +215,11 @@ async def replay_message_events(
238215
tool_call_id=part.tool_call_id,
239216
chunk=part.tool_args,
240217
)
241-
yield ToolEnd(tool_call_id=part.tool_call_id, tool_call=part)
218+
yield ToolEnd(
219+
tool_call_id=part.tool_call_id,
220+
tool_call=part,
221+
provider_metadata=part.provider_metadata,
222+
)
242223
elif isinstance(part, messages.BuiltinToolCallPart):
243224
yield BuiltinToolStart(
244225
tool_call_id=part.tool_call_id,
@@ -249,7 +230,11 @@ async def replay_message_events(
249230
tool_call_id=part.tool_call_id,
250231
chunk=part.tool_args,
251232
)
252-
yield BuiltinToolEnd(tool_call_id=part.tool_call_id, tool_call=part)
233+
yield BuiltinToolEnd(
234+
tool_call_id=part.tool_call_id,
235+
tool_call=part,
236+
provider_metadata=part.provider_metadata,
237+
)
253238
elif isinstance(part, messages.BuiltinToolReturnPart):
254239
yield BuiltinToolResult(tool_call_id=part.tool_call_id, result=part)
255240
elif isinstance(part, messages.FilePart):
@@ -258,8 +243,9 @@ async def replay_message_events(
258243
data=part.data,
259244
media_type=part.media_type,
260245
filename=part.filename,
246+
provider_metadata=part.provider_metadata,
261247
)
262-
yield StreamEnd()
248+
yield StreamEnd(provider_metadata=msg.provider_metadata)
263249

264250

265251
# ---------------------------------------------------------------------------

tests/models/core/test_api.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,43 @@ async def _spy_stream(
464464
assert not any(isinstance(e, events_.TextDelta) for e in events)
465465

466466

467+
async def test_replay_message_preserves_id() -> None:
468+
"""``Stream.replay_message`` keeps the message id and rebuilds parts."""
469+
msg = messages_.Message(
470+
role="assistant",
471+
parts=[
472+
messages_.TextPart(id="t1", text="calling tools"),
473+
messages_.ToolCallPart(
474+
tool_call_id="tc-1",
475+
tool_name="weather",
476+
tool_args='{"city":"SF"}',
477+
),
478+
],
479+
)
480+
481+
async with ai.Stream.replay_message(msg) as stream:
482+
events: list[events_.Event] = [event async for event in stream]
483+
484+
# The rebuilt message keeps the original id (a fresh Message, not the
485+
# original object) and the parts are reconstructed from the events --
486+
# no duplication, same part ids.
487+
assert stream.message is not msg
488+
assert stream.message.id == msg.id
489+
assert len(stream.message.parts) == 2
490+
assert stream.text == "calling tools"
491+
assert [tc.tool_call_id for tc in stream.tool_calls] == ["tc-1"]
492+
493+
# The full event set is re-emitted (and is visible to consumers --
494+
# nothing is flagged ``replay``), enough to drive a tool runner.
495+
assert events
496+
assert not any(e.replay for e in events)
497+
tool_ends = [e for e in events if isinstance(e, events_.ToolEnd)]
498+
assert len(tool_ends) == 1
499+
assert tool_ends[0].tool_call.tool_call_id == "tc-1"
500+
assert any(isinstance(e, events_.TextDelta) for e in events)
501+
assert any(isinstance(e, events_.ToolStart) for e in events)
502+
503+
467504
def test_tool_end_replay_flag_excluded_from_json() -> None:
468505
"""The replay flag is internal — it must not appear in serialized output."""
469506
ev = events_.ToolEnd(

tests/types/test_events.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async def test_reasoning_signature_survives_replay(self) -> None:
2323
)
2424

2525
async with models.Stream(
26-
events.replay_message_events(original)
26+
events._replay_message_events(original)
2727
) as stream:
2828
async for _ in stream:
2929
pass
@@ -54,10 +54,44 @@ async def test_reasoning_signature_on_end_event(self) -> None:
5454

5555
reasoning_ends = [
5656
e
57-
async for e in events.replay_message_events(msg)
57+
async for e in events._replay_message_events(msg)
5858
if isinstance(e, events.ReasoningEnd)
5959
]
6060
assert len(reasoning_ends) == 1
6161
assert reasoning_ends[0].provider_metadata == {
6262
"anthropic": {"signature": "sig"}
6363
}
64+
65+
async def test_provider_metadata_survives_replay(self) -> None:
66+
"""provider_metadata on every part, and the message itself, round-
67+
trips through the aggregator -- not just reasoning signatures."""
68+
original = messages.Message(
69+
role="assistant",
70+
parts=[
71+
messages.TextPart(text="hi", provider_metadata={"p": {"t": 1}}),
72+
messages.ToolCallPart(
73+
tool_call_id="tc-1",
74+
tool_name="weather",
75+
tool_args="{}",
76+
provider_metadata={"p": {"tc": 2}},
77+
),
78+
],
79+
provider_metadata={"p": {"msg": 3}},
80+
)
81+
82+
async with models.Stream(
83+
events._replay_message_events(original)
84+
) as stream:
85+
async for _ in stream:
86+
pass
87+
88+
rebuilt = stream.message
89+
assert rebuilt.provider_metadata == {"p": {"msg": 3}}
90+
text = next(
91+
p for p in rebuilt.parts if isinstance(p, messages.TextPart)
92+
)
93+
assert text.provider_metadata == {"p": {"t": 1}}
94+
tool = next(
95+
p for p in rebuilt.parts if isinstance(p, messages.ToolCallPart)
96+
)
97+
assert tool.provider_metadata == {"p": {"tc": 2}}

0 commit comments

Comments
 (0)