Skip to content

Commit e9ad280

Browse files
committed
Support overriding randomness source for ids
Make message id generation use Random and add use_random and use_random_async @contextmanagers for oerriding it. The main goal here is to support durable execution frameworks where we need to be able to provide a deterministic randomness source.
1 parent 63ad8c5 commit e9ad280

5 files changed

Lines changed: 350 additions & 496 deletions

File tree

examples/temporal-direct/main.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
import dataclasses
3030
import datetime
3131
import json
32+
import os
3233
import sys
34+
import threading
3335
import uuid
3436
from typing import TYPE_CHECKING, Any, ClassVar
3537

@@ -42,9 +44,14 @@
4244
if TYPE_CHECKING:
4345
from collections.abc import AsyncGenerator
4446

47+
# ai itself is safe to import in a workflow... but its transitive deps
48+
# httpx and modelsdotdev are *not*
4549
with temporalio.workflow.unsafe.imports_passed_through():
46-
import ai
50+
import httpx # noqa: F401
51+
import modelsdotdev # noqa: F401
52+
import pydantic_core # noqa: F401
4753

54+
import ai
4855

4956
MODEL_ID = "gateway:anthropic/claude-sonnet-4.6"
5057

@@ -94,6 +101,28 @@ async def get_population_activity(city: str) -> int:
94101
}
95102

96103

104+
# ── Stream-event sink ────────────────────────────────────────────
105+
#
106+
# The durable result of the workflow is the final list of Messages.
107+
# Live token-by-token streaming is a separate, ephemeral concern: the
108+
# LLM activity emits every model-stream event to an out-of-band sink
109+
# as it drains the stream. The sink is a plain file named in
110+
# DURABILITY_EVENT_LOG (/dev/stdout is a good choice for observing);
111+
# each line is one event serialized as JSON
112+
# (``event.model_dump_json()``), so a consumer can rebuild the event.
113+
114+
_EVENT_LOG_ENV = "DURABILITY_EVENT_LOG"
115+
_event_lock = threading.Lock()
116+
117+
118+
def _emit_event(event: ai.events.Event) -> None:
119+
path = os.environ.get(_EVENT_LOG_ENV)
120+
if not path:
121+
return
122+
with _event_lock, open(path, "a") as f:
123+
f.write(event.model_dump_json() + "\n")
124+
125+
97126
@dataclasses.dataclass
98127
class LLMParams:
99128
model_id: str
@@ -114,8 +143,8 @@ async def llm_call_activity(params: LLMParams) -> LLMResult:
114143
tools = [ai.Tool.model_validate(t) for t in params.tool_schemas]
115144

116145
async with ai.stream(model, messages, tools=tools) as s:
117-
async for _event in s:
118-
pass
146+
async for event in s:
147+
_emit_event(event)
119148
if s.message is None:
120149
raise RuntimeError("LLM stream ended without a final message")
121150
return LLMResult(message=s.message.model_dump())
@@ -206,8 +235,15 @@ async def _call() -> ai.events.ToolCallResult:
206235

207236
@temporalio.workflow.defn
208237
class WeatherWorkflow:
238+
def __init__(self) -> None:
239+
self._messages: list[ai.messages.Message] = []
240+
241+
# Draw message/part ids from the workflow's deterministic RNG so they
242+
# are stable across replay. ``workflow.random`` is passed as a factory
243+
# (it's only valid inside the workflow) and resolved on each call.
209244
@temporalio.workflow.run
210-
async def run(self, user_query: str) -> str:
245+
@ai.messages.use_random_async(temporalio.workflow.random)
246+
async def run(self, user_query: str) -> list[dict[str, Any]]:
211247
model = ai.get_model(MODEL_ID)
212248
messages: list[ai.messages.Message] = [
213249
ai.system_message(
@@ -216,12 +252,16 @@ async def run(self, user_query: str) -> str:
216252
ai.user_message(user_query),
217253
]
218254

219-
final_text = ""
220255
async with weather_agent.run(model, messages) as stream:
221-
async for event in stream:
222-
if isinstance(event, ai.events.StreamEnd):
223-
final_text = event.message.text
224-
return final_text
256+
async for _event in stream:
257+
pass
258+
self._messages = stream.messages
259+
return [m.model_dump() for m in self._messages]
260+
261+
@temporalio.workflow.query
262+
def messages(self) -> list[dict[str, Any]]:
263+
"""The final messages, as rebuilt by this replay."""
264+
return [m.model_dump() for m in self._messages]
225265

226266

227267
# ── Entry point ──────────────────────────────────────────────────
@@ -246,13 +286,14 @@ async def main(user_query: str) -> None:
246286
print(f"Workflow: {workflow_id}")
247287
print(f"Query: {user_query}\n")
248288

249-
result = await client.execute_workflow(
289+
messages = await client.execute_workflow(
250290
WeatherWorkflow.run,
251291
user_query,
252292
id=workflow_id,
253293
task_queue=TASK_QUEUE,
254294
)
255-
print(result)
295+
final = ai.messages.Message.model_validate(messages[-1])
296+
print(f"\nResult: {final.text}")
256297

257298

258299
if __name__ == "__main__":

examples/temporal-direct/test_durability.py

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@
4848
from typing import Any
4949

5050
import main as ex
51+
import pydantic
5152
import temporalio.client
5253
import temporalio.testing
5354
import temporalio.worker
5455
from _durability_worker import LOGGED_ACTIVITIES # noqa: PLC2701
5556

57+
import ai
58+
5659
# ── Helpers ──────────────────────────────────────────────────────
5760

5861

@@ -71,6 +74,22 @@ def read_activity_log(log_file: pathlib.Path) -> Counter[str]:
7174
return Counter(log_file.read_text().splitlines())
7275

7376

77+
_EVENT_ADAPTER: pydantic.TypeAdapter[ai.events.Event] = pydantic.TypeAdapter(
78+
ai.events.DiscriminatedEvent
79+
)
80+
81+
82+
def read_streamed_events(event_log: pathlib.Path) -> list[ai.events.Event]:
83+
"""Deserialize the events the LLM activity wrote to the streaming sink."""
84+
if not event_log.exists():
85+
return []
86+
return [
87+
_EVENT_ADAPTER.validate_json(line)
88+
for line in event_log.read_text().splitlines()
89+
if line
90+
]
91+
92+
7493
QUERY = "What's the weather and population of New York and Los Angeles?"
7594

7695

@@ -92,11 +111,12 @@ async def test_happy_path(
92111
task_queue=ex.TASK_QUEUE,
93112
)
94113

95-
assert result, "expected non-empty text"
114+
assert result, "expected a non-empty conversation"
115+
final_text = ai.messages.Message.model_validate(result[-1]).text
96116
assert (
97-
"8,336,817" in result or "8336817" in result
98-
), f"expected NYC population in result, got: {result!r}"
99-
print(f" ✓ workflow {wid} produced {len(result)} chars")
117+
"8,336,817" in final_text or "8336817" in final_text
118+
), f"expected NYC population in result, got: {final_text!r}"
119+
print(f" ✓ workflow {wid} produced {len(result)} messages")
100120
print(f" ✓ activity calls: {dict(read_activity_log(log_file))}")
101121
return wid
102122

@@ -118,6 +138,80 @@ async def test_replay_determinism(
118138
print(f" ✓ replay clean for {workflow_id} ({len(history.events)} events)")
119139

120140

141+
# ── Test: workflow-minted ids are stable across replay ───────────
142+
143+
144+
async def test_workflow_minted_ids_are_deterministic(
145+
client: temporalio.client.Client, workflow_id: str
146+
) -> None:
147+
print("\n── test_workflow_minted_ids_are_deterministic ─────")
148+
149+
def ids(messages: list[dict[str, Any]]) -> list[str]:
150+
return [
151+
id_
152+
for m in messages
153+
for id_ in (m["id"], *(p["id"] for p in m["parts"]))
154+
]
155+
156+
handle = client.get_workflow_handle(workflow_id)
157+
original = ids(await handle.result())
158+
159+
# Querying the closed workflow forces a worker to replay the history
160+
# and re-run ``run`` to answer -- so these messages are rebuilt by the
161+
# replay, not the original execution.
162+
async with make_worker(client):
163+
replayed = ids(await handle.query(ex.WeatherWorkflow.messages))
164+
165+
# Any id generated in workflow code (system/user/tool messages and the
166+
# tool-result parts inside them) would be re-minted on replay and
167+
# diverge here. Ids sourced from a cached activity result survive
168+
# identically regardless.
169+
assert original == replayed, (
170+
"workflow re-minted ids on replay -- nondeterministic id "
171+
"generation in workflow code:\n"
172+
f" original: {original}\n"
173+
f" replayed: {replayed}"
174+
)
175+
print(f" ✓ all {len(original)} message/part ids stable across replay")
176+
177+
178+
# ── Test: streamed ids match the final returned messages ─────────
179+
180+
181+
async def test_stream_ids_match_final_messages(
182+
client: temporalio.client.Client, event_log: pathlib.Path
183+
) -> None:
184+
print("\n── test_stream_ids_match_final_messages ───────────")
185+
event_log.write_text("")
186+
187+
async with make_worker(client):
188+
wid = f"ids-{uuid.uuid4().hex[:8]}"
189+
messages = await client.execute_workflow(
190+
ex.WeatherWorkflow.run,
191+
QUERY,
192+
id=wid,
193+
task_queue=ex.TASK_QUEUE,
194+
)
195+
196+
streamed = read_streamed_events(event_log)
197+
streamed_ids = {e.message.id for e in streamed}
198+
final_ids = {m["id"] for m in messages}
199+
assert streamed, "no events reached the streaming sink"
200+
201+
# Every assistant turn streamed to the sink must be identifiable in
202+
# the durable result by the same message id. Today the workflow's
203+
# Stream re-mints the id when it reassembles the message, so the id a
204+
# client saw streaming live is absent from the final messages.
205+
missing = streamed_ids - final_ids
206+
assert not missing, (
207+
f"message ids streamed to the sink are absent from the final "
208+
f"returned messages: {sorted(missing)}\n"
209+
f" streamed: {sorted(streamed_ids)}\n"
210+
f" final: {sorted(final_ids)}"
211+
)
212+
print(f" ✓ all {len(streamed_ids)} streamed id(s) present in final")
213+
214+
121215
# ── Test 3: activity caching across a worker restart ─────────────
122216

123217

@@ -234,11 +328,15 @@ def _unraisablehook(unraisable: Any) -> None:
234328

235329

236330
async def main() -> None:
237-
log_file = pathlib.Path(tempfile.mkdtemp()) / "activity_log.txt"
331+
tmp = pathlib.Path(tempfile.mkdtemp())
332+
log_file = tmp / "activity_log.txt"
333+
event_log = tmp / "event_log.txt"
238334
# In-process workers (worker2 below, plus the happy-path worker)
239335
# share this module's ``LOGGED_ACTIVITIES``, which look at this env
240336
# var to decide whether to log. Set it for the whole run.
241337
os.environ["DURABILITY_ACTIVITY_LOG"] = str(log_file)
338+
# The LLM activity emits every stream event to this sink.
339+
os.environ[ex._EVENT_LOG_ENV] = str(event_log)
242340

243341
print("Starting embedded Temporal dev server...")
244342
async with (
@@ -248,6 +346,8 @@ async def main() -> None:
248346

249347
wid = await test_happy_path(client, log_file)
250348
await test_replay_determinism(client, wid)
349+
await test_workflow_minted_ids_are_deterministic(client, wid)
350+
await test_stream_ids_match_final_messages(client, event_log)
251351
await test_activity_caching(env, client, log_file)
252352

253353
print("\nAll durability checks passed.")

0 commit comments

Comments
 (0)