Skip to content

Commit d78221c

Browse files
jssmithbrianstrauchclaude
authored
Add openai_agents streaming sample (#301)
* Add openai_agents streaming sample Demonstrates buffered token streaming for OpenAI Agents-backed workflows via temporalio.contrib.workflow_streams (experimental, contrib/pubsub branch of sdk-python). The OpenAI Agents plugin's ModelActivityParameters carries a streaming_event_topic; the model activity publishes raw stream events to that topic with a configurable flush interval (default 100ms), and the workflow emits a sentinel on a "done" topic when Runner.run_streamed finishes. Subscribers iterate (events, done) and break on the sentinel — race_with_workflow handles the case where the workflow fails before publishing the sentinel. Two scenarios: - stream_text: text-delta events from a simple haiku agent - stream_items: agent-update / handoff / tool-call events across a multi-agent workflow with a joke-rating activity * samples: openai_agents streaming review polish run_stream_items_workflow: print the workflow's final result after the streamed events render — matches run_stream_text_workflow and makes streamed-vs-final parity visible. * Update streaming sample for the released workflow_streams API The sample was written against the contrib/pubsub branch of sdk-python. Workflow Streams and OpenAI Agents streaming both shipped in 1.30.0, with some renames and one behavioral difference, so bring the sample in line: - ModelActivityParameters.streaming_event_topic is now streaming_topic, and streaming_event_batch_interval is streaming_batch_interval. - subscribe() without result_type decodes payloads rather than handing back a raw Payload. Pass result_type=RawValue and decode per topic, matching the workflow_streams samples. - subscribe() exits cleanly once the workflow reaches a terminal state, so the race_with_workflow helper is unnecessary: break on the terminator, then await handle.result(), which raises if the workflow failed. Verified against a terminated workflow. - Workflows hold the run open briefly after publishing the terminator so a subscriber's next poll can drain the tail of the stream, which lives in workflow memory. Fix the stream_items scenario. The streaming activity publishes native OpenAI events, not the agents-SDK StreamEvent wrappers, so the agent-update / tool-call / message-output events the subscriber was matching on never appear on that topic. The agents SDK builds those inside the workflow, so the workflow now publishes them itself as a serializable ItemEvent on its own topic (the SDK's own event types carry the originating Agent, which holds tool callables). stream_events() resolves a turn at a time, so the play-by-play still arrives progressively. For the same reason, the stream_text subscriber now matches ResponseTextDeltaEvent directly instead of unwrapping a raw_response_event. Also move both workflow module docstrings above the imports, where they are actually docstrings, and drop the stale contrib/pubsub install notes from the READMEs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add tests for the openai_agents streaming sample Covers both scenarios against a scripted streaming model, so no OPENAI_API_KEY is needed: the plugin accepts a model_provider directly, so unlike the other AI sample tests this one needs no monkeypatching. - stream_text: the text arrives as several native OpenAI delta events that reassemble into exactly what the workflow returns. - stream_items: the workflow-published events arrive in order — agent_updated, tool_call, tool_output, message_output. Both subscribe the same way the runner scripts do (one iterator over the event and terminator topics, RawValue payloads decoded per topic) and assert the terminator is seen, which is what lets the subscriber stop without racing the workflow's completion. These are the first tests under tests/openai_agents. The directory should also be listed in CODEOWNERS alongside the other AI sample test directories, but this branch predates that block, so adding it here would conflict with main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Add tests/openai_agents to CODEOWNERS Matches the other AI sample test directories. Deferred until after the merge from main, which is where that block came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Brian Strauch <brian@brianstrauch.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9ecd078 commit d78221c

20 files changed

Lines changed: 994 additions & 5 deletions

File tree

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@
2626
/tests/langfuse_tracing/ @temporalio/sdk @temporalio/ai-sdk
2727
/tests/langgraph_plugin/ @temporalio/sdk @temporalio/ai-sdk
2828
/tests/langsmith_tracing/ @temporalio/sdk @temporalio/ai-sdk
29+
/tests/openai_agents/ @temporalio/sdk @temporalio/ai-sdk
2930
/tests/strands_plugin/ @temporalio/sdk @temporalio/ai-sdk

openai_agents/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ Each directory contains a complete example with its own README for detailed inst
3939
- **[Customer Service](./customer_service/README.md)** - Interactive customer service agent with escalation capabilities, demonstrating conversational workflows.
4040
- **[Reasoning Content](./reasoning_content/README.md)** - Example of how to retrieve the thought process of reasoning models.
4141
- **[Financial Research Agent](./financial_research_agent/README.md)** - Multi-agent financial research system with planner, search, analyst, writer, and verifier agents collaborating.
42+
- **[Streaming](./streaming/README.md)** - `Runner.run_streamed` with buffered token streaming to external subscribers via `temporalio.contrib.workflow_streams`. **Experimental.**

openai_agents/agent_patterns/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ uv run openai_agents/agent_patterns/run_agents_as_tools_workflow.py
4040
```
4141

4242
### Agent Routing and Handoffs
43-
Route requests to specialized agents based on content analysis (adapted for non-streaming):
43+
Route requests to specialized agents based on content analysis (adapted to consume the run's output in one piece; see [Streaming](../streaming/README.md) for streaming output to external subscribers):
4444
```bash
4545
uv run openai_agents/agent_patterns/run_routing_workflow.py
4646
```
@@ -94,4 +94,4 @@ This is really useful for latency: for example, you might have a very fast model
9494

9595
The following patterns from the [reference repository](https://github.com/openai/openai-agents-python/tree/main/examples/agent_patterns) are not included in this Temporal adaptation:
9696

97-
- **Streaming Guardrails**: Requires streaming capabilities which are not yet available in the Temporal integration
97+
- **Streaming Guardrails**: The pattern interrupts generation by inspecting deltas from inside the run loop. The Temporal integration does support streaming (see [Streaming](../streaming/README.md)), but the model call runs in an activity and the workflow only sees its events once that activity returns, so there is no in-run delta to act on mid-response.

openai_agents/basic/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,5 @@ uv run openai_agents/basic/run_previous_response_id_workflow.py
7676
The following examples from the [reference repository](https://github.com/openai/openai-agents-python/tree/main/examples/basic) are not included in this Temporal adaptation:
7777

7878
- **Session** - Stores state in local SQLite database, not appropriate for distributed workflows
79-
- **Stream Items/Stream Text** - Streaming is not supported in Temporal OpenAI Agents SDK integration
79+
80+
**Stream Items/Stream Text** are adapted in [`../streaming/`](../streaming/README.md) rather than here. They need `streaming_topic` set on the plugin's `ModelActivityParameters`, so they run on their own worker instead of sharing this directory's.

openai_agents/handoffs/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,4 @@ The workflow returns both the final response and complete message history for in
4141

4242
The following patterns from the [reference repository](https://github.com/openai/openai-agents-python/tree/main/examples/handoffs) are not included in this Temporal adaptation:
4343

44-
- **Message Filter Streaming**: Streaming capabilities are not yet available in the Temporal integration
44+
- **Message Filter Streaming**: Differs from the included message-filter example only in rendering the same run's output as it streams. The Temporal integration does support that — see [Streaming](../streaming/README.md) — but it is demonstrated there rather than duplicated here.

openai_agents/reasoning_content/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,4 @@ uv run openai_agents/reasoning_content/run_reasoning_content_workflow.py
3434

3535
## Note on Streaming
3636

37-
The original OpenAI Agents SDK example includes streaming capabilities, but since Temporal workflows do not support streaming yet, this example contains only the non-streaming approach.
37+
The original OpenAI Agents SDK example includes a streaming variant. This example keeps only the non-streaming approach for brevity; the integration does support streaming model output to external subscribers, which is covered in [Streaming](../streaming/README.md).

openai_agents/streaming/README.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# Streaming OpenAI Agents
2+
3+
> **Experimental.** These samples use the streaming support in
4+
> `temporalio.contrib.openai_agents` together with
5+
> `temporalio.contrib.workflow_streams`. Both are experimental and their APIs
6+
> may change in future versions.
7+
8+
*Adapted from the [OpenAI Agents SDK basic examples](https://github.com/openai/openai-agents-python/tree/main/examples/basic)*
9+
10+
Before running these examples, be sure to review the [prerequisites and background on the integration](../README.md).
11+
12+
The OpenAI Agents SDK streams model output via `Runner.run_streamed`, which
13+
yields events as the model produces them. Inside a Temporal workflow the model
14+
call runs in an activity, so the workflow cannot iterate the live HTTP stream
15+
directly. Instead the plugin runs `model.stream_response()` in a streaming
16+
activity, and that activity publishes each event to the workflow's
17+
[`WorkflowStream`](../../workflow_streams/README.md) so external subscribers
18+
see events as they are produced.
19+
20+
Publishing is batched: the activity coalesces events over
21+
`ModelActivityParameters.streaming_batch_interval` (default 100ms) before
22+
signalling the workflow. Call this **buffered token streaming** — deltas reach
23+
subscribers within a batch window of being produced, not on every byte. At
24+
typical model speeds one batch carries several tokens, so output arrives in
25+
small bursts rather than glyph-by-glyph. Lower the interval for smoother
26+
output at the cost of more signals.
27+
28+
Two things to know before reading the samples:
29+
30+
* `streaming_topic` is **required** for `Runner.run_streamed`. If it is unset,
31+
`run_streamed` raises before scheduling any activity.
32+
* The workflow must host a `WorkflowStream`. It has to be constructed from a
33+
method named `__init__``WorkflowStream` inspects its caller's frame and
34+
raises otherwise — and `@workflow.init` is what makes the workflow's run
35+
argument (carrying `stream_state` for continue-as-new) available there.
36+
37+
## Running the Examples
38+
39+
First, start the worker (supports both examples):
40+
41+
```bash
42+
uv run openai_agents/streaming/run_worker.py
43+
```
44+
45+
Then run either example in another terminal.
46+
47+
### `stream_text` — buffered text deltas
48+
49+
Adapted from [`examples/basic/stream_text.py`][upstream-text]. The workflow
50+
just calls `Runner.run_streamed`; the subscriber renders the
51+
`ResponseTextDeltaEvent`s the streaming activity publishes on the `events`
52+
topic.
53+
54+
Subscribers receive **native OpenAI events** (`TResponseStreamEvent`), because
55+
the activity publishes them straight from `Model.stream_response`. That differs
56+
from `stream_events()` inside the workflow, which yields the agents-SDK
57+
`StreamEvent` union — raw model events arrive there wrapped as
58+
`RawResponsesStreamEvent.data`.
59+
60+
[upstream-text]: https://github.com/openai/openai-agents-python/blob/main/examples/basic/stream_text.py
61+
62+
```bash
63+
uv run openai_agents/streaming/run_stream_text_workflow.py
64+
```
65+
66+
### `stream_items` — agent-level events with a tool call
67+
68+
Adapted from [`examples/basic/stream_items.py`][upstream-items]. Renders agent
69+
updates, tool calls, tool outputs, and message outputs as a play-by-play.
70+
71+
The agents SDK builds those higher-level events from the model output, so they
72+
exist only inside the workflow — the streaming activity never sees them. This
73+
workflow therefore does its own publishing: it iterates
74+
`result.stream_events()` and forwards each event of interest to an `items`
75+
topic as a small serializable `ItemEvent`. (The agents-SDK event types carry
76+
the originating `Agent`, which holds tool callables and so cannot be
77+
serialized.) `stream_events()` resolves a turn at a time — each model call is
78+
one activity — so a multi-turn run like this one reaches the subscriber
79+
progressively rather than in one lump.
80+
81+
[upstream-items]: https://github.com/openai/openai-agents-python/blob/main/examples/basic/stream_items.py
82+
83+
```bash
84+
uv run openai_agents/streaming/run_stream_items_workflow.py
85+
```
86+
87+
## How it works
88+
89+
1. The workflow constructs a `WorkflowStream` in `@workflow.init`.
90+
2. `OpenAIAgentsPlugin` is configured with `streaming_topic="events"`, which
91+
routes `Runner.run_streamed` to `invoke_model_activity_streaming`.
92+
3. Inside that activity each event from the live HTTP stream is both collected
93+
(returned to the workflow when the activity completes) and published to the
94+
stream via `WorkflowStreamClient.from_within_activity()`.
95+
4. Just before returning, the workflow publishes a terminator on a separate
96+
`done` topic, then sleeps briefly so the subscriber's next poll can drain
97+
the tail of the stream — the log lives in workflow memory and disappears
98+
when the run completes.
99+
5. External code subscribes with
100+
`WorkflowStreamClient.create(...).subscribe([...], result_type=RawValue)`
101+
and breaks on the terminator. `RawValue` keeps the payloads undecoded so
102+
each topic can be decoded against its own type. If the workflow reaches a
103+
terminal state without publishing a terminator (a failure, say), the
104+
iterator exhausts on its own and the following `handle.result()` raises.
105+
106+
In the workflow, `stream_events()` resolves only after the model activity
107+
returns, so the workflow itself does not see deltas as they arrive — the
108+
streaming benefit is for external observers.
109+
110+
## Notes
111+
112+
* Streaming is incompatible with `use_local_activity=True`: local activities
113+
support neither heartbeats nor the workflow stream signal channel.
114+
* The streaming activity heartbeats on a background task, so set
115+
`heartbeat_timeout` well below `start_to_close_timeout` to detect a stuck
116+
model call early.
117+
* Delivery is at-least-once per activity attempt. An attempt that fails
118+
mid-response leaves its partial events on the stream — they are flushed
119+
before the failure is reported — and the retry publishes a whole new
120+
response. `stream_events()` in the workflow only sees the successful attempt,
121+
so the workflow's return value stays correct while a naive subscriber renders
122+
the truncated attempt followed by the full one.
123+
124+
The plugin's streaming activity publishes no retry marker, so subscribers
125+
detect this in band: every OpenAI stream event carries a `sequence_number`
126+
that starts at 0 per response, and a number that fails to advance means a new
127+
attempt. `run_stream_text_workflow.py` prints a notice at that seam;
128+
`workflow_streams/run_llm.py` shows the fuller treatment, where an activity
129+
you own publishes an explicit `RetryEvent` from `activity.info().attempt` and
130+
the consumer erases the failed attempt's output.

openai_agents/streaming/__init__.py

Whitespace-only changes.

openai_agents/streaming/activities/__init__.py

Whitespace-only changes.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from __future__ import annotations
2+
3+
import random
4+
5+
from temporalio import activity
6+
7+
8+
@activity.defn
9+
async def how_many_jokes() -> int:
10+
"""Return a random integer of jokes to tell between 1 and 10 (inclusive)."""
11+
return random.randint(1, 10)

0 commit comments

Comments
 (0)