-
Notifications
You must be signed in to change notification settings - Fork 148
AI-310: add streaming sample for @temporalio/openai-agents #488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
xumaple
wants to merge
1
commit into
main
Choose a base branch
from
maplexu/AI-310-openai-agents-streaming-samples
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # OpenAI Agents: Streaming | ||
|
|
||
| Demonstrates the streaming API of the Temporal OpenAI Agents integration. The Workflow hosts a | ||
| [Workflow Stream](https://github.com/temporalio/sdk-typescript/tree/main/contrib/workflow-streams) | ||
| and runs an agent in streaming mode with `runner.run(agent, input, { stream: true })`. As the model | ||
| responds, the streaming model Activity publishes each raw model stream event to the Workflow Stream | ||
| topic, and an external client subscribes to that topic to print the deltas live. | ||
|
|
||
| The Workflow (`src/streaming/workflows.ts`) constructs `new WorkflowStream()` at the top, then | ||
| iterates the `StreamedRunResult` to drive the run to completion and returns the final text. The | ||
| Worker configures `modelParams.streamingTopic` (and a `streamingBatchInterval`) on the | ||
| `OpenAIAgentsPlugin` so the streaming Activity knows which topic to publish to. The client | ||
| (`src/streaming/client.ts`) subscribes from outside the Workflow with | ||
| `WorkflowStreamClient.create(client, workflowId).topic(streamingTopic).subscribe()`. | ||
|
|
||
| ## Run | ||
|
|
||
| Run these from the `openai-agents/` root (run `npm install` there once first). | ||
|
|
||
| ```bash | ||
| # In one terminal, start the Worker (requires a local Temporal server and OPENAI_API_KEY): | ||
| OPENAI_API_KEY=sk-... npx ts-node src/streaming/worker.ts | ||
|
|
||
| # In another terminal, start the streaming client: | ||
| npx ts-node src/streaming/client.ts | ||
| ``` | ||
|
|
||
| ## Test | ||
|
|
||
| ```bash | ||
| npx mocha --exit --require ts-node/register --require source-map-support/register "src/streaming/mocha/*.test.ts" | ||
| ``` | ||
|
|
||
| The test runs a real Worker against `TestWorkflowEnvironment` with a scripted streaming fake model, | ||
| so no `OPENAI_API_KEY` is required. It asserts that an external `WorkflowStreamClient` subscriber | ||
| receives the streamed events in order and that the Workflow completes with the final text. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { Connection, Client } from '@temporalio/client'; | ||
| import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; | ||
| import { OpenAIProvider } from '@openai/agents-openai'; | ||
| import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; | ||
| import { streamingChat, streamingTopic } from './workflows'; | ||
| import { nanoid } from 'nanoid'; | ||
|
|
||
| interface ModelStreamEvent { | ||
| type?: string; | ||
| delta?: string; | ||
| } | ||
|
|
||
| async function run() { | ||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| if (!apiKey) { | ||
| throw new Error('OPENAI_API_KEY environment variable is required'); | ||
| } | ||
|
|
||
| const connection = await Connection.connect(); | ||
| const client = new Client({ | ||
| connection, | ||
| plugins: [ | ||
| new OpenAIAgentsPlugin({ | ||
| modelProvider: new OpenAIProvider({ apiKey }), | ||
| modelParams: { streamingTopic }, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| const taskQueue = 'openai-agents-streaming'; | ||
| const workflowId = 'openai-agents-streaming-' + nanoid(); | ||
|
|
||
| const handle = await client.workflow.start(streamingChat, { | ||
| taskQueue, | ||
| workflowId, | ||
| args: ['Write a short poem about durable execution.'], | ||
| }); | ||
| console.log(`Started workflow ${handle.workflowId}`); | ||
|
|
||
| const streamClient = WorkflowStreamClient.create(client, workflowId); | ||
| const subscriber = (async () => { | ||
| for await (const item of streamClient.topic<ModelStreamEvent>(streamingTopic).subscribe()) { | ||
| if (item.data.type === 'output_text_delta' && item.data.delta) { | ||
| process.stdout.write(item.data.delta); | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| const result = await handle.result(); | ||
| await subscriber; | ||
| console.log('\n---'); | ||
| console.log(result); | ||
| } | ||
|
|
||
| run().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| import { | ||
| type AgentOutputItem, | ||
| type Model, | ||
| type ModelProvider, | ||
| type ModelRequest, | ||
| type ModelResponse, | ||
| type StreamEvent, | ||
| } from '@openai/agents-core'; | ||
|
|
||
| export function streamingTextEvents(text: string): StreamEvent[] { | ||
| const output: AgentOutputItem[] = [ | ||
| { | ||
| type: 'message', | ||
| id: 'msg_fake_stream_001', | ||
| role: 'assistant', | ||
| content: [{ type: 'output_text', text }], | ||
| status: 'completed', | ||
| }, | ||
| ]; | ||
| return [ | ||
| { type: 'output_text_delta', delta: text }, | ||
| { | ||
| type: 'response_done', | ||
| response: { | ||
| id: 'resp_fake_stream_001', | ||
| usage: { requests: 1, inputTokens: 10, outputTokens: text.length, totalTokens: 10 + text.length }, | ||
| output, | ||
| }, | ||
| }, | ||
| ] as StreamEvent[]; | ||
| } | ||
|
|
||
| export class StreamingFakeModel implements Model { | ||
| constructor(private readonly events: StreamEvent[]) {} | ||
| async getResponse(_request: ModelRequest): Promise<ModelResponse> { | ||
| throw new Error('StreamingFakeModel only supports getStreamedResponse'); | ||
| } | ||
| async *getStreamedResponse(_request: ModelRequest): AsyncIterable<StreamEvent> { | ||
| for (const event of this.events) { | ||
| yield event; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| export class StreamingFakeModelProvider implements ModelProvider { | ||
| private readonly model: StreamingFakeModel; | ||
| constructor(events: StreamEvent[]) { | ||
| this.model = new StreamingFakeModel(events); | ||
| } | ||
| getModel(_name?: string): Model { | ||
| return this.model; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,100 @@ | ||
| import { TestWorkflowEnvironment } from '@temporalio/testing'; | ||
| import { after, before, describe, it } from 'mocha'; | ||
| import { Worker } from '@temporalio/worker'; | ||
| import { Client } from '@temporalio/client'; | ||
| import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; | ||
| import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; | ||
| import assert from 'assert'; | ||
| import { StreamingFakeModelProvider, streamingTextEvents } from './fake-model'; | ||
| import { streamingChat, streamingTopic } from '../workflows'; | ||
|
|
||
| interface ModelStreamEvent { | ||
| type?: string; | ||
| delta?: string; | ||
| } | ||
|
|
||
| describe('openai-agents/streaming workflow scenarios', function () { | ||
| this.timeout(30_000); | ||
|
|
||
| let testEnv: TestWorkflowEnvironment; | ||
|
|
||
| before(async () => { | ||
| testEnv = await TestWorkflowEnvironment.createLocal(); | ||
| }); | ||
|
|
||
| after(async () => { | ||
| await testEnv?.teardown(); | ||
| }); | ||
|
|
||
| it('streamingChat: external subscriber receives the streamed events in order', async () => { | ||
| const taskQueue = 'test-streaming'; | ||
| const events = streamingTextEvents('Hello streamed world'); | ||
|
|
||
| const worker = await Worker.create({ | ||
| connection: testEnv.nativeConnection, | ||
| taskQueue, | ||
| workflowsPath: require.resolve('../workflows'), | ||
| plugins: [ | ||
| new OpenAIAgentsPlugin({ | ||
| modelProvider: new StreamingFakeModelProvider(events), | ||
| modelParams: { streamingTopic, streamingBatchInterval: '50 milliseconds' }, | ||
| }), | ||
| ], | ||
| bundlerOptions: { | ||
| webpackConfigHook: (config) => ({ | ||
| ...config, | ||
| resolve: { | ||
| ...config.resolve, | ||
| conditionNames: ['require', 'browser', 'default'], | ||
| }, | ||
| }), | ||
| }, | ||
| }); | ||
|
|
||
| // The client carries streamingTopic to the Workflow via the config header. | ||
| const client = new Client({ | ||
| connection: testEnv.connection, | ||
| plugins: [ | ||
| new OpenAIAgentsPlugin({ | ||
| modelProvider: new StreamingFakeModelProvider(events), | ||
| modelParams: { streamingTopic }, | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| const workflowId = 'test-streaming-' + Date.now(); | ||
| const result = await worker.runUntil(async () => { | ||
| const handle = await client.workflow.start(streamingChat, { | ||
| taskQueue, | ||
| workflowId, | ||
| args: ['Hi'], | ||
| }); | ||
|
|
||
| const received: ModelStreamEvent[] = []; | ||
| const streamClient = WorkflowStreamClient.create(client, workflowId); | ||
| const gen = streamClient.topic<ModelStreamEvent>(streamingTopic).subscribe(0, { pollCooldown: 0 }); | ||
| const collect = (async () => { | ||
| for await (const item of gen) { | ||
| received.push(item.data); | ||
| if (received.length >= events.length) { | ||
| await gen.return(); | ||
| break; | ||
| } | ||
| } | ||
| })(); | ||
|
|
||
| const finalOutput = await handle.result(); | ||
| await collect; | ||
|
|
||
| assert.strictEqual(received.length, events.length); | ||
| assert.deepStrictEqual( | ||
| received.map((e) => e.type), | ||
| events.map((e) => e.type), | ||
| ); | ||
| assert.strictEqual(received[0]!.delta, 'Hello streamed world'); | ||
| return finalOutput; | ||
| }); | ||
|
|
||
| assert.strictEqual(result, 'Hello streamed world'); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { NativeConnection, Worker } from '@temporalio/worker'; | ||
| import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; | ||
| import { OpenAIProvider } from '@openai/agents-openai'; | ||
| import { streamingTopic } from './workflows'; | ||
|
|
||
| async function run() { | ||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| if (!apiKey) { | ||
| throw new Error('OPENAI_API_KEY environment variable is required'); | ||
| } | ||
|
|
||
| const connection = await NativeConnection.connect({ address: 'localhost:7233' }); | ||
| try { | ||
| const worker = await Worker.create({ | ||
| connection, | ||
| taskQueue: 'openai-agents-streaming', | ||
| workflowsPath: require.resolve('./workflows'), | ||
| plugins: [ | ||
| new OpenAIAgentsPlugin({ | ||
| modelProvider: new OpenAIProvider({ apiKey }), | ||
| modelParams: { streamingTopic, streamingBatchInterval: '200 milliseconds' }, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The worker is setting |
||
| }), | ||
| ], | ||
| bundlerOptions: { | ||
| webpackConfigHook: (config) => ({ | ||
| ...config, | ||
| resolve: { | ||
| ...config.resolve, | ||
| conditionNames: ['require', 'browser', 'default'], | ||
| }, | ||
| }), | ||
| }, | ||
| }); | ||
| await worker.run(); | ||
| } finally { | ||
| await connection.close(); | ||
| } | ||
| } | ||
|
|
||
| run().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import { Agent } from '@openai/agents-core'; | ||
| import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; | ||
| import { WorkflowStream } from '@temporalio/workflow-streams/workflow'; | ||
|
|
||
| export const streamingTopic = 'model-stream'; | ||
|
|
||
| // @@@SNIPSTART typescript-openai-agents-streaming-workflow | ||
| export async function streamingChat(prompt: string): Promise<string> { | ||
| new WorkflowStream(); | ||
| const agent = new Agent({ name: 'StreamingAgent', instructions: 'You are a helpful assistant.' }); | ||
| const result = await new TemporalOpenAIRunner().run(agent, prompt, { stream: true }); | ||
| // The external client is the event consumer; the Workflow only drives the run to completion. | ||
| for await (const _event of result); | ||
| await result.completed; | ||
| return result.finalOutput ?? ''; | ||
| } | ||
| // @@@SNIPEND |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The client needs OPENAI_API_KEY too (client.ts: 14-17), prefix with
OPENAI_API_KEY=sk-...?