Skip to content

Commit b64ebb5

Browse files
committed
Added the Stream examples and the end-to-end Redis tests.
1 parent ee23c91 commit b64ebb5

13 files changed

Lines changed: 1418 additions & 2 deletions

File tree

‎.github/CODEOWNERS‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,6 @@
1212
# manage repo-wide concerns.
1313
/temporalio/contrib/workflow_streams/ @temporalio/ai-sdk @temporalio/sdk
1414
/tests/contrib/workflow_streams/ @temporalio/ai-sdk @temporalio/sdk
15+
/temporalio/streams/ @temporalio/ai-sdk @temporalio/sdk
16+
/temporalio/contrib/stream/ @temporalio/ai-sdk @temporalio/sdk
17+
/tests/contrib/streams/ @temporalio/ai-sdk @temporalio/sdk
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Add experimental Streams. A Stream is an ordered log of Records that Workflows, Activities and clients write, and that clients and Activities read. `temporalio.streams` has the shared types, such as the `StreamRef` data, `temporalio.workflow.WorkflowStream` makes the calls in Workflow code, and `temporalio.client.Stream` makes them elsewhere. Register a backend with `stream_backends=` on `Client.connect`, `Replayer` or `WorkflowEnvironment`, then use `workflow.create_stream[T]`, `Client.create_stream[T]` or `Client.get_stream[T]`, and `to_stream` for a ref that came as data.
2+
Add a Redis Stream backend in `temporalio.contrib.stream`, and an in-memory one for tests in `temporalio.contrib.stream.testing`. The Redis backend needs a build with the bridge's `streams` feature, which the official builds have.

‎temporalio/streams/__init__.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,34 @@
11
"""The Streams API, which Workflows, Activities and clients share.
22
3+
A Stream is an ordered log of Records that Workflows, Activities and clients write, and that
4+
clients and Activities read. A Stream has one payload type, and a call takes it as a type
5+
argument, as in ``client.get_stream[Turn]("chat-42")``. With no type argument, the payload type
6+
is ``Any``, and a read gives JSON values.
7+
8+
Code creates a Stream before its first publish. :py:data:`temporalio.workflow.create_stream` and
9+
:py:attr:`temporalio.client.Client.create_stream` fail with :py:class:`StreamAlreadyExistsError` if
10+
the id exists, and ``create_or_get_stream`` gets the Stream instead. In Workflow code, each run of a
11+
chain sends the same create for an id, so a retry run or a cron run gets the Stream that an earlier
12+
run made. Workflow code gets a :py:class:`temporalio.workflow.WorkflowStream`, and other code a
13+
:py:class:`temporalio.client.Stream`. A client gets any Stream by its id with
14+
:py:attr:`temporalio.client.Client.get_stream`, with no call.
15+
16+
A Stream's :py:class:`StreamRef` is data with no calls. Code passes it as an argument to a
17+
Workflow, an Activity, a child Workflow or the next run, or hands it out through a query.
18+
:py:func:`temporalio.workflow.to_stream` and :py:meth:`temporalio.client.Client.to_stream` give
19+
the Stream of a ref. In an Activity, a Stream from ``activity.client()`` writes as the attempt.
20+
21+
A read gives :py:class:`Record` and :py:class:`Superseded` items, and the ``kind`` of each item
22+
narrows its type. A close carries no value. A read of a closed Stream ends once it gives the last
23+
item.
24+
25+
A :py:class:`StreamBackend` makes the Stream calls and registers a backend name. Examples are
26+
:py:class:`temporalio.contrib.stream.RedisStreamBackend` (``redis``) and
27+
:py:class:`temporalio.contrib.stream.testing.MemoryStreamBackend` (``memory``). Register
28+
backends with ``Client.connect(..., stream_backends=[...])``. A ref keeps the name of its
29+
backend. With more than one registered backend, exactly one must have ``default=True``. A create
30+
with no ``backend=`` uses that backend. A Worker serves only one backend.
31+
332
.. warning::
433
The Streams API is experimental.
534
"""
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Examples for the Streams API proposal
2+
3+
Each file is a complete program for one section of the proposal. `test_examples.py` runs each one
4+
end to end on the memory Store and on Redis.
5+
6+
| Proposal section | File |
7+
|---|---|
8+
| A UI backend reads a Stream by its id | [`ui_backend_reads.py`](ui_backend_reads.py) |
9+
| A UI backend reads a Stream by its id, its resume notes | [`resume_from_offset.py`](resume_from_offset.py) |
10+
| A client creates a Stream and passes it to a Workflow | [`client_creates_stream.py`](client_creates_stream.py) |
11+
| An agent Workflow publishes its turns | [`agent_publishes_turns.py`](agent_publishes_turns.py) |
12+
| An Activity streams tokens to the Workflow's Stream | [`activity_streams_tokens.py`](activity_streams_tokens.py) |
13+
| Worker setup | [`worker_setup.py`](worker_setup.py) |
14+
15+
The proposal's Nexus examples aren't in this directory.

‎tests/contrib/streams/examples/__init__.py‎

Whitespace-only changes.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Streams API proposal, "An Activity streams tokens to the Workflow's Stream".
2+
3+
The Workflow passes its Stream's ref to the Activity as a field of the input. An async Activity
4+
gets the Stream from ``activity.client()``, so it writes as the attempt, and batches the model's
5+
deltas with ``BufferedPublisher``. A sync Activity has no ``activity.client()``, so it keeps the
6+
Worker's client. It wraps its Stream in ``SyncStream``, whose calls block its thread, so it works
7+
in a thread pool executor.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import concurrent.futures
13+
import uuid
14+
from collections.abc import AsyncIterator
15+
from dataclasses import dataclass
16+
from datetime import timedelta
17+
18+
from temporalio import activity, streams, workflow
19+
from temporalio.client import BufferedPublisher, Client, SyncStream
20+
from temporalio.worker import Worker
21+
22+
Message = str
23+
24+
25+
@dataclass
26+
class Reply:
27+
text: str
28+
29+
30+
class FakeResponse:
31+
"""A model's streamed response, one delta for each word."""
32+
33+
def __init__(self, messages: list[Message]) -> None:
34+
self.words = messages[-1].split()
35+
36+
async def __aiter__(self) -> AsyncIterator[str]:
37+
for word in self.words:
38+
yield word
39+
40+
def reply(self) -> Reply:
41+
return Reply(" ".join(self.words).upper())
42+
43+
44+
class FakeLLM:
45+
"""Stands in for a model client."""
46+
47+
def stream(self, messages: list[Message]) -> FakeResponse:
48+
return FakeResponse(messages)
49+
50+
51+
llm = FakeLLM()
52+
53+
54+
@dataclass
55+
class CallModelInput:
56+
messages: list[Message]
57+
tokens: streams.StreamRef[str] # the ref that the Workflow passes
58+
59+
60+
@dataclass
61+
class SummarizeInput:
62+
text: str
63+
tokens: streams.StreamRef[str]
64+
65+
66+
@activity.defn
67+
async def call_model(input: CallModelInput) -> Reply:
68+
stream = activity.client().to_stream(input.tokens) # writes as this attempt
69+
response = llm.stream(input.messages)
70+
async with BufferedPublisher(stream) as tokens: # batches deltas
71+
async for delta in response:
72+
tokens.publish(delta)
73+
return response.reply()
74+
75+
76+
class Summarizer:
77+
def __init__(self, client: Client) -> None:
78+
self._client = client # the Worker's client
79+
80+
@activity.defn
81+
def summarize(self, input: SummarizeInput) -> int:
82+
tokens = SyncStream(self._client.to_stream(input.tokens))
83+
# The sync publish blocks until the Record is written, and returns its offset.
84+
return tokens.publish(f"summary: {input.text}")
85+
86+
87+
@workflow.defn
88+
class Model:
89+
def __init__(self) -> None:
90+
self._tokens: workflow.WorkflowStream[str] | None = None
91+
92+
@workflow.query
93+
def stream(self) -> streams.StreamRef[str] | None:
94+
return self._tokens.ref if self._tokens else None
95+
96+
@workflow.run
97+
async def run(self, prompt: str) -> int:
98+
self._tokens = tokens = await workflow.create_stream[str](
99+
f"{workflow.info().workflow_id}-tokens"
100+
)
101+
reply = await workflow.execute_activity(
102+
call_model,
103+
CallModelInput([prompt], tokens.ref),
104+
start_to_close_timeout=timedelta(minutes=5),
105+
)
106+
summary = await workflow.execute_activity_method(
107+
Summarizer.summarize,
108+
SummarizeInput(reply.text, tokens.ref),
109+
start_to_close_timeout=timedelta(minutes=5),
110+
)
111+
await tokens.close()
112+
return summary
113+
114+
115+
async def main(
116+
client: Client, task_queue: str
117+
) -> tuple[int, list[streams.Record[str]]]:
118+
with concurrent.futures.ThreadPoolExecutor() as executor:
119+
async with Worker(
120+
client,
121+
task_queue=task_queue,
122+
workflows=[Model],
123+
activities=[call_model, Summarizer(client).summarize],
124+
activity_executor=executor,
125+
):
126+
handle = await client.start_workflow(
127+
Model.run,
128+
"one two three",
129+
id=f"model-{uuid.uuid4()}",
130+
task_queue=task_queue,
131+
)
132+
summary = await handle.result()
133+
ref = await handle.query(Model.stream)
134+
assert ref is not None
135+
stream = client.to_stream(ref)
136+
records = [r async for r in stream.subscribe() if isinstance(r, streams.Record)]
137+
return summary, records
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Streams API proposal, "An agent Workflow publishes its turns".
2+
3+
The Workflow creates its two Streams, turns and token deltas, under ids of its choice, and hands
4+
out the turns Stream's ref through a ``stream`` query. It publishes each turn with an awaited
5+
``publish``, and passes the tokens ref to the model Activity in its one input object. A run
6+
after continue-as-new gets the earlier run's refs and creates nothing. It closes both Streams
7+
before it returns, since a run's end doesn't close a Stream.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import uuid
13+
from dataclasses import dataclass, field
14+
from datetime import timedelta
15+
16+
from temporalio import activity, streams, workflow
17+
from temporalio.client import BufferedPublisher, Client
18+
from temporalio.worker import Worker
19+
20+
MINUTE = timedelta(minutes=1)
21+
22+
Message = str
23+
24+
25+
@dataclass
26+
class Turn:
27+
kind: str
28+
text: str | None = None
29+
30+
31+
@dataclass
32+
class Reply:
33+
text: str
34+
tool_calls: list[str] = field(default_factory=list)
35+
36+
37+
@dataclass
38+
class CallModelInput:
39+
messages: list[Message]
40+
tokens: streams.StreamRef[str] # the ref that the Workflow passes
41+
42+
43+
@activity.defn
44+
async def call_model(input: CallModelInput) -> Reply:
45+
stream = activity.client().to_stream(input.tokens) # writes as this attempt
46+
async with BufferedPublisher(stream) as tokens: # batches deltas
47+
for delta in input.messages[-1].split():
48+
tokens.publish(delta)
49+
if len(input.messages) == 1:
50+
return Reply("", tool_calls=["search"])
51+
return Reply("the answer")
52+
53+
54+
@activity.defn
55+
async def run_tool(call: str) -> Message:
56+
return f"{call} result"
57+
58+
59+
@dataclass
60+
class AgentInput:
61+
session_id: str
62+
messages: list[Message]
63+
# The run before Continue-as-New sets the refs.
64+
turns: streams.StreamRef[Turn] | None = None
65+
tokens: streams.StreamRef[str] | None = None
66+
67+
68+
@workflow.defn
69+
class Agent:
70+
def __init__(self) -> None:
71+
self._turns: workflow.WorkflowStream[Turn] | None = None # run creates it
72+
73+
@workflow.query
74+
def stream(self) -> streams.StreamRef[Turn] | None: # one way to hand it out
75+
return self._turns.ref if self._turns else None # None before the create
76+
77+
@workflow.run
78+
async def run(self, input: AgentInput) -> str:
79+
messages = input.messages
80+
# A Workflow creates only standalone Streams, under an id of its choice. A run after
81+
# Continue-as-New gets the earlier run's refs and creates nothing.
82+
if input.turns and input.tokens:
83+
self._turns = turns = workflow.to_stream(input.turns)
84+
tokens = workflow.to_stream(input.tokens)
85+
else:
86+
self._turns = turns = await workflow.create_stream[Turn](
87+
f"{input.session_id}-turns"
88+
) # the default Retention
89+
# Token deltas matter only to live readers, so they get a short Retention.
90+
tokens = await workflow.create_stream[str](
91+
f"{input.session_id}-tokens", retention=timedelta(hours=1)
92+
)
93+
while True:
94+
if workflow.info().is_continue_as_new_suggested():
95+
workflow.continue_as_new( # forward the refs, so the next run keeps the Streams
96+
AgentInput(input.session_id, messages, turns.ref, tokens.ref)
97+
)
98+
await turns.publish(Turn("model")) # one Local Activity
99+
reply = await workflow.execute_activity(
100+
call_model,
101+
CallModelInput(messages, tokens.ref),
102+
start_to_close_timeout=MINUTE,
103+
)
104+
if not reply.tool_calls:
105+
await turns.publish(Turn("done", reply.text))
106+
await turns.close() # the done Turn carries the reply
107+
await tokens.close()
108+
return reply.text
109+
for call in reply.tool_calls:
110+
messages.append(
111+
await workflow.execute_activity(
112+
run_tool, call, start_to_close_timeout=MINUTE
113+
)
114+
)
115+
116+
117+
async def main(client: Client, task_queue: str) -> list[streams.Record[Turn]]:
118+
async with Worker(
119+
client,
120+
task_queue=task_queue,
121+
workflows=[Agent],
122+
activities=[call_model, run_tool],
123+
):
124+
session_id = f"session-{uuid.uuid4()}"
125+
handle = await client.start_workflow(
126+
Agent.run,
127+
AgentInput(session_id, ["find it"]),
128+
id=session_id,
129+
task_queue=task_queue,
130+
)
131+
await handle.result()
132+
# The Workflow hands out its turns ref, and the query's client reads the Stream.
133+
ref = await handle.query(Agent.stream)
134+
assert ref is not None
135+
stream = client.to_stream(ref, payload_type=Turn)
136+
return [r async for r in stream.subscribe() if isinstance(r, streams.Record)]

0 commit comments

Comments
 (0)