diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ae6231..febb4db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,6 +69,7 @@ jobs: workflow-streams message-passing/introduction message-passing/safe-message-handlers + openai-agents polling/infrequent ) for project in "${projects[@]}"; do diff --git a/.scripts/copy-shared-files.mjs b/.scripts/copy-shared-files.mjs index 42c2a749..b5658398 100644 --- a/.scripts/copy-shared-files.mjs +++ b/.scripts/copy-shared-files.mjs @@ -42,6 +42,7 @@ const GITIGNORE_EXCLUDE = [ 'nestjs-exchange-rates', ]; const ESLINTRC_EXCLUDE = [ + 'openai-agents', 'nextjs-ecommerce-oneclick', 'monorepo-folders', 'fetch-esm', @@ -63,6 +64,7 @@ const ESLINTIGNORE_EXCLUDE = [ ]; const POST_CREATE_EXCLUDE = [ + 'openai-agents', 'schedules', 'timer-examples', 'query-subscriptions', diff --git a/.scripts/list-of-samples.json b/.scripts/list-of-samples.json index 1bb3c811..0923b07a 100644 --- a/.scripts/list-of-samples.json +++ b/.scripts/list-of-samples.json @@ -32,6 +32,7 @@ "nexus-cancellation", "nexus-hello", "nexus-messaging", + "openai-agents", "patching-api", "production", "protobufs", diff --git a/README.md b/README.md index 571103cc..37ede1c8 100644 --- a/README.md +++ b/README.md @@ -3,25 +3,25 @@ -- [samples-typescript](#samples-typescript) - - [Running](#running) - - [Locally](#locally) - - [Scaffold](#scaffold) - - [Samples](#samples) - - [Basic](#basic) - - [API demos](#api-demos) - - [Activity APIs and design patterns](#activity-apis-and-design-patterns) - - [Nexus APIs](#nexus-apis) - - [Workflow APIs](#workflow-apis) - - [Production APIs](#production-apis) - - [Advanced APIs](#advanced-apis) - - [Test APIs](#test-apis) - - [Full-stack apps](#full-stack-apps) - - [External apps \& libraries](#external-apps--libraries) - - [Contributing](#contributing) - - [Dependencies](#dependencies) - - [Upgrading the SDK version in `package.json`s](#upgrading-the-sdk-version-in-packagejsons) - - [Config files](#config-files) +- [Running](#running) + - [Locally](#locally) + - [Scaffold](#scaffold) +- [Samples](#samples) + - [Basic](#basic) + - [API demos](#api-demos) + - [Activity APIs and design patterns](#activity-apis-and-design-patterns) + - [Nexus APIs](#nexus-apis) + - [Workflow APIs](#workflow-apis) + - [Production APIs](#production-apis) + - [Advanced APIs](#advanced-apis) + - [Test APIs](#test-apis) + - [AI / LLM](#ai--llm) + - [Full-stack apps](#full-stack-apps) +- [External apps & libraries](#external-apps--libraries) +- [Contributing](#contributing) + - [Dependencies](#dependencies) + - [Upgrading the SDK version in `package.json`s](#upgrading-the-sdk-version-in-packagejsons) + - [Config files](#config-files) @@ -161,6 +161,24 @@ and you'll be given the list of sample options. - [**Mocha with code coverage or Jest**](https://github.com/temporalio/samples-typescript/tree/main/activities-examples#testing) - [**Time skipping**](https://github.com/temporalio/samples-typescript/tree/main/timer-examples#testing) +#### AI / LLM + +- [**OpenAI Agents**](./openai-agents): Run [OpenAI Agents SDK](https://github.com/openai/openai-agents-js) agents as Temporal Workflows with the `@temporalio/openai-agents` integration. The [`openai-agents/`](./openai-agents) directory contains fourteen samples: + - [**Basic**](./openai-agents/basic): A single agent plus the building blocks — Activity-backed and inline tools, local-Activity tools, agent context, structured output, per-run model override, and dynamic instructions. + - [**Handoffs**](./openai-agents/handoffs): A triage agent routes each request to a specialist agent, using both the `Agent[]` and `handoff()` forms and a per-handoff input filter. + - [**Agent Patterns**](./openai-agents/agent-patterns): Multi-agent orchestration patterns — deterministic chaining, parallelization, LLM-as-judge, agents-as-tools, and input/output guardrails. + - [**Sessions**](./openai-agents/sessions): Conversation history with `WorkflowSafeMemorySession`, including carrying history across a `continueAsNew` boundary. + - [**Human Approval**](./openai-agents/human-approval): A human-in-the-loop tool that pauses the run for an `approve` Signal, then resumes by serializing and rehydrating the run state across `continueAsNew`. + - [**Tools**](./openai-agents/tools): Server-side hosted tools — web search, image generation, and code interpreter — executed by the model provider in the model Activity. + - [**Tracing**](./openai-agents/tracing): The three supported tracing paths — a custom `TracingProcessor`, the OpenAI hosted exporter, and OpenTelemetry — plus `temporal:*` orchestration spans. + - [**Model Providers**](./openai-agents/model-providers): Pass a custom `ModelProvider` to point an agent at any OpenAI-compatible endpoint. + - [**Reasoning Content**](./openai-agents/reasoning-content): Read a reasoning model's `reasoning_content` field by calling the `openai` SDK directly from an Activity. + - [**MCP**](./openai-agents/mcp): Stateless and stateful Model Context Protocol servers (stdio, Streamable HTTP, SSE, and prompt servers) running locally. + - [**Hosted MCP**](./openai-agents/hosted-mcp): A `HostedMCPTool` the model calls server-side, with and without a Signal-driven approval round trip. + - [**Research Bot**](./openai-agents/research-bot): A planner agent fans out concurrent web searches and a writer agent synthesizes a final report. + - [**Customer Service**](./openai-agents/customer-service): A long-running, multi-turn Workflow driven by Updates and Queries, with triage handoffs and `continueAsNew` to bound history. + - [**Nexus Tools**](./openai-agents/nexus-tools): Expose a Nexus Operation as an agent tool with `nexusOperationAsTool`. + ### Full-stack apps - **Next.js**: diff --git a/openai-agents/.eslintignore b/openai-agents/.eslintignore new file mode 100644 index 00000000..7bd99a41 --- /dev/null +++ b/openai-agents/.eslintignore @@ -0,0 +1,3 @@ +node_modules +lib +.eslintrc.js \ No newline at end of file diff --git a/openai-agents/.eslintrc.js b/openai-agents/.eslintrc.js new file mode 100644 index 00000000..53812fe4 --- /dev/null +++ b/openai-agents/.eslintrc.js @@ -0,0 +1,48 @@ +const { builtinModules } = require('module'); + +const ALLOWED_NODE_BUILTINS = new Set(['assert']); + +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir: __dirname, + }, + plugins: ['@typescript-eslint', 'deprecation'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'prettier', + ], + rules: { + // recommended for safety + '@typescript-eslint/no-floating-promises': 'error', // forgetting to await Activities and Workflow APIs is bad + 'deprecation/deprecation': 'warn', + + // code style preference + 'object-shorthand': ['error', 'always'], + + // relaxed rules, for convenience + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + }, + overrides: [ + { + files: ['src/*/workflows.ts', 'src/*/workflows-*.ts', 'src/*/workflows/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + ...builtinModules.filter((m) => !ALLOWED_NODE_BUILTINS.has(m)).flatMap((m) => [m, `node:${m}`]), + ], + }, + }, + ], +}; diff --git a/openai-agents/.gitignore b/openai-agents/.gitignore new file mode 100644 index 00000000..a9f4ed54 --- /dev/null +++ b/openai-agents/.gitignore @@ -0,0 +1,2 @@ +lib +node_modules \ No newline at end of file diff --git a/openai-agents/.npmrc b/openai-agents/.npmrc new file mode 100644 index 00000000..9cf94950 --- /dev/null +++ b/openai-agents/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/openai-agents/.nvmrc b/openai-agents/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/openai-agents/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/openai-agents/.post-create b/openai-agents/.post-create new file mode 100644 index 00000000..a5b0cae2 --- /dev/null +++ b/openai-agents/.post-create @@ -0,0 +1,20 @@ +To begin development, install the Temporal CLI: + +Mac: {cyan brew install temporal} +Other: Download and extract the latest release from https://github.com/temporalio/cli/releases/latest + +Start Temporal Server: + +{cyan temporal server start-dev} + +Use Node version 18+ (v22.x is recommended): + +Mac: {cyan brew install node@22} +Other: https://nodejs.org/en/download/ + +This sample has several scenarios under {cyan src/}. Using two other shells, start a Worker for one scenario and run its client (example: {cyan basic}): + +{cyan OPENAI_API_KEY= npx ts-node src/basic/worker.ts} +{cyan npx ts-node src/basic/client.ts hello-world} + +See README.md for the full list of scenarios. diff --git a/openai-agents/.prettierignore b/openai-agents/.prettierignore new file mode 100644 index 00000000..7951405f --- /dev/null +++ b/openai-agents/.prettierignore @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/openai-agents/.prettierrc b/openai-agents/.prettierrc new file mode 100644 index 00000000..965d50bf --- /dev/null +++ b/openai-agents/.prettierrc @@ -0,0 +1,2 @@ +printWidth: 120 +singleQuote: true diff --git a/openai-agents/README.md b/openai-agents/README.md new file mode 100644 index 00000000..0adea03a --- /dev/null +++ b/openai-agents/README.md @@ -0,0 +1,62 @@ +# OpenAI Agents + +These samples use the `@temporalio/openai-agents` integration to run [OpenAI Agents SDK](https://github.com/openai/openai-agents-js) agents as Temporal Workflows. Agent orchestration — the agent loop, handoffs, tool calls, and guardrails — runs inside the Workflow, while model calls run as durable Activities, so they retry on failure and are not repeated during Workflow replay. + +This is a single project: one `package.json` and one set of configs at the `openai-agents/` root, with each scenario in its own subdirectory. Run `npm install` once here, then run any scenario by path (see each scenario's README). The integration package itself is documented in the [`@temporalio/openai-agents` README](https://github.com/temporalio/sdk-typescript/tree/main/packages/openai-agents). + +## Prerequisites + +These apply to every sample in this directory: + +- A running Temporal dev server: `temporal server start-dev`. +- Node 22 or later. +- An OpenAI API key: `export OPENAI_API_KEY=...`. +- Dependencies installed once at the `openai-agents/` root: `npm install`. + +Each scenario's README describes how to start its Worker and run its scenarios by path. + +## Samples + +| Sample | Demonstrates | +| :----------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`basic`](./src/basic) | A single agent plus the core building blocks: Activity-backed and inline tools, local-Activity tools, agent context, structured output, per-run model override, and dynamic instructions. | +| [`handoffs`](./src/handoffs) | A triage agent routes each request to a specialist agent, using both the `Agent[]` and `handoff()` forms and a per-handoff input filter. | +| [`agent-patterns`](./src/agent-patterns) | Multi-agent orchestration patterns: deterministic chaining, parallelization, LLM-as-judge, agents-as-tools, and input/output guardrails. | +| [`sessions`](./src/sessions) | Conversation history with `WorkflowSafeMemorySession`, including carrying history across a `continueAsNew` boundary. | +| [`human-approval`](./src/human-approval) | A human-in-the-loop tool that pauses the run for an `approve` Signal, then resumes by serializing and rehydrating the run state across `continueAsNew`. | +| [`tools`](./src/tools) | Server-side hosted tools — web search, image generation, and code interpreter — executed by the model provider during the model Activity. | +| [`tracing`](./src/tracing) | The three supported tracing paths: a custom `TracingProcessor`, the OpenAI hosted exporter, and OpenTelemetry, plus `temporal:*` orchestration spans. | +| [`model-providers`](./src/model-providers) | Pass a custom `ModelProvider` to point an agent at any OpenAI-compatible endpoint. | +| [`reasoning-content`](./src/reasoning-content) | Read a reasoning model's `reasoning_content` field by calling the `openai` SDK directly from an Activity. | +| [`mcp`](./src/mcp) | Stateless and stateful Model Context Protocol servers (stdio, Streamable HTTP, SSE, and prompt servers) running locally. | +| [`hosted-mcp`](./src/hosted-mcp) | A `HostedMCPTool` the model calls server-side, with and without a Signal-driven approval round trip. | +| [`multi-agent`](./src/multi-agent) | A planner agent fans out concurrent web searches and a writer agent synthesizes a final report. | +| [`stateful-conversation`](./src/stateful-conversation) | A long-running, multi-turn Workflow driven by Updates and Queries, with triage handoffs and `continueAsNew` to bound history. | +| [`nexus-tools`](./src/nexus-tools) | Expose a [Nexus](https://docs.temporal.io/nexus) Operation as an agent tool with `nexusOperationAsTool`. | + +## Feature support + +Any OpenAI Agents SDK `ModelProvider` can drive the model Activity. The provider runs in the Activity, never inside the Workflow sandbox. + +| Feature | Status | Notes | +| :---------------------- | :------------ | :-------------------------------------------------------------------------------------- | +| Multi-turn agents | Supported | Agent loop runs durably in the Workflow | +| Handoffs | Supported | `Agent` and `handoff()` forms | +| Inline function tools | Supported | Must be deterministic | +| Activity-backed tools | Supported | Via `activityAsTool()` | +| Nexus operation tools | Supported | Via `nexusOperationAsTool()` | +| Nested agent tools | Supported | Via `agentAsTool()` | +| Hosted tools | Supported | Executed server-side by the model provider | +| Stateless MCP servers | Supported | Via `StatelessMCPServerProvider` and `statelessMcpServer()` | +| Stateful MCP servers | Supported | Via `StatefulMCPServerProvider` and `statefulMcpServer()` | +| Sessions | Supported | Via `WorkflowSafeMemorySession`; upstream `MemorySession` is rejected | +| Run state and approvals | Supported | Serialize with `result.state.toString()` and rehydrate with `RunState.fromString` | +| Guardrails | Supported | Guardrail callbacks must be deterministic | +| Tracing | Supported | OpenAI hosted traces, custom `TracingProcessor`s, OTel, and optional `temporal:*` spans | +| Agent context | Supported | Activity tools receive a copy | +| `continueAsNew` | Supported | Plugin config propagates to the continuation | +| Child Workflows | Supported | Plugin config propagates to children | +| Local Activities | Supported | Set `useLocalActivity: true` in `modelParams` | +| Model override per run | Supported | `runConfig.model` accepts a string model name | +| Streaming | Not supported | Use `runner.run()` | +| Voice agents | Not supported | | diff --git a/openai-agents/package.json b/openai-agents/package.json new file mode 100644 index 00000000..05117461 --- /dev/null +++ b/openai-agents/package.json @@ -0,0 +1,46 @@ +{ + "name": "temporal-openai-agents", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "tsc --build", + "build.watch": "tsc --build --watch", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint .", + "test": "mocha --exit --require ts-node/register --require source-map-support/register \"src/*/mocha/*.test.ts\"" + }, + "dependencies": { + "@temporalio/activity": "^1.18.0", + "@temporalio/client": "^1.18.0", + "@temporalio/nexus": "^1.18.0", + "@temporalio/openai-agents": "^1.18.0", + "@temporalio/worker": "^1.18.0", + "@temporalio/workflow": "^1.18.0", + "@openai/agents-core": "^0.11.6", + "@openai/agents-openai": "^0.11.6", + "@modelcontextprotocol/sdk": "^1.29.0", + "@opentelemetry/api": "^1.9.0", + "openai": "^6.0.0", + "nexus-rpc": "^0.0.2", + "nanoid": "3.x", + "zod": "^4.0.0" + }, + "devDependencies": { + "@temporalio/testing": "^1.18.0", + "@tsconfig/node22": "^22.0.0", + "@types/mocha": "8.x", + "@types/node": "^22.9.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "@opentelemetry/sdk-trace-base": "^1.30.0", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-deprecation": "^3.0.0", + "mocha": "8.x", + "prettier": "^3.4.2", + "ts-node": "^10.9.2", + "typescript": "^5.6.3", + "source-map-support": "^0.5.21" + } +} diff --git a/openai-agents/src/agent-patterns/README.md b/openai-agents/src/agent-patterns/README.md new file mode 100644 index 00000000..583e6700 --- /dev/null +++ b/openai-agents/src/agent-patterns/README.md @@ -0,0 +1,40 @@ +# OpenAI Agents: Agent Patterns + +Demonstrates common multi-agent orchestration patterns with the Temporal OpenAI Agents +integration. Each pattern is its own Workflow in `src/agent-patterns/workflows.ts`. + +Scenarios: + +- **deterministic** — three agents run in sequence, each gating the next (outline → draft → polish). +- **parallelization** — `Promise.all` fans out to three agents, then a judge agent picks the best answer. +- **llm-as-judge** — a generate→judge loop that retries until the judge approves. +- **agents-as-tools** — an orchestrator uses `agentAsTool` to call a specialist agent as a tool. +- **input-guardrails** — `runConfig.inputGuardrails` blocks forbidden input before the model runs. +- **output-guardrails** — `runConfig.outputGuardrails` blocks unsafe model output after the model runs. + +## 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/agent-patterns/worker.ts + +# In another terminal, start a scenario: +npx ts-node src/agent-patterns/client.ts deterministic +npx ts-node src/agent-patterns/client.ts parallelization +npx ts-node src/agent-patterns/client.ts llm-as-judge +npx ts-node src/agent-patterns/client.ts agents-as-tools +npx ts-node src/agent-patterns/client.ts input-guardrails +npx ts-node src/agent-patterns/client.ts output-guardrails +``` + +## Test + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/agent-patterns/mocha/*.test.ts" +``` + +Tests run a real Worker against `TestWorkflowEnvironment` with a scripted fake model, so no +`OPENAI_API_KEY` is required. Each pattern has a test asserting its mechanism (call counts, +history threading, tool round-trips, and guardrail tripwires). diff --git a/openai-agents/src/agent-patterns/client.ts b/openai-agents/src/agent-patterns/client.ts new file mode 100644 index 00000000..7a990146 --- /dev/null +++ b/openai-agents/src/agent-patterns/client.ts @@ -0,0 +1,87 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { + deterministic, + parallelization, + llmAsJudge, + agentsAsTools, + inputGuardrail, + outputGuardrail, +} from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'deterministic'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-agent-patterns'; + const workflowId = 'openai-agents-' + nanoid(); + + let handle; + switch (scenario) { + case 'deterministic': + handle = await client.workflow.start(deterministic, { + taskQueue, + workflowId, + args: ['Write a short piece about the importance of software testing.'], + }); + break; + case 'parallelization': + handle = await client.workflow.start(parallelization, { + taskQueue, + workflowId, + args: ['What is the most important skill for a software engineer?'], + }); + break; + case 'llm-as-judge': + handle = await client.workflow.start(llmAsJudge, { + taskQueue, + workflowId, + args: ['Explain what Temporal is in two sentences.'], + }); + break; + case 'agents-as-tools': + handle = await client.workflow.start(agentsAsTools, { + taskQueue, + workflowId, + args: ['What are the benefits of durable execution?'], + }); + break; + case 'input-guardrails': + handle = await client.workflow.start(inputGuardrail, { + taskQueue, + workflowId, + args: ['Tell me something interesting about Temporal.'], + }); + break; + case 'output-guardrails': + handle = await client.workflow.start(outputGuardrail, { + taskQueue, + workflowId, + args: ['Describe a safe software deployment strategy.'], + }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/agent-patterns/mocha/fake-model.ts b/openai-agents/src/agent-patterns/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/agent-patterns/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/agent-patterns/mocha/workflows.test.ts b/openai-agents/src/agent-patterns/mocha/workflows.test.ts new file mode 100644 index 00000000..d81f8234 --- /dev/null +++ b/openai-agents/src/agent-patterns/mocha/workflows.test.ts @@ -0,0 +1,190 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { + deterministic, + parallelization, + llmAsJudge, + agentsAsTools, + inputGuardrail, + outputGuardrail, +} from '../workflows'; + +describe('openai-agents/agent-patterns workflow scenarios', function () { + this.timeout(60_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('deterministic: runs three agents in sequence, each gating the next', async () => { + const taskQueue = 'test-deterministic'; + const outlineText = 'Outline: Introduction, Body, Conclusion'; + const draftText = 'Draft: Here is the full draft.'; + const polishedText = 'Polished: Here is the final polished text.'; + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse(outlineText), + textResponse(draftText), + textResponse(polishedText), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(deterministic, { + args: ['Write about testing.'], + workflowId: 'test-deterministic-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, polishedText); + assert.strictEqual(provider.model.requests.length, 3, 'should have made exactly 3 model calls'); + const secondTurnInput = provider.model.requests[1]?.input; + const secondTurnText = Array.isArray(secondTurnInput) ? JSON.stringify(secondTurnInput) : String(secondTurnInput); + assert.ok( + secondTurnText.includes(outlineText), + 'second agent input should contain the first agent output (outline)', + ); + }); + + it('parallelization: fans out to 3 agents concurrently then judge picks best', async () => { + const taskQueue = 'test-parallelization'; + const answer1 = 'Technical answer: reliability.'; + const answer2 = 'Business answer: value delivery.'; + const answer3 = 'Creative answer: problem-solving mindset.'; + const judgeAnswer = answer1; + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse(answer1), + textResponse(answer2), + textResponse(answer3), + textResponse(judgeAnswer), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(parallelization, { + args: ['What is the most important skill?'], + workflowId: 'test-parallelization-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, judgeAnswer); + assert.strictEqual(provider.model.requests.length, 4, 'should have called model 3 times for agents + 1 for judge'); + }); + + it('llmAsJudge: loops generate→judge until approved, returns approved output', async () => { + const taskQueue = 'test-llm-as-judge'; + const firstDraft = 'First draft about Temporal.'; + const secondDraft = 'Improved draft about Temporal.'; + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse(firstDraft), + textResponse('REJECTED: needs more detail'), + textResponse(secondDraft), + textResponse('APPROVED'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(llmAsJudge, { + args: ['Explain what Temporal is.'], + workflowId: 'test-llm-as-judge-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, secondDraft); + assert.strictEqual( + provider.model.requests.length, + 4, + 'should have made 4 model calls: generate, judge-reject, generate, judge-approve', + ); + }); + + it('agentsAsTools: orchestrator calls specialist tool and returns final output', async () => { + const taskQueue = 'test-agents-as-tools'; + const specialistAnswer = 'Specialist answer: durable execution ensures reliability.'; + const finalAnswer = 'Orchestrator synthesized: ' + specialistAnswer; + const { worker, provider } = await makeWorker(taskQueue, [ + toolCallResponse('ask_specialist', { input: 'What are the benefits of durable execution?' }), + textResponse(specialistAnswer), + textResponse(finalAnswer), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(agentsAsTools, { + args: ['What are the benefits of durable execution?'], + workflowId: 'test-agents-as-tools-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, finalAnswer); + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && item.name === 'ask_specialist'); + assert.strictEqual(toolResults.length, 1, 'ask_specialist tool result should reach the orchestrator exactly once'); + }); + + it('inputGuardrail: blocks forbidden input before model is called', async () => { + const taskQueue = 'test-input-guardrail-blocked'; + const { worker, provider } = await makeWorker(taskQueue, []); + const result = await worker.runUntil( + testEnv.client.workflow.execute(inputGuardrail, { + args: ['This is a blocked request.'], + workflowId: 'test-input-guardrail-blocked-' + Date.now(), + taskQueue, + }), + ); + assert.ok(result.startsWith('BLOCKED:'), `expected BLOCKED sentinel, got: ${result}`); + assert.ok(result.includes('keyword-guardrail'), 'sentinel should name the guardrail'); + assert.strictEqual(provider.model.requests.length, 0, 'model should not be called when input guardrail fires'); + }); + + it('inputGuardrail: passes clean input through to model', async () => { + const taskQueue = 'test-input-guardrail-pass'; + const modelReply = 'Here is something interesting about Temporal.'; + const { worker } = await makeWorker(taskQueue, [textResponse(modelReply)]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(inputGuardrail, { + args: ['Tell me something interesting about Temporal.'], + workflowId: 'test-input-guardrail-pass-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, modelReply); + }); + + it('outputGuardrail: blocks unsafe model output after model call', async () => { + const taskQueue = 'test-output-guardrail'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('This response contains UNSAFE content.')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(outputGuardrail, { + args: ['Describe something.'], + workflowId: 'test-output-guardrail-' + Date.now(), + taskQueue, + }), + ); + assert.ok(result.startsWith('BLOCKED:'), `expected BLOCKED sentinel, got: ${result}`); + assert.ok(result.includes('output-safety-guardrail'), 'sentinel should name the guardrail'); + assert.strictEqual(provider.model.requests.length, 1, 'model should be called once before output guardrail fires'); + }); +}); diff --git a/openai-agents/src/agent-patterns/worker.ts b/openai-agents/src/agent-patterns/worker.ts new file mode 100644 index 00000000..88e7d864 --- /dev/null +++ b/openai-agents/src/agent-patterns/worker.ts @@ -0,0 +1,42 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +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-agent-patterns', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/agent-patterns/workflows.ts b/openai-agents/src/agent-patterns/workflows.ts new file mode 100644 index 00000000..609cc0a5 --- /dev/null +++ b/openai-agents/src/agent-patterns/workflows.ts @@ -0,0 +1,197 @@ +import { Agent } from '@openai/agents-core'; +import { InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered } from '@openai/agents-core'; +import { ApplicationFailure } from '@temporalio/workflow'; +import type { + InputGuardrail, + InputGuardrailFunctionArgs, + GuardrailFunctionOutput, + OutputGuardrail, + OutputGuardrailFunctionArgs, +} from '@openai/agents-core'; +import { TemporalOpenAIRunner, agentAsTool } from '@temporalio/openai-agents/workflow'; + +export async function deterministic(prompt: string): Promise { + const outlineAgent = new Agent({ + name: 'OutlineAgent', + instructions: 'Create a brief outline for the given topic.', + }); + const draftAgent = new Agent({ + name: 'DraftAgent', + instructions: 'Write a short draft based on the outline provided.', + }); + const polishAgent = new Agent({ + name: 'PolishAgent', + instructions: 'Polish and improve the draft provided.', + }); + + const runner = new TemporalOpenAIRunner(); + + const outlineResult = await runner.run(outlineAgent, prompt); + const outline = outlineResult.finalOutput ?? ''; + + const draftResult = await runner.run(draftAgent, outline); + const draft = draftResult.finalOutput ?? ''; + + const polishResult = await runner.run(polishAgent, draft); + return polishResult.finalOutput ?? ''; +} + +export async function parallelization(prompt: string): Promise { + const agent1 = new Agent({ + name: 'Perspective1Agent', + instructions: 'Answer the question from a technical perspective.', + }); + const agent2 = new Agent({ + name: 'Perspective2Agent', + instructions: 'Answer the question from a business perspective.', + }); + const agent3 = new Agent({ + name: 'Perspective3Agent', + instructions: 'Answer the question from a creative perspective.', + }); + const judgeAgent = new Agent({ + name: 'JudgeAgent', + instructions: 'You are given multiple candidate answers. Select the best one and return it verbatim.', + }); + + const runner = new TemporalOpenAIRunner(); + + const [r1, r2, r3] = await Promise.all([ + runner.run(agent1, prompt), + runner.run(agent2, prompt), + runner.run(agent3, prompt), + ]); + + const candidates = [r1.finalOutput ?? '', r2.finalOutput ?? '', r3.finalOutput ?? '']; + const judgeInput = candidates.map((c, i) => `Candidate ${i + 1}:\n${c}`).join('\n\n'); + + const judgeResult = await runner.run(judgeAgent, judgeInput); + return judgeResult.finalOutput ?? ''; +} + +export async function llmAsJudge(prompt: string): Promise { + const generatorAgent = new Agent({ + name: 'GeneratorAgent', + instructions: 'Generate a response to the given prompt.', + }); + const judgeAgent = new Agent({ + name: 'JudgeAgent', + instructions: + 'Evaluate the response. Reply with exactly "APPROVED" if it is good, or "REJECTED: " if it needs improvement.', + }); + + const runner = new TemporalOpenAIRunner(); + const maxIterations = 3; + let output = ''; + + for (let i = 0; i < maxIterations; i++) { + const genResult = await runner.run(generatorAgent, i === 0 ? prompt : `Improve: ${output}`); + output = genResult.finalOutput ?? ''; + + const judgeResult = await runner.run(judgeAgent, output); + const judgment = judgeResult.finalOutput ?? ''; + + if (judgment.startsWith('APPROVED')) { + return output; + } + } + + return output; +} + +export async function agentsAsTools(prompt: string): Promise { + const specialistAgent = new Agent({ + name: 'SpecialistAgent', + instructions: 'You are a specialist. Answer questions concisely.', + }); + + const specialistTool = agentAsTool(specialistAgent, { + toolName: 'ask_specialist', + toolDescription: 'Ask the specialist agent a question and get a concise answer.', + }); + + const orchestratorAgent = new Agent({ + name: 'OrchestratorAgent', + instructions: + 'You orchestrate tasks. Use the ask_specialist tool to get answers, then synthesize a final response.', + tools: [specialistTool], + }); + + const runner = new TemporalOpenAIRunner(); + const result = await runner.run(orchestratorAgent, prompt); + return result.finalOutput ?? ''; +} + +export async function inputGuardrail(prompt: string): Promise { + const blockedKeywords = ['blocked', 'BLOCK', 'forbidden']; + + const guardrail: InputGuardrail = { + name: 'keyword-guardrail', + execute: async (args: InputGuardrailFunctionArgs): Promise => { + const inputText = typeof args.input === 'string' ? args.input : JSON.stringify(args.input); + const tripped = blockedKeywords.some((kw) => inputText.includes(kw)); + return { tripwireTriggered: tripped, outputInfo: { checked: inputText } }; + }, + }; + + const agent = new Agent({ + name: 'GuardedAgent', + instructions: 'You are a helpful assistant.', + }); + + const runner = new TemporalOpenAIRunner(); + try { + const result = await runner.run(agent, prompt, { + runConfig: { inputGuardrails: [guardrail] }, + }); + return result.finalOutput ?? ''; + } catch (err) { + const tripwire = + err instanceof InputGuardrailTripwireTriggered + ? err + : err instanceof ApplicationFailure && err.cause instanceof InputGuardrailTripwireTriggered + ? err.cause + : undefined; + if (tripwire) { + return `BLOCKED: input failed guardrail "${tripwire.result.guardrail.name}"`; + } + throw err; + } +} + +export async function outputGuardrail(prompt: string): Promise { + const bannedPhrase = 'UNSAFE'; + + const guardrail: OutputGuardrail = { + name: 'output-safety-guardrail', + execute: async (args: OutputGuardrailFunctionArgs): Promise => { + const outputText = typeof args.agentOutput === 'string' ? args.agentOutput : JSON.stringify(args.agentOutput); + const tripped = outputText.includes(bannedPhrase); + return { tripwireTriggered: tripped, outputInfo: { checked: outputText } }; + }, + }; + + const agent = new Agent({ + name: 'GuardedOutputAgent', + instructions: 'You are a helpful assistant.', + }); + + const runner = new TemporalOpenAIRunner(); + try { + const result = await runner.run(agent, prompt, { + runConfig: { outputGuardrails: [guardrail] }, + }); + return result.finalOutput ?? ''; + } catch (err) { + const tripwire = + err instanceof OutputGuardrailTripwireTriggered + ? err + : err instanceof ApplicationFailure && err.cause instanceof OutputGuardrailTripwireTriggered + ? err.cause + : undefined; + if (tripwire) { + return `BLOCKED: output failed guardrail "${tripwire.result.guardrail.name}"`; + } + throw err; + } +} diff --git a/openai-agents/src/basic/README.md b/openai-agents/src/basic/README.md new file mode 100644 index 00000000..f8e7e8ad --- /dev/null +++ b/openai-agents/src/basic/README.md @@ -0,0 +1,41 @@ +# OpenAI Agents: Basic + +Demonstrates the building blocks of the Temporal OpenAI Agents integration: a single agent, the +different ways to give it tools, run context, structured output, per-run model overrides, and +dynamic instructions — each running durably as a Temporal Workflow. + +Scenarios (`src/basic/workflows.ts`): + +- **hello-world** — a single agent that returns model text, with no tools. +- **tools** — an Activity-backed tool wired in with `activityAsTool` (getWeather). +- **inline-tool** — an inline deterministic `tool()` that runs inside the Workflow (adds two numbers). +- **local-activity-tool** — a tool backed by a local Activity via `proxyLocalActivities` (getHeadlines). +- **agent-context** — a tool reads `runContext.context` (the userId) and that value reaches the model. +- **structured-output** — an agent with a zod `outputType` that returns a typed object. +- **model-override** — `runConfig.model` overrides the model for a single run. +- **dynamic-instructions** — instructions computed from `runContext.context` (userName and style). + +## 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/basic/worker.ts + +# In another terminal, start a scenario: +npx ts-node src/basic/client.ts hello-world +npx ts-node src/basic/client.ts tools +npx ts-node src/basic/client.ts structured-output +``` + +## Test + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/basic/mocha/*.test.ts" +``` + +Tests run a real Worker against `TestWorkflowEnvironment` with a scripted fake model, so no +`OPENAI_API_KEY` is required. They assert each scenario's mechanism — that tools are invoked, that +context reaches the model, that structured output is typed, and that the model override and dynamic +instructions take effect. diff --git a/openai-agents/src/basic/activities.ts b/openai-agents/src/basic/activities.ts new file mode 100644 index 00000000..ed220cd0 --- /dev/null +++ b/openai-agents/src/basic/activities.ts @@ -0,0 +1,10 @@ +export async function getWeather(input: { city: string }): Promise { + return JSON.stringify({ city: input.city, temperature: '22C', conditions: 'Sunny' }); +} + +export async function getHeadlines(input: { topic: string }): Promise { + return JSON.stringify({ + topic: input.topic, + headlines: [`Breaking: ${input.topic} makes news`, `Update on ${input.topic}`], + }); +} diff --git a/openai-agents/src/basic/client.ts b/openai-agents/src/basic/client.ts new file mode 100644 index 00000000..03f8e640 --- /dev/null +++ b/openai-agents/src/basic/client.ts @@ -0,0 +1,83 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { + helloWorld, + tools, + inlineTool, + localActivityTool, + agentContext, + structuredOutput, + modelOverride, + dynamicInstructions, +} from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'hello-world'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-basic'; + const workflowId = 'openai-agents-' + nanoid(); + + let handle; + switch (scenario) { + case 'hello-world': + handle = await client.workflow.start(helloWorld, { taskQueue, workflowId, args: ['Say hello in one sentence.'] }); + break; + case 'tools': + handle = await client.workflow.start(tools, { taskQueue, workflowId, args: ['What is the weather in Tokyo?'] }); + break; + case 'inline-tool': + handle = await client.workflow.start(inlineTool, { taskQueue, workflowId, args: ['What is 42 plus 58?'] }); + break; + case 'local-activity-tool': + handle = await client.workflow.start(localActivityTool, { + taskQueue, + workflowId, + args: ['Get headlines about AI.'], + }); + break; + case 'agent-context': + handle = await client.workflow.start(agentContext, { taskQueue, workflowId, args: ['Who am I?'] }); + break; + case 'structured-output': + handle = await client.workflow.start(structuredOutput, { + taskQueue, + workflowId, + args: ['Temporal is a durable execution platform for building reliable distributed systems.'], + }); + break; + case 'model-override': + handle = await client.workflow.start(modelOverride, { + taskQueue, + workflowId, + args: ['Briefly explain what Temporal is.'], + }); + break; + case 'dynamic-instructions': + handle = await client.workflow.start(dynamicInstructions, { taskQueue, workflowId, args: ['Hello!'] }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/basic/mocha/fake-model.ts b/openai-agents/src/basic/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/basic/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/basic/mocha/workflows.test.ts b/openai-agents/src/basic/mocha/workflows.test.ts new file mode 100644 index 00000000..77021024 --- /dev/null +++ b/openai-agents/src/basic/mocha/workflows.test.ts @@ -0,0 +1,190 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import * as activities from '../activities'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { + helloWorld, + tools, + inlineTool, + localActivityTool, + agentContext, + structuredOutput, + modelOverride, + dynamicInstructions, +} from '../workflows'; + +describe('openai-agents/basic workflow scenarios', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('helloWorld: returns model text output', async () => { + const taskQueue = 'test-hello-world'; + const { worker } = await makeWorker(taskQueue, [textResponse('Hello from the fake model!')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(helloWorld, { + args: ['Say hello.'], + workflowId: 'test-hello-world-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Hello from the fake model!'); + }); + + it('tools: activityAsTool round-trip', async () => { + const taskQueue = 'test-tools'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('getWeather', { city: 'Tokyo' }), + textResponse('It is sunny in Tokyo.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(tools, { + args: ['What is the weather in Tokyo?'], + workflowId: 'test-tools-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'It is sunny in Tokyo.'); + }); + + it('inlineTool: inline tool round-trip', async () => { + const taskQueue = 'test-inline-tool'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('add', { a: 3, b: 4 }), + textResponse('3 plus 4 equals 7.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(inlineTool, { + args: ['What is 3 + 4?'], + workflowId: 'test-inline-tool-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, '3 plus 4 equals 7.'); + }); + + it('localActivityTool: local activity tool round-trip', async () => { + const taskQueue = 'test-local-activity-tool'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('getHeadlines', { topic: 'AI' }), + textResponse('Here are the latest AI headlines.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(localActivityTool, { + args: ['Get headlines about AI.'], + workflowId: 'test-local-activity-tool-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Here are the latest AI headlines.'); + }); + + it('agentContext: tool reads runContext.context and value reaches the model', async () => { + const taskQueue = 'test-agent-context'; + const { worker, provider } = await makeWorker(taskQueue, [ + toolCallResponse('whoAmI', {}), + textResponse('You are user-42.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(agentContext, { + args: ['Who am I?'], + workflowId: 'test-agent-context-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'You are user-42.'); + + // The whoAmI tool returns runContext.context.userId; its result is sent back + // to the model on the second turn as a function_call_result item, proving the + // context-derived value flowed through the tool to the model. + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && item.name === 'whoAmI'); + assert.strictEqual(toolResults.length, 1, 'whoAmI tool result should reach the model exactly once'); + assert.deepStrictEqual((toolResults[0] as { output: unknown }).output, { type: 'text', text: 'user-42' }); + }); + + it('structuredOutput: returns typed object as JSON', async () => { + const taskQueue = 'test-structured-output'; + const payload = { title: 'Temporal', summary: 'A durable execution platform.', keywords: ['temporal', 'workflow'] }; + const { worker } = await makeWorker(taskQueue, [textResponse(JSON.stringify(payload))]); + const raw = await worker.runUntil( + testEnv.client.workflow.execute(structuredOutput, { + args: ['Temporal is a durable execution platform for building reliable distributed systems.'], + workflowId: 'test-structured-output-' + Date.now(), + taskQueue, + }), + ); + const parsed = JSON.parse(raw); + assert.strictEqual(parsed.title, 'Temporal'); + assert.ok(typeof parsed.summary === 'string'); + assert.ok(Array.isArray(parsed.keywords)); + }); + + it('modelOverride: runConfig.model is the model resolved by the provider', async () => { + const taskQueue = 'test-model-override'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Temporal is a workflow engine.')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(modelOverride, { + args: ['Briefly explain what Temporal is.'], + workflowId: 'test-model-override-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Temporal is a workflow engine.'); + assert.deepStrictEqual(provider.requestedModelNames, ['gpt-4o-mini']); + }); + + it('dynamicInstructions: context-derived instruction reaches the model', async () => { + const taskQueue = 'test-dynamic-instructions'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Hello back!')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(dynamicInstructions, { + args: ['Hello!'], + workflowId: 'test-dynamic-instructions-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Hello back!'); + + // The instructions function computes the system prompt from runContext.context + // ({ userName: 'Ada', style: 'concise' }); assert that resolved prompt reached the model. + const systemInstructions = provider.model.requests[0]?.systemInstructions; + assert.strictEqual( + systemInstructions, + 'You are a helpful assistant. Address the user as Ada and respond in a concise style.', + ); + }); +}); diff --git a/openai-agents/src/basic/worker.ts b/openai-agents/src/basic/worker.ts new file mode 100644 index 00000000..d90f5327 --- /dev/null +++ b/openai-agents/src/basic/worker.ts @@ -0,0 +1,44 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import * as activities from './activities'; + +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-basic', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/basic/workflows.ts b/openai-agents/src/basic/workflows.ts new file mode 100644 index 00000000..45f6aef0 --- /dev/null +++ b/openai-agents/src/basic/workflows.ts @@ -0,0 +1,135 @@ +import { Agent, RunContext, tool } from '@openai/agents-core'; +import { TemporalOpenAIRunner, activityAsTool } from '@temporalio/openai-agents/workflow'; +import { proxyLocalActivities } from '@temporalio/workflow'; +import z from 'zod'; +import type * as activities from './activities'; + +const localActivities = proxyLocalActivities({ startToCloseTimeout: '10 seconds' }); + +export async function helloWorld(prompt: string): Promise { + const agent = new Agent({ name: 'HelloAgent', instructions: 'You are a helpful assistant.' }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function tools(prompt: string): Promise { + const weatherTool = activityAsTool( + { + name: 'getWeather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + additionalProperties: false, + }, + }, + { startToCloseTimeout: '1 minute' }, + ); + + const agent = new Agent({ + name: 'WeatherAgent', + instructions: 'You are a helpful weather assistant. Always use the getWeather tool to answer weather questions.', + tools: [weatherTool], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function inlineTool(prompt: string): Promise { + const addTool = tool({ + name: 'add', + description: 'Add two numbers together.', + parameters: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number') }), + execute: async ({ a, b }) => String(a + b), + }); + + const agent = new Agent({ + name: 'MathAgent', + instructions: 'You are a math assistant. Use the add tool to compute sums.', + tools: [addTool], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function localActivityTool(prompt: string): Promise { + const headlinesTool = tool({ + name: 'getHeadlines', + description: 'Get the latest headlines for a topic.', + parameters: z.object({ topic: z.string().describe('The topic to get headlines for') }), + execute: async ({ topic }) => localActivities.getHeadlines({ topic }), + }); + + const agent = new Agent({ + name: 'NewsAgent', + instructions: 'You are a news assistant. Use the getHeadlines tool to fetch headlines.', + tools: [headlinesTool], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function agentContext(prompt: string): Promise { + interface AppContext { + userId: string; + } + + const whoAmITool = tool({ + name: 'whoAmI', + description: 'Returns the current user ID from context.', + parameters: z.object({}), + execute: async (_args, runCtx?: RunContext) => runCtx?.context.userId ?? 'unknown', + }); + + const agent = new Agent({ + name: 'ContextAgent', + instructions: 'You are a helpful assistant. Use the whoAmI tool to identify the user.', + tools: [whoAmITool], + }); + + const result = await new TemporalOpenAIRunner().run(agent, prompt, { context: { userId: 'user-42' } }); + return result.finalOutput ?? ''; +} + +const SummarySchema = z.object({ + title: z.string().describe('Short title'), + summary: z.string().describe('One sentence summary'), + keywords: z.array(z.string()).describe('Key terms'), +}); + +export async function structuredOutput(prompt: string): Promise { + const agent = new Agent({ + name: 'SummaryAgent', + instructions: 'Summarize the provided text as structured JSON matching the output schema.', + outputType: SummarySchema, + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return JSON.stringify(result.finalOutput); +} + +export async function modelOverride(prompt: string): Promise { + const agent = new Agent({ name: 'OverrideAgent', instructions: 'You are a helpful assistant.' }); + const result = await new TemporalOpenAIRunner().run(agent, prompt, { + runConfig: { model: 'gpt-4o-mini' }, + }); + return result.finalOutput ?? ''; +} + +export async function dynamicInstructions(prompt: string): Promise { + interface UserContext { + userName: string; + style: string; + } + + const agent = new Agent({ + name: 'DynamicAgent', + instructions: (runContext: RunContext, _agent: Agent) => + `You are a helpful assistant. Address the user as ${runContext.context.userName} and respond in a ${runContext.context.style} style.`, + }); + + const result = await new TemporalOpenAIRunner().run(agent, prompt, { + context: { userName: 'Ada', style: 'concise' }, + }); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/handoffs/README.md b/openai-agents/src/handoffs/README.md new file mode 100644 index 00000000..917ddadf --- /dev/null +++ b/openai-agents/src/handoffs/README.md @@ -0,0 +1,36 @@ +# OpenAI Agents: Handoffs + +Demonstrates agent handoffs with the Temporal OpenAI Agents integration. A triage agent routes +each request to one of two specialist agents (billing, support), running durably as a Temporal +Workflow. + +Scenarios (`src/handoffs/workflows.ts`): + +- **agent-handoffs** — handoffs declared as a plain `Agent[]`. +- **handoff-function** — handoffs declared with the `handoff()` form. +- **handoff-with-filter** — a per-handoff `inputFilter` that strips the current turn's items + (the handoff `function_call_result`) before the specialist runs. + +## 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/handoffs/worker.ts + +# In another terminal, start a scenario: +npx ts-node src/handoffs/client.ts agent-handoffs +npx ts-node src/handoffs/client.ts handoff-function +npx ts-node src/handoffs/client.ts handoff-with-filter +``` + +## Test + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/handoffs/mocha/*.test.ts" +``` + +Tests run a real Worker against `TestWorkflowEnvironment` with a scripted fake model, so no +`OPENAI_API_KEY` is required. They assert that triage transfers to the correct specialist and that +the input filter removes the handoff tool result from the specialist's model input. diff --git a/openai-agents/src/handoffs/activities.ts b/openai-agents/src/handoffs/activities.ts new file mode 100644 index 00000000..7b8577b1 --- /dev/null +++ b/openai-agents/src/handoffs/activities.ts @@ -0,0 +1,3 @@ +export async function lookupAccount(input: { accountId: string }): Promise { + return JSON.stringify({ accountId: input.accountId, status: 'active', plan: 'pro' }); +} diff --git a/openai-agents/src/handoffs/client.ts b/openai-agents/src/handoffs/client.ts new file mode 100644 index 00000000..965db30c --- /dev/null +++ b/openai-agents/src/handoffs/client.ts @@ -0,0 +1,59 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { agentHandoffs, handoffFunction, handoffWithFilter } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'agent-handoffs'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-handoffs'; + const workflowId = 'openai-agents-' + nanoid(); + + let handle; + switch (scenario) { + case 'agent-handoffs': + handle = await client.workflow.start(agentHandoffs, { + taskQueue, + workflowId, + args: ['I have a billing question about my invoice.'], + }); + break; + case 'handoff-function': + handle = await client.workflow.start(handoffFunction, { + taskQueue, + workflowId, + args: ['I need help with a support issue.'], + }); + break; + case 'handoff-with-filter': + handle = await client.workflow.start(handoffWithFilter, { + taskQueue, + workflowId, + args: ['I have a billing question about my invoice.'], + }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/handoffs/mocha/fake-model.ts b/openai-agents/src/handoffs/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/handoffs/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/handoffs/mocha/workflows.test.ts b/openai-agents/src/handoffs/mocha/workflows.test.ts new file mode 100644 index 00000000..49b692d5 --- /dev/null +++ b/openai-agents/src/handoffs/mocha/workflows.test.ts @@ -0,0 +1,120 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import * as activities from '../activities'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { agentHandoffs, handoffFunction, handoffWithFilter } from '../workflows'; + +describe('openai-agents/handoffs workflow scenarios', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('agentHandoffs: triage routes to billing specialist via Agent[] handoffs', async () => { + const taskQueue = 'test-agent-handoffs'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('transfer_to_billing', {}), + textResponse('Your invoice has been processed.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(agentHandoffs, { + args: ['I have a billing question about my invoice.'], + workflowId: 'test-agent-handoffs-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Your invoice has been processed.'); + }); + + it('agentHandoffs: triage routes to support specialist via Agent[] handoffs', async () => { + const taskQueue = 'test-agent-handoffs-support'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('transfer_to_support', {}), + textResponse('Your support ticket has been filed.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(agentHandoffs, { + args: ['I need help with a support issue.'], + workflowId: 'test-agent-handoffs-support-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Your support ticket has been filed.'); + }); + + it('handoffFunction: triage routes to billing specialist via handoff() function', async () => { + const taskQueue = 'test-handoff-function'; + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('transfer_to_billing', {}), + textResponse('Billing handled via handoff function.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(handoffFunction, { + args: ['I have a billing question.'], + workflowId: 'test-handoff-function-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Billing handled via handoff function.'); + }); + + it('handoffWithFilter: filter drops newItems so specialist sees no handoff function_call_result', async () => { + const taskQueue = 'test-handoff-with-filter'; + const { worker, provider } = await makeWorker(taskQueue, [ + toolCallResponse('transfer_to_billing', {}), + textResponse('Billing answer after filtered handoff.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(handoffWithFilter, { + args: ['I have a billing question.'], + workflowId: 'test-handoff-with-filter-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Billing answer after filtered handoff.'); + + assert.strictEqual(provider.model.requests.length, 2, 'expected exactly two model requests'); + + const specialistRequest = provider.model.requests[1]!; + const inputItems = Array.isArray(specialistRequest.input) ? specialistRequest.input : []; + const handoffResultItems = inputItems.filter( + (item) => item.type === 'function_call_result' && (item as { name?: string }).name === 'transfer_to_billing', + ); + assert.strictEqual( + handoffResultItems.length, + 0, + 'filter should have removed the transfer_to_billing function_call_result from the specialist input', + ); + }); +}); diff --git a/openai-agents/src/handoffs/worker.ts b/openai-agents/src/handoffs/worker.ts new file mode 100644 index 00000000..dc934ae2 --- /dev/null +++ b/openai-agents/src/handoffs/worker.ts @@ -0,0 +1,44 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import * as activities from './activities'; + +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-handoffs', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/handoffs/workflows.ts b/openai-agents/src/handoffs/workflows.ts new file mode 100644 index 00000000..84763281 --- /dev/null +++ b/openai-agents/src/handoffs/workflows.ts @@ -0,0 +1,79 @@ +import { Agent, handoff } from '@openai/agents-core'; +import type { HandoffInputData } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; + +export async function agentHandoffs(prompt: string): Promise { + const billingAgent = new Agent({ + name: 'billing', + instructions: 'You are a billing specialist. Help with billing questions.', + }); + + const supportAgent = new Agent({ + name: 'support', + instructions: 'You are a support specialist. Help with support questions.', + }); + + const triageAgent = new Agent({ + name: 'triage', + instructions: 'You are a triage agent. Route billing questions to billing and support questions to support.', + handoffs: [billingAgent, supportAgent], + }); + + const runner = new TemporalOpenAIRunner(); + const result = await runner.run(triageAgent, prompt); + return result.finalOutput ?? ''; +} + +export async function handoffFunction(prompt: string): Promise { + const billingAgent = new Agent({ + name: 'billing', + instructions: 'You are a billing specialist. Help with billing questions.', + }); + + const supportAgent = new Agent({ + name: 'support', + instructions: 'You are a support specialist. Help with support questions.', + }); + + const triageAgent = new Agent({ + name: 'triage', + instructions: 'You are a triage agent. Route billing questions to billing and support questions to support.', + handoffs: [handoff(billingAgent), handoff(supportAgent)], + }); + + const runner = new TemporalOpenAIRunner(); + const result = await runner.run(triageAgent, prompt); + return result.finalOutput ?? ''; +} + +function dropNewItems(data: HandoffInputData): HandoffInputData { + return { + ...data, + newItems: [], + }; +} + +export async function handoffWithFilter(prompt: string): Promise { + const billingAgent = new Agent({ + name: 'billing', + instructions: 'You are a billing specialist. Help with billing questions.', + }); + + const supportAgent = new Agent({ + name: 'support', + instructions: 'You are a support specialist. Help with support questions.', + }); + + const triageAgent = new Agent({ + name: 'triage', + instructions: 'You are a triage agent. Route billing questions to billing and support questions to support.', + handoffs: [ + handoff(billingAgent, { inputFilter: dropNewItems }), + handoff(supportAgent, { inputFilter: dropNewItems }), + ], + }); + + const runner = new TemporalOpenAIRunner(); + const result = await runner.run(triageAgent, prompt); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/hosted-mcp/README.md b/openai-agents/src/hosted-mcp/README.md new file mode 100644 index 00000000..5c3288c4 --- /dev/null +++ b/openai-agents/src/hosted-mcp/README.md @@ -0,0 +1,56 @@ +# openai-agents/hosted-mcp + +Demonstrates using a `HostedMCPTool` from the OpenAI Agents SDK inside a Temporal Workflow with the +`@temporalio/openai-agents` plugin. A hosted MCP tool is executed server-side by the model: the +model itself calls the remote hosted MCP server during a model request, so there is no local MCP +server to run. + +Two workflows are included: + +- **simple** — an agent carrying a `hostedMcpTool` with `requireApproval: 'never'`. The model is + free to call the hosted MCP server without any approval round trip. +- **approval** — an agent carrying a `hostedMcpTool` with `requireApproval: 'always'`. Approval is + driven by a Temporal Signal (`approvalDecision`, carrying a boolean). The Workflow waits for the + Signal before running the agent, and the tool's `onApproval` callback resolves approve/reject from + the signaled decision. + +The runnable sample points at the public [GitMCP](https://gitmcp.io) hosted MCP server +(`serverLabel: 'gitmcp'`, `serverUrl: 'https://gitmcp.io/openai/codex'`). + +## Live-only requirement + +Running the worker and client makes real model calls against OpenAI, and the model in turn calls the +public GitMCP server. This sample therefore requires: + +- A real `OPENAI_API_KEY` environment variable. +- Outbound network access to OpenAI and to `https://gitmcp.io`. + +The included tests do not exercise any of this. They use a fake model provider, make no network +calls, and assert wiring/completion only (that the agent carries the hosted MCP tool and the +Workflow runs to completion). + +## Run + +1. Start a Temporal dev server: + + ```sh + temporal server start-dev + ``` + +2. In another terminal, start the worker (run from the `openai-agents/` root, after `npm install` there): + + ```sh + export OPENAI_API_KEY=sk-... + npx ts-node src/hosted-mcp/worker.ts + ``` + +3. In a third terminal, run a workflow: + + ```sh + export OPENAI_API_KEY=sk-... + npx ts-node src/hosted-mcp/client.ts simple + npx ts-node src/hosted-mcp/client.ts approval + ``` + +The `approval` client starts the Workflow and then sends the `approvalDecision` Signal with `true` +so the demo completes. diff --git a/openai-agents/src/hosted-mcp/client.ts b/openai-agents/src/hosted-mcp/client.ts new file mode 100644 index 00000000..38dc2256 --- /dev/null +++ b/openai-agents/src/hosted-mcp/client.ts @@ -0,0 +1,53 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { simple, approval, approvalDecision } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'simple'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-hosted-mcp'; + const workflowId = 'openai-agents-' + nanoid(); + + let handle; + switch (scenario) { + case 'simple': + handle = await client.workflow.start(simple, { + taskQueue, + workflowId, + args: ['What does the openai/codex project do? Use the hosted MCP tools to find out.'], + }); + break; + case 'approval': + handle = await client.workflow.start(approval, { + taskQueue, + workflowId, + args: ['What does the openai/codex project do? Use the hosted MCP tools to find out.'], + }); + await handle.signal(approvalDecision, true); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/hosted-mcp/mocha/fake-model.ts b/openai-agents/src/hosted-mcp/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/hosted-mcp/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/hosted-mcp/mocha/workflows.test.ts b/openai-agents/src/hosted-mcp/mocha/workflows.test.ts new file mode 100644 index 00000000..fdc2bdb9 --- /dev/null +++ b/openai-agents/src/hosted-mcp/mocha/workflows.test.ts @@ -0,0 +1,83 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse } from './fake-model'; +import { simple, approval, approvalDecision } from '../workflows'; + +describe('openai-agents/hosted-mcp workflow scenarios', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('simple: agent carrying the hosted MCP tool runs and returns model text', async () => { + const taskQueue = 'test-hosted-mcp-simple'; + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse('The openai/codex project is a coding agent.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(simple, { + args: ['What does openai/codex do?'], + workflowId: 'test-hosted-mcp-simple-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'The openai/codex project is a coding agent.'); + + // The hosted MCP tool serializes as a hosted_tool named 'hosted_mcp'; asserting it + // is present in the first request's tool list proves the agent was wired with it. + const hostedTools = provider.model.requests[0]?.tools.filter((t) => t.name === 'hosted_mcp'); + assert.strictEqual(hostedTools?.length, 1, 'request should carry exactly one hosted_mcp tool'); + }); + + it('approval: workflow consumes the approval signal, runs, and returns model text', async () => { + const taskQueue = 'test-hosted-mcp-approval'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Approved and answered.')]); + + const result = await worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(approval, { + args: ['What does openai/codex do?'], + workflowId: 'test-hosted-mcp-approval-' + Date.now(), + taskQueue, + }); + // The workflow gates the run on the approval signal; without this the run never + // starts, so a returned result proves the signal was received and consumed. + await handle.signal(approvalDecision, true); + return handle.result(); + }); + + assert.strictEqual(result, 'Approved and answered.'); + + const hostedTools = provider.model.requests[0]?.tools.filter((t) => t.name === 'hosted_mcp'); + assert.strictEqual(hostedTools?.length, 1, 'request should carry exactly one hosted_mcp tool'); + }); +}); diff --git a/openai-agents/src/hosted-mcp/worker.ts b/openai-agents/src/hosted-mcp/worker.ts new file mode 100644 index 00000000..d936b1b3 --- /dev/null +++ b/openai-agents/src/hosted-mcp/worker.ts @@ -0,0 +1,42 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +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-hosted-mcp', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/hosted-mcp/workflows.ts b/openai-agents/src/hosted-mcp/workflows.ts new file mode 100644 index 00000000..db491433 --- /dev/null +++ b/openai-agents/src/hosted-mcp/workflows.ts @@ -0,0 +1,53 @@ +import { Agent, RunContext, RunToolApprovalItem } from '@openai/agents-core'; +import { hostedMcpTool } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import { condition, defineSignal, setHandler } from '@temporalio/workflow'; + +const HOSTED_MCP_SERVER_LABEL = 'gitmcp'; +const HOSTED_MCP_SERVER_URL = 'https://gitmcp.io/openai/codex'; + +export async function simple(prompt: string): Promise { + const agent = new Agent({ + name: 'HostedMcpSimpleAgent', + instructions: 'You are a helpful assistant. Use the hosted MCP tools when they are relevant.', + tools: [ + hostedMcpTool({ + serverLabel: HOSTED_MCP_SERVER_LABEL, + serverUrl: HOSTED_MCP_SERVER_URL, + requireApproval: 'never', + }), + ], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export const approvalDecision = defineSignal<[boolean]>('approvalDecision'); + +export async function approval(prompt: string): Promise { + let decision: boolean | undefined; + setHandler(approvalDecision, (approve: boolean) => { + decision = approve; + }); + + await condition(() => decision !== undefined); + + const agent = new Agent({ + name: 'HostedMcpApprovalAgent', + instructions: 'You are a helpful assistant. Use the hosted MCP tools when they are relevant.', + tools: [ + hostedMcpTool({ + serverLabel: HOSTED_MCP_SERVER_LABEL, + serverUrl: HOSTED_MCP_SERVER_URL, + requireApproval: 'always', + onApproval: async (_context: RunContext, _item: RunToolApprovalItem) => { + await condition(() => decision !== undefined); + return { approve: decision === true }; + }, + }), + ], + }); + + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/human-approval/README.md b/openai-agents/src/human-approval/README.md new file mode 100644 index 00000000..249e331a --- /dev/null +++ b/openai-agents/src/human-approval/README.md @@ -0,0 +1,36 @@ +# OpenAI Agents: Human Approval + +Demonstrates a human-in-the-loop approval flow with the Temporal OpenAI Agents integration. A tool +marked `needsApproval` pauses the agent run with an interruption; the Workflow waits for an +`approve` Signal, then resumes by serializing and rehydrating the run state across `continueAsNew`. + +Flow (`src/human-approval/workflows.ts`): + +1. The agent requests a `dangerousAction` tool call; because the tool sets `needsApproval`, the run + returns with `result.interruptions`. +2. The Workflow waits for the `approve` Signal. +3. On approval it continues as new with `resumeFromRunState: result.state.toString()`. +4. The resumed run rehydrates with `RunState.fromString`, calls `state.approve` for each + interruption, and re-runs to completion. + +## 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/human-approval/worker.ts + +# In another terminal, start the workflow (the client sends the approval Signal): +npx ts-node src/human-approval/client.ts +``` + +## Test + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/human-approval/mocha/*.test.ts" +``` + +The test runs a real Worker against `TestWorkflowEnvironment` with a scripted fake model, so no +`OPENAI_API_KEY` is required. It scripts a `dangerousAction` tool call to produce an interruption, +drives the `approve` Signal, and asserts the resumed run completes. diff --git a/openai-agents/src/human-approval/activities.ts b/openai-agents/src/human-approval/activities.ts new file mode 100644 index 00000000..5c9bbd88 --- /dev/null +++ b/openai-agents/src/human-approval/activities.ts @@ -0,0 +1 @@ +export async function placeholder(): Promise {} diff --git a/openai-agents/src/human-approval/client.ts b/openai-agents/src/human-approval/client.ts new file mode 100644 index 00000000..4b8a5102 --- /dev/null +++ b/openai-agents/src/human-approval/client.ts @@ -0,0 +1,40 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { approvalWorkflow } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'human-approval'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-human-approval'; + const workflowId = 'openai-agents-' + nanoid(); + + const handle = await client.workflow.start(approvalWorkflow, { + taskQueue, + workflowId, + args: [{}], + }); + + console.log(`Started workflow ${handle.workflowId}`); + console.log('Sending approval signal...'); + await handle.signal('approve'); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/human-approval/mocha/fake-model.ts b/openai-agents/src/human-approval/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/human-approval/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/human-approval/mocha/workflows.test.ts b/openai-agents/src/human-approval/mocha/workflows.test.ts new file mode 100644 index 00000000..4b92806b --- /dev/null +++ b/openai-agents/src/human-approval/mocha/workflows.test.ts @@ -0,0 +1,68 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import * as activities from '../activities'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { approvalWorkflow } from '../workflows'; + +describe('openai-agents/human-approval workflow scenarios', function () { + this.timeout(60_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('approvalWorkflow: interruption produced, approval signal triggers continueAsNew, resumed run completes', async () => { + const taskQueue = 'test-human-approval'; + + // Two model turns total across both workflow runs: + // Turn 1 (original run): model requests dangerousAction → needsApproval interruption + // Turn 2 (resumed run after continueAsNew): tool executed, result returned to model → final text + const { worker } = await makeWorker(taskQueue, [ + toolCallResponse('dangerousAction', { reason: 'demo' }), + textResponse('Action completed successfully.'), + ]); + + const result = await worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(approvalWorkflow, { + args: [{}], + workflowId: 'test-human-approval-' + Date.now(), + taskQueue, + }); + + await handle.signal('approve'); + return handle.result(); + }); + + assert.strictEqual(result, 'Action completed successfully.'); + }); +}); diff --git a/openai-agents/src/human-approval/worker.ts b/openai-agents/src/human-approval/worker.ts new file mode 100644 index 00000000..21f5d690 --- /dev/null +++ b/openai-agents/src/human-approval/worker.ts @@ -0,0 +1,44 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import * as activities from './activities'; + +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-human-approval', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/human-approval/workflows.ts b/openai-agents/src/human-approval/workflows.ts new file mode 100644 index 00000000..1bee9b39 --- /dev/null +++ b/openai-agents/src/human-approval/workflows.ts @@ -0,0 +1,59 @@ +import { Agent, RunState, tool } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import { condition, continueAsNew, defineSignal, setHandler } from '@temporalio/workflow'; + +export const approveSignal = defineSignal('approve'); + +export interface ApprovalInput { + resumeFromRunState?: string; +} + +export async function approvalWorkflow(input: ApprovalInput = {}): Promise { + const action = tool({ + name: 'dangerousAction', + description: 'Performs a dangerous action that requires human approval before execution.', + parameters: { + type: 'object', + properties: { + reason: { type: 'string', description: 'The reason for performing the dangerous action.' }, + }, + required: ['reason'], + additionalProperties: false, + } as const, + needsApproval: true, + execute: async (args) => `did: ${(args as { reason: string }).reason}`, + }); + + const agent = new Agent({ + name: 'Approver', + instructions: "You carry out the user's request using the dangerousAction tool.", + tools: [action], + modelSettings: { toolChoice: 'required' }, + }); + + const runner = new TemporalOpenAIRunner(); + + if (input.resumeFromRunState !== undefined) { + const state = await RunState.fromString(agent, input.resumeFromRunState); + for (const interruption of state.getInterruptions()) { + state.approve(interruption); + } + const resumed = await runner.run(agent, state); + return resumed.finalOutput ?? ''; + } + + let approved = false; + setHandler(approveSignal, () => { + approved = true; + }); + + const result = await runner.run(agent, 'Delete the old backup files.'); + + if (result.interruptions.length === 0) { + return result.finalOutput ?? ''; + } + + await condition(() => approved); + await continueAsNew({ resumeFromRunState: result.state.toString() }); + throw new Error('unreachable'); +} diff --git a/openai-agents/src/mcp/README.md b/openai-agents/src/mcp/README.md new file mode 100644 index 00000000..ba8b2eee --- /dev/null +++ b/openai-agents/src/mcp/README.md @@ -0,0 +1,47 @@ +# openai-agents/mcp + +Demonstrates stateless and stateful MCP (Model Context Protocol) servers used by a Temporal-backed +OpenAI agent through the `@temporalio/openai-agents` plugin. Every MCP server in this sample is +bundled and runs locally — either in-process or as a localhost/subprocess server the sample starts +itself — so no external MCP service is required. + +Five scenarios are included: + +- `filesystem` — stateless MCP over stdio; a bundled stdio server (`src/mcp/servers/filesystem-server.ts`) + exposes `listFiles`/`readFile` over `src/mcp/servers/sample-files/`. +- `streamable-http` — stateless MCP over a localhost Streamable-HTTP server (`src/mcp/servers/tools-server.ts`) + with `add`/`getWeather`/`getSecret` tools. +- `sse` — stateless MCP over a localhost SSE server (`src/mcp/servers/sse-server.ts`) with the same tools. +- `prompt-server` — stateless MCP server (`src/mcp/servers/prompt-server.ts`) exposing a `summarize` + prompt; the workflow fetches the prompt and uses it as the agent's instructions. +- `stateful-memory` — a `StatefulMCPServerProvider` notes server (`src/mcp/servers/notes-server.ts`) with + `saveNote`/`listNotes`/`readNote`; the workflow calls `connect()`/`cleanup()` to keep server state + for the run. + +The worker requires `OPENAI_API_KEY`. The included tests use a fake model provider, make zero external +network calls, and drive the bundled MCP servers locally to assert that tool results flow back. + +## Run + +1. Start a Temporal dev server: + + ```sh + temporal server start-dev + ``` + +2. In another terminal, start the worker (run from the `openai-agents/` root, after `npm install` there): + + ```sh + export OPENAI_API_KEY=sk-... + npx ts-node src/mcp/worker.ts + ``` + +3. In a third terminal, run a workflow: + + ```sh + npx ts-node src/mcp/client.ts filesystem + npx ts-node src/mcp/client.ts streamable-http + npx ts-node src/mcp/client.ts sse + npx ts-node src/mcp/client.ts prompt-server + npx ts-node src/mcp/client.ts stateful-memory + ``` diff --git a/openai-agents/src/mcp/activities.ts b/openai-agents/src/mcp/activities.ts new file mode 100644 index 00000000..5b9fdc39 --- /dev/null +++ b/openai-agents/src/mcp/activities.ts @@ -0,0 +1,24 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +export function makeActivities(promptServerUrl: string) { + return { + async fetchSummarizePrompt(): Promise { + const client = new Client({ name: 'prompt-activity-client', version: '1.0.0' }); + const transport = new StreamableHTTPClientTransport(new URL(promptServerUrl)); + await client.connect(transport); + try { + const result = await client.getPrompt({ name: 'summarize' }); + const textContent = result.messages.find((m) => m.content.type === 'text'); + if (!textContent || textContent.content.type !== 'text') { + throw new Error('summarize prompt returned no text content'); + } + return textContent.content.text; + } finally { + await client.close(); + } + }, + }; +} + +export type Activities = ReturnType; diff --git a/openai-agents/src/mcp/client.ts b/openai-agents/src/mcp/client.ts new file mode 100644 index 00000000..2a27298b --- /dev/null +++ b/openai-agents/src/mcp/client.ts @@ -0,0 +1,73 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { filesystem, streamableHttp, sse, promptServer, statefulMemory } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'filesystem'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-mcp'; + const workflowId = 'openai-agents-mcp-' + nanoid(); + + let handle; + switch (scenario) { + case 'filesystem': + handle = await client.workflow.start(filesystem, { + taskQueue, + workflowId, + args: ['List the files in the sample-files directory.'], + }); + break; + case 'streamable-http': + handle = await client.workflow.start(streamableHttp, { + taskQueue, + workflowId, + args: ['What is 17 plus 25?'], + }); + break; + case 'sse': + handle = await client.workflow.start(sse, { + taskQueue, + workflowId, + args: ['What is the weather in London?'], + }); + break; + case 'prompt-server': + handle = await client.workflow.start(promptServer, { + taskQueue, + workflowId, + args: ['Temporal is a durable execution platform for building reliable distributed systems.'], + }); + break; + case 'stateful-memory': + handle = await client.workflow.start(statefulMemory, { + taskQueue, + workflowId, + args: ['Save a note titled "greeting" with body "Hello, world!". Then list all notes.'], + }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/mcp/mocha/fake-model.ts b/openai-agents/src/mcp/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/mcp/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/mcp/mocha/workflows.test.ts b/openai-agents/src/mcp/mocha/workflows.test.ts new file mode 100644 index 00000000..aad518d7 --- /dev/null +++ b/openai-agents/src/mcp/mocha/workflows.test.ts @@ -0,0 +1,269 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin, StatelessMCPServerProvider, StatefulMCPServerProvider } from '@temporalio/openai-agents'; +import { MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE } from '@openai/agents-core'; +import assert from 'assert'; +import * as path from 'path'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { filesystem, streamableHttp, sse, promptServer, statefulMemory } from '../workflows'; +import { makeActivities } from '../activities'; +import { startToolsHttpServer } from '../servers/tools-server'; +import { startToolsSseServer } from '../servers/sse-server'; +import { startPromptHttpServer, SUMMARIZE_PROMPT_TEXT } from '../servers/prompt-server'; +import { createNotesServer } from '../servers/notes-server'; + +describe('openai-agents/mcp workflow scenarios', function () { + this.timeout(60_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + const webpackConfigHook = (config: object & { resolve?: { conditionNames?: string[] } }) => ({ + ...config, + resolve: { + ...(config.resolve ?? {}), + conditionNames: ['require', 'browser', 'default'], + }, + }); + + it('filesystem: MCP stdio tool result flows back to the model', async () => { + const taskQueue = 'test-mcp-filesystem'; + const filesystemServerPath = path.resolve(__dirname, '..', 'servers', 'filesystem-server.ts'); + const provider = new FakeModelProvider([ + toolCallResponse('listFiles', {}), + textResponse('The files are: hello.txt, notes.txt'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: provider, + mcpServerProviders: [ + new StatelessMCPServerProvider( + 'filesystem', + () => + new MCPServerStdio({ + command: 'npx', + args: ['ts-node', filesystemServerPath], + name: 'filesystem', + }), + ), + ], + }), + ], + bundlerOptions: { webpackConfigHook }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(filesystem, { + args: ['List the files.'], + workflowId: 'test-mcp-filesystem-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'The files are: hello.txt, notes.txt'); + + // The listFiles tool result must have been sent back to the model. + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && (item as { name: string }).name === 'listFiles'); + assert.strictEqual(toolResults.length, 1, 'listFiles tool result should reach the model exactly once'); + }); + + it('streamableHttp: MCP HTTP tool result flows back to the model', async () => { + const taskQueue = 'test-mcp-streamable-http'; + const toolsHttp = await startToolsHttpServer(); + + try { + const provider = new FakeModelProvider([ + toolCallResponse('add', { a: 17, b: 25 }), + textResponse('The answer is 42.'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: provider, + mcpServerProviders: [ + new StatelessMCPServerProvider( + 'streamableHttp', + () => new MCPServerStreamableHttp({ url: toolsHttp.url, name: 'streamableHttp' }), + ), + ], + }), + ], + bundlerOptions: { webpackConfigHook }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(streamableHttp, { + args: ['What is 17 plus 25?'], + workflowId: 'test-mcp-streamable-http-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'The answer is 42.'); + + // The add tool must have returned "42" to the model. + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && (item as { name: string }).name === 'add'); + assert.strictEqual(toolResults.length, 1, 'add tool result should reach the model exactly once'); + assert.deepStrictEqual((toolResults[0] as { output: unknown }).output, [{ type: 'input_text', text: '42' }]); + } finally { + await toolsHttp.close(); + } + }); + + it('sse: MCP SSE tool result flows back to the model', async () => { + const taskQueue = 'test-mcp-sse'; + const toolsSse = await startToolsSseServer(); + + try { + const provider = new FakeModelProvider([ + toolCallResponse('getSecret', {}), + textResponse('The secret is swordfish.'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: provider, + mcpServerProviders: [ + new StatelessMCPServerProvider('sse', () => new MCPServerSSE({ url: toolsSse.url, name: 'sse' })), + ], + }), + ], + bundlerOptions: { webpackConfigHook }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(sse, { + args: ['What is the secret?'], + workflowId: 'test-mcp-sse-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'The secret is swordfish.'); + + // The getSecret tool must have returned "swordfish" to the model. + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && (item as { name: string }).name === 'getSecret'); + assert.strictEqual(toolResults.length, 1, 'getSecret tool result should reach the model exactly once'); + assert.deepStrictEqual((toolResults[0] as { output: unknown }).output, [ + { type: 'input_text', text: 'swordfish' }, + ]); + } finally { + await toolsSse.close(); + } + }); + + it('promptServer: fetched prompt text becomes the agent instructions', async () => { + const taskQueue = 'test-mcp-prompt-server'; + const promptHttp = await startPromptHttpServer(); + + try { + const provider = new FakeModelProvider([textResponse('Temporal is a durable execution platform.')]); + + const activities = makeActivities(promptHttp.url); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { webpackConfigHook }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(promptServer, { + args: ['Temporal is a durable execution platform for building reliable distributed systems.'], + workflowId: 'test-mcp-prompt-server-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'Temporal is a durable execution platform.'); + + // The summarize prompt text must have been set as the agent's system instructions. + const systemInstructions = provider.model.requests[0]?.systemInstructions; + assert.strictEqual( + systemInstructions, + SUMMARIZE_PROMPT_TEXT, + 'fetched prompt text should become agent instructions (systemInstructions)', + ); + } finally { + await promptHttp.close(); + } + }); + + it('statefulMemory: state persists across multiple tool calls within the run', async () => { + const taskQueue = 'test-mcp-stateful-memory'; + const provider = new FakeModelProvider([ + // First turn: save a note + toolCallResponse('saveNote', { title: 'greeting', body: 'Hello, world!' }), + // Second turn: read the note back + toolCallResponse('readNote', { title: 'greeting' }), + // Final turn: text response + textResponse('I saved and read back: Hello, world!'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: provider, + mcpServerProviders: [ + new StatefulMCPServerProvider('memory', () => createNotesServer(), testEnv.nativeConnection), + ], + }), + ], + bundlerOptions: { webpackConfigHook }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(statefulMemory, { + args: ['Save a note titled "greeting" with body "Hello, world!" then read it back.'], + workflowId: 'test-mcp-stateful-memory-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'I saved and read back: Hello, world!'); + + // The readNote tool must have returned the saved note body to the model, + // proving that state persisted across the saveNote call within the same run. + const allInputItems = provider.model.requests.flatMap((req) => (Array.isArray(req.input) ? req.input : [])); + const readNoteResults = allInputItems.filter( + (item) => item.type === 'function_call_result' && (item as { name: string }).name === 'readNote', + ); + assert.strictEqual(readNoteResults.length, 1, 'readNote tool result should reach the model exactly once'); + assert.deepStrictEqual((readNoteResults[0] as { output: unknown }).output, [ + { type: 'input_text', text: 'Hello, world!' }, + ]); + }); +}); diff --git a/openai-agents/src/mcp/servers/filesystem-server.ts b/openai-agents/src/mcp/servers/filesystem-server.ts new file mode 100644 index 00000000..98b0ea47 --- /dev/null +++ b/openai-agents/src/mcp/servers/filesystem-server.ts @@ -0,0 +1,74 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const SAMPLE_FILES_DIR = path.resolve(__dirname, 'sample-files'); + +function createFilesystemServer(): Server { + const server = new Server({ name: 'filesystem', version: '1.0.0' }, { capabilities: { tools: {} } }); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'listFiles', + description: 'List all files in the sample-files directory.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [], + additionalProperties: false, + }, + }, + { + name: 'readFile', + description: 'Read the contents of a file in the sample-files directory.', + inputSchema: { + type: 'object' as const, + properties: { + filename: { type: 'string', description: 'The filename to read (basename only).' }, + }, + required: ['filename'], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + if (name === 'listFiles') { + const files = fs + .readdirSync(SAMPLE_FILES_DIR) + .filter((f) => fs.statSync(path.join(SAMPLE_FILES_DIR, f)).isFile()); + return { content: [{ type: 'text', text: files.join('\n') }] }; + } + if (name === 'readFile') { + const filename = String((args as Record)?.filename ?? ''); + const resolved = path.resolve(SAMPLE_FILES_DIR, filename); + if (!resolved.startsWith(SAMPLE_FILES_DIR + path.sep) && resolved !== SAMPLE_FILES_DIR) { + return { content: [{ type: 'text', text: 'Error: path escape not allowed.' }], isError: true }; + } + if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) { + return { content: [{ type: 'text', text: `Error: file not found: ${filename}` }], isError: true }; + } + const text = fs.readFileSync(resolved, 'utf-8'); + return { content: [{ type: 'text', text }] }; + } + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + }); + + return server; +} + +if (require.main === module) { + const server = createFilesystemServer(); + const transport = new StdioServerTransport(); + server.connect(transport).catch((err) => { + process.stderr.write(String(err) + '\n'); + process.exit(1); + }); +} + +export { createFilesystemServer }; diff --git a/openai-agents/src/mcp/servers/notes-server.ts b/openai-agents/src/mcp/servers/notes-server.ts new file mode 100644 index 00000000..77f6e94c --- /dev/null +++ b/openai-agents/src/mcp/servers/notes-server.ts @@ -0,0 +1,72 @@ +import type { StatefulTemporalMCPServer } from '@temporalio/openai-agents/workflow'; + +export function createNotesServer(): StatefulTemporalMCPServer { + const notes = new Map(); + + return { + cacheToolsList: false, + name: 'memory', + async connect() {}, + async close() {}, + async invalidateToolsCache() {}, + async cleanup() {}, + async listTools() { + return [ + { + name: 'saveNote', + description: 'Save a note with a title and body.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + body: { type: 'string' }, + }, + required: ['title', 'body'], + additionalProperties: false, + }, + }, + { + name: 'listNotes', + description: 'List all note titles saved in this session.', + inputSchema: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }, + }, + { + name: 'readNote', + description: 'Read the body of a saved note by title.', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string' }, + }, + required: ['title'], + additionalProperties: false, + }, + }, + ]; + }, + async callTool(toolName: string, args: Record | null) { + const a = args ?? {}; + if (toolName === 'saveNote') { + const title = String(a.title); + const body = String(a.body); + notes.set(title, body); + return [{ type: 'text', text: `Saved note "${title}".` }]; + } + if (toolName === 'listNotes') { + const titles = Array.from(notes.keys()); + return [{ type: 'text', text: titles.length ? titles.join(', ') : '(no notes yet)' }]; + } + if (toolName === 'readNote') { + const title = String(a.title); + const body = notes.get(title); + return [{ type: 'text', text: body ?? `(no note titled "${title}")` }]; + } + return [{ type: 'text', text: `Unknown tool: ${toolName}` }]; + }, + }; +} diff --git a/openai-agents/src/mcp/servers/prompt-server.ts b/openai-agents/src/mcp/servers/prompt-server.ts new file mode 100644 index 00000000..378ce12f --- /dev/null +++ b/openai-agents/src/mcp/servers/prompt-server.ts @@ -0,0 +1,103 @@ +import * as http from 'http'; +import * as crypto from 'crypto'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListPromptsRequestSchema, GetPromptRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +export const SUMMARIZE_PROMPT_TEXT = "You are a concise summarizer. Summarize the user's text in one sentence."; + +export async function startPromptHttpServer(port?: number): Promise<{ url: string; close(): Promise }> { + const transports = new Map(); + + function createSession(): { server: Server; transport: StreamableHTTPServerTransport } { + const server = new Server({ name: 'prompt-server', version: '1.0.0' }, { capabilities: { prompts: {} } }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) transports.delete(sid); + }; + + server.setRequestHandler(ListPromptsRequestSchema, async () => ({ + prompts: [ + { + name: 'summarize', + description: 'A prompt for concise text summarization.', + }, + ], + })); + + server.setRequestHandler(GetPromptRequestSchema, async (req) => { + if (req.params.name !== 'summarize') { + throw new Error(`Unknown prompt: ${req.params.name}`); + } + return { + messages: [ + { + role: 'user' as const, + content: { type: 'text' as const, text: SUMMARIZE_PROMPT_TEXT }, + }, + ], + }; + }); + + return { server, transport }; + } + + const httpServer = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf-8')) : undefined; + + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && transports.has(sessionId)) { + const transport = transports.get(sessionId)!; + await transport.handleRequest(req, res, body); + return; + } + + const isInit = body && typeof body === 'object' && !Array.isArray(body) && body.method === 'initialize'; + + if (isInit || !sessionId) { + const { server, transport } = createSession(); + await server.connect(transport); + await transport.handleRequest(req, res, body); + const sid = transport.sessionId; + if (sid) transports.set(sid, transport); + return; + } + + res.writeHead(404).end('Session not found'); + }); + + await new Promise((resolve) => { + httpServer.listen(port ?? 0, '127.0.0.1', resolve); + }); + + const addr = httpServer.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}/`; + + return { + url, + close: async () => { + await Promise.allSettled(Array.from(transports.values()).map((t) => t.close())); + await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve()))); + }, + }; +} + +if (require.main === module) { + startPromptHttpServer(3003) + .then(({ url }) => { + console.log(`Prompt HTTP MCP server listening at ${url}`); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/openai-agents/src/mcp/servers/sample-files/hello.txt b/openai-agents/src/mcp/servers/sample-files/hello.txt new file mode 100644 index 00000000..4fec89ed --- /dev/null +++ b/openai-agents/src/mcp/servers/sample-files/hello.txt @@ -0,0 +1,3 @@ +Hello from the MCP filesystem server! +This file is part of the sample-files directory. +It demonstrates the listFiles and readFile tools. diff --git a/openai-agents/src/mcp/servers/sample-files/notes.txt b/openai-agents/src/mcp/servers/sample-files/notes.txt new file mode 100644 index 00000000..1143080c --- /dev/null +++ b/openai-agents/src/mcp/servers/sample-files/notes.txt @@ -0,0 +1,3 @@ +Temporal is a durable execution platform. +Workflows are code that can survive process failures. +Activities are the building blocks for side effects. diff --git a/openai-agents/src/mcp/servers/sse-server.ts b/openai-agents/src/mcp/servers/sse-server.ts new file mode 100644 index 00000000..d7abb007 --- /dev/null +++ b/openai-agents/src/mcp/servers/sse-server.ts @@ -0,0 +1,125 @@ +import * as http from 'http'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const SSE_PATH = '/sse'; +const MESSAGES_PATH = '/messages'; + +function buildToolHandlers(server: Server): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add', + description: 'Add two numbers together.', + inputSchema: { + type: 'object' as const, + properties: { + a: { type: 'number', description: 'First number.' }, + b: { type: 'number', description: 'Second number.' }, + }, + required: ['a', 'b'], + additionalProperties: false, + }, + }, + { + name: 'getWeather', + description: 'Get canned weather data for a city.', + inputSchema: { + type: 'object' as const, + properties: { + city: { type: 'string', description: 'City name.' }, + }, + required: ['city'], + additionalProperties: false, + }, + }, + { + name: 'getSecret', + description: 'Get the secret passphrase.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + const a = (args ?? {}) as Record; + if (name === 'add') { + return { content: [{ type: 'text', text: String(Number(a.a) + Number(a.b)) }] }; + } + if (name === 'getWeather') { + const city = String(a.city ?? 'unknown'); + return { content: [{ type: 'text', text: JSON.stringify({ city, temperature: '22C', conditions: 'Sunny' }) }] }; + } + if (name === 'getSecret') { + return { content: [{ type: 'text', text: 'swordfish' }] }; + } + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + }); +} + +export async function startToolsSseServer(port?: number): Promise<{ url: string; close(): Promise }> { + const transports = new Map(); + + const httpServer = http.createServer(async (req, res) => { + if (req.method === 'GET' && req.url === SSE_PATH) { + const mcpServer = new Server({ name: 'tools-sse', version: '1.0.0' }, { capabilities: { tools: {} } }); + buildToolHandlers(mcpServer); + const transport = new SSEServerTransport(MESSAGES_PATH, res); + transports.set(transport.sessionId, transport); + transport.onclose = () => transports.delete(transport.sessionId); + await mcpServer.connect(transport); + return; + } + + if (req.method === 'POST' && req.url?.startsWith(MESSAGES_PATH)) { + const sessionId = new URL(req.url, 'http://localhost').searchParams.get('sessionId'); + const transport = sessionId ? transports.get(sessionId) : undefined; + if (!transport) { + res.writeHead(404).end('Session not found'); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf-8')) : undefined; + await transport.handlePostMessage(req, res, body); + return; + } + + res.writeHead(404).end(); + }); + + await new Promise((resolve) => { + httpServer.listen(port ?? 0, '127.0.0.1', resolve); + }); + + const addr = httpServer.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}${SSE_PATH}`; + + return { + url, + close: async () => { + await Promise.allSettled(Array.from(transports.values()).map((t) => t.close())); + await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve()))); + }, + }; +} + +if (require.main === module) { + startToolsSseServer(3002) + .then(({ url }) => { + console.log(`Tools SSE MCP server listening at ${url}`); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/openai-agents/src/mcp/servers/tools-server.ts b/openai-agents/src/mcp/servers/tools-server.ts new file mode 100644 index 00000000..ce18dfa3 --- /dev/null +++ b/openai-agents/src/mcp/servers/tools-server.ts @@ -0,0 +1,104 @@ +import * as http from 'http'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +function registerToolHandlers(server: Server): void { + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'add', + description: 'Add two numbers together.', + inputSchema: { + type: 'object' as const, + properties: { + a: { type: 'number', description: 'First number.' }, + b: { type: 'number', description: 'Second number.' }, + }, + required: ['a', 'b'], + additionalProperties: false, + }, + }, + { + name: 'getWeather', + description: 'Get canned weather data for a city.', + inputSchema: { + type: 'object' as const, + properties: { + city: { type: 'string', description: 'City name.' }, + }, + required: ['city'], + additionalProperties: false, + }, + }, + { + name: 'getSecret', + description: 'Get the secret passphrase.', + inputSchema: { + type: 'object' as const, + properties: {}, + required: [], + additionalProperties: false, + }, + }, + ], + })); + + server.setRequestHandler(CallToolRequestSchema, async (req) => { + const { name, arguments: args } = req.params; + const a = (args ?? {}) as Record; + if (name === 'add') { + const result = Number(a.a) + Number(a.b); + return { content: [{ type: 'text', text: String(result) }] }; + } + if (name === 'getWeather') { + const city = String(a.city ?? 'unknown'); + return { content: [{ type: 'text', text: JSON.stringify({ city, temperature: '22C', conditions: 'Sunny' }) }] }; + } + if (name === 'getSecret') { + return { content: [{ type: 'text', text: 'swordfish' }] }; + } + return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true }; + }); +} + +export async function startToolsHttpServer(port?: number): Promise<{ url: string; close(): Promise }> { + const httpServer = http.createServer(async (req, res) => { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(chunk as Buffer); + } + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf-8')) : undefined; + + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const server = new Server({ name: 'tools', version: '1.0.0' }, { capabilities: { tools: {} } }); + registerToolHandlers(server); + await server.connect(transport); + await transport.handleRequest(req, res, body); + }); + + await new Promise((resolve) => { + httpServer.listen(port ?? 0, '127.0.0.1', resolve); + }); + + const addr = httpServer.address() as { port: number }; + const url = `http://127.0.0.1:${addr.port}/`; + + return { + url, + close: async () => { + await new Promise((resolve, reject) => httpServer.close((err) => (err ? reject(err) : resolve()))); + }, + }; +} + +if (require.main === module) { + startToolsHttpServer(3001) + .then(({ url }) => { + console.log(`Tools HTTP MCP server listening at ${url}`); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/openai-agents/src/mcp/worker.ts b/openai-agents/src/mcp/worker.ts new file mode 100644 index 00000000..5ede31a9 --- /dev/null +++ b/openai-agents/src/mcp/worker.ts @@ -0,0 +1,78 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin, StatelessMCPServerProvider, StatefulMCPServerProvider } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { MCPServerStdio, MCPServerStreamableHttp, MCPServerSSE } from '@openai/agents-core'; +import * as path from 'path'; +import { makeActivities } from './activities'; +import { startToolsHttpServer } from './servers/tools-server'; +import { startToolsSseServer } from './servers/sse-server'; +import { startPromptHttpServer } from './servers/prompt-server'; +import { createNotesServer } from './servers/notes-server'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const toolsHttp = await startToolsHttpServer(); + const toolsSse = await startToolsSseServer(); + const promptHttp = await startPromptHttpServer(); + + const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + + try { + const activities = makeActivities(promptHttp.url); + + const filesystemServerPath = path.resolve(__dirname, 'servers', 'filesystem-server.ts'); + + const worker = await Worker.create({ + connection, + taskQueue: 'openai-agents-mcp', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + mcpServerProviders: [ + new StatelessMCPServerProvider( + 'filesystem', + () => + new MCPServerStdio({ + command: 'npx', + args: ['ts-node', filesystemServerPath], + name: 'filesystem', + }), + ), + new StatelessMCPServerProvider( + 'streamableHttp', + () => new MCPServerStreamableHttp({ url: toolsHttp.url, name: 'streamableHttp' }), + ), + new StatelessMCPServerProvider('sse', () => new MCPServerSSE({ url: toolsSse.url, name: 'sse' })), + new StatefulMCPServerProvider('memory', () => createNotesServer(), connection), + ], + }), + ], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + + await worker.run(); + } finally { + await Promise.allSettled([toolsHttp.close(), toolsSse.close(), promptHttp.close()]); + await connection.close(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/mcp/workflows.ts b/openai-agents/src/mcp/workflows.ts new file mode 100644 index 00000000..81afc803 --- /dev/null +++ b/openai-agents/src/mcp/workflows.ts @@ -0,0 +1,62 @@ +import { Agent } from '@openai/agents-core'; +import { TemporalOpenAIRunner, statelessMcpServer, statefulMcpServer } from '@temporalio/openai-agents/workflow'; +import { proxyActivities } from '@temporalio/workflow'; +import type { Activities } from './activities'; + +const activities = proxyActivities({ startToCloseTimeout: '1 minute' }); + +export async function filesystem(prompt: string): Promise { + const agent = new Agent({ + name: 'FilesystemAgent', + instructions: 'You are a helpful assistant with access to a filesystem.', + mcpServers: [statelessMcpServer('filesystem')], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function streamableHttp(prompt: string): Promise { + const agent = new Agent({ + name: 'StreamableHttpAgent', + instructions: 'You are a helpful assistant with access to tools via HTTP.', + mcpServers: [statelessMcpServer('streamableHttp')], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function sse(prompt: string): Promise { + const agent = new Agent({ + name: 'SseAgent', + instructions: 'You are a helpful assistant with access to tools via SSE.', + mcpServers: [statelessMcpServer('sse')], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function promptServer(prompt: string): Promise { + const instructions = await activities.fetchSummarizePrompt(); + const agent = new Agent({ + name: 'PromptAgent', + instructions, + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function statefulMemory(prompt: string): Promise { + const server = statefulMcpServer('memory'); + await server.connect(); + try { + const agent = new Agent({ + name: 'MemoryAgent', + instructions: 'You are a helpful assistant with access to a persistent notes store.', + mcpServers: [server], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; + } finally { + await server.cleanup(); + } +} diff --git a/openai-agents/src/model-providers/README.md b/openai-agents/src/model-providers/README.md new file mode 100644 index 00000000..57d53222 --- /dev/null +++ b/openai-agents/src/model-providers/README.md @@ -0,0 +1,61 @@ +# OpenAI Agents: Custom Model Providers + +Shows how to pass a **custom (non-default) `ModelProvider`** to `OpenAIAgentsPlugin`. Any OpenAI +Agents SDK `ModelProvider` can drive the model Activity, so you can point an agent at any +OpenAI-compatible endpoint (a local server such as Ollama, OpenRouter, or another gateway) by +constructing `OpenAIProvider` with a custom `baseURL`. + +The provider runs inside the model Activity, never in the Workflow sandbox. + +## Run + +Start a Temporal dev server: + +```bash +temporal server start-dev +``` + +Point the Worker at your OpenAI-compatible endpoint. For example, a local Ollama server: + +```bash +export OPENAI_BASE_URL=http://localhost:11434/v1 +export OPENAI_API_KEY=ollama # any non-empty value; most local servers ignore it +export OPENAI_MODEL=llama3.2 # a model your endpoint serves +``` + +Pull the model and start Ollama: + +```bash +ollama pull llama3.2 +ollama serve +``` + +Or an OpenRouter endpoint: + +```bash +export OPENAI_BASE_URL=https://openrouter.ai/api/v1 +export OPENAI_API_KEY=sk-or-... +export OPENAI_MODEL=meta-llama/llama-3.1-8b-instruct +``` + +Then start the Worker and run the Workflow (run from the `openai-agents/` root, after `npm install` there): + +```bash +npx ts-node src/model-providers/worker.ts +``` + +```bash +npx ts-node src/model-providers/client.ts "Say hello in one sentence." +``` + +`OPENAI_MODEL` is forwarded to the run via `runConfig.model` and resolved by your custom provider. + +## Test + +The tests run fully offline. They inject a `FakeModelProvider` — itself a custom `ModelProvider` — +and assert the agent run is handled by the injected provider, including resolving the requested +model name. This is the same injection point the live custom-provider setup uses. + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/model-providers/mocha/*.test.ts" +``` diff --git a/openai-agents/src/model-providers/client.ts b/openai-agents/src/model-providers/client.ts new file mode 100644 index 00000000..36fdc24b --- /dev/null +++ b/openai-agents/src/model-providers/client.ts @@ -0,0 +1,38 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { customProvider } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + const baseURL = process.env.OPENAI_BASE_URL; + if (!baseURL) { + throw new Error('OPENAI_BASE_URL environment variable is required (the OpenAI-compatible endpoint)'); + } + const model = process.env.OPENAI_MODEL; + + const prompt = process.argv[2] ?? 'Say hello in one sentence.'; + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey, baseURL }) })], + }); + + const taskQueue = 'openai-agents-model-providers'; + const workflowId = 'openai-agents-' + nanoid(); + + const handle = await client.workflow.start(customProvider, { taskQueue, workflowId, args: [prompt, model] }); + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/model-providers/mocha/fake-model.ts b/openai-agents/src/model-providers/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/model-providers/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/model-providers/mocha/workflows.test.ts b/openai-agents/src/model-providers/mocha/workflows.test.ts new file mode 100644 index 00000000..3bc6779d --- /dev/null +++ b/openai-agents/src/model-providers/mocha/workflows.test.ts @@ -0,0 +1,68 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse } from './fake-model'; +import { customProvider } from '../workflows'; + +describe('openai-agents/model-providers custom-provider injection', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('runs the agent through the injected custom provider', async () => { + const taskQueue = 'test-custom-provider'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Hello from the custom provider.')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(customProvider, { + args: ['Say hello.'], + workflowId: 'test-custom-provider-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Hello from the custom provider.'); + assert.strictEqual(provider.model.requests.length, 1, 'the injected provider should handle the model call'); + }); + + it('resolves the run model name through the injected provider', async () => { + const taskQueue = 'test-custom-provider-model'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Done.')]); + await worker.runUntil( + testEnv.client.workflow.execute(customProvider, { + args: ['Say hello.', 'my-custom-model'], + workflowId: 'test-custom-provider-model-' + Date.now(), + taskQueue, + }), + ); + assert.deepStrictEqual(provider.requestedModelNames, ['my-custom-model']); + }); +}); diff --git a/openai-agents/src/model-providers/worker.ts b/openai-agents/src/model-providers/worker.ts new file mode 100644 index 00000000..a85a6ab6 --- /dev/null +++ b/openai-agents/src/model-providers/worker.ts @@ -0,0 +1,47 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + const baseURL = process.env.OPENAI_BASE_URL; + if (!baseURL) { + throw new Error('OPENAI_BASE_URL environment variable is required (the OpenAI-compatible endpoint)'); + } + const modelProvider = new OpenAIProvider({ apiKey, baseURL }); + + const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + try { + const worker = await Worker.create({ + connection, + taskQueue: 'openai-agents-model-providers', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider, + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/model-providers/workflows.ts b/openai-agents/src/model-providers/workflows.ts new file mode 100644 index 00000000..29976968 --- /dev/null +++ b/openai-agents/src/model-providers/workflows.ts @@ -0,0 +1,8 @@ +import { Agent } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; + +export async function customProvider(prompt: string, model?: string): Promise { + const agent = new Agent({ name: 'Assistant', instructions: 'You are a helpful assistant.' }); + const result = await new TemporalOpenAIRunner().run(agent, prompt, model ? { runConfig: { model } } : undefined); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/multi-agent/README.md b/openai-agents/src/multi-agent/README.md new file mode 100644 index 00000000..5bb360c0 --- /dev/null +++ b/openai-agents/src/multi-agent/README.md @@ -0,0 +1,39 @@ +# OpenAI Agents: Multi-Agent + +A multi-agent research Workflow built with `@temporalio/openai-agents`. It mirrors the OpenAI Agents +SDK research-bot sample: + +- A **planner** agent turns a query into a structured list of web searches (`outputType`). +- The planned searches run **concurrently** in the Workflow via `Promise.all`; each is its own durable + model call. +- A **writer** agent synthesizes the individual summaries into a final markdown report. + +## Run + +Start the Temporal dev server: + +``` +temporal server start-dev +``` + +Set your OpenAI key and start the Worker (run from the `openai-agents/` root, after `npm install` there): + +``` +export OPENAI_API_KEY=sk-... +npx ts-node src/multi-agent/worker.ts +``` + +In another shell, start the Workflow (optionally pass a query): + +``` +npx ts-node src/multi-agent/client.ts "Caribbean surfing in April" +``` + +## Test + +``` +npx mocha --exit --require ts-node/register --require source-map-support/register "src/multi-agent/mocha/*.test.ts" +``` + +The test uses `TestWorkflowEnvironment`, a real Worker, and a `FakeModelProvider`, so it runs without +an API key. diff --git a/openai-agents/src/multi-agent/client.ts b/openai-agents/src/multi-agent/client.ts new file mode 100644 index 00000000..e5bb8d25 --- /dev/null +++ b/openai-agents/src/multi-agent/client.ts @@ -0,0 +1,34 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { researchWorkflow } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const query = process.argv[2] ?? 'Caribbean vacation spots in April, optimizing for surfing.'; + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const handle = await client.workflow.start(researchWorkflow, { + taskQueue: 'openai-agents-multi-agent', + workflowId: 'openai-agents-multi-agent-' + nanoid(), + args: [query], + }); + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/multi-agent/mocha/fake-model.ts b/openai-agents/src/multi-agent/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/multi-agent/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/multi-agent/mocha/workflows.test.ts b/openai-agents/src/multi-agent/mocha/workflows.test.ts new file mode 100644 index 00000000..da05bd72 --- /dev/null +++ b/openai-agents/src/multi-agent/mocha/workflows.test.ts @@ -0,0 +1,77 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse } from './fake-model'; +import { researchWorkflow } from '../workflows'; + +describe('openai-agents/multi-agent workflow', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('researchWorkflow: plans searches, runs them concurrently, synthesizes a report', async () => { + const taskQueue = 'test-multi-agent'; + + const plan = { + searches: [ + { reason: 'Surf conditions', query: 'best April surfing Caribbean' }, + { reason: 'Travel logistics', query: 'cheap flights Caribbean April' }, + ], + }; + const summaryA = 'SUMMARY_A: Barbados has consistent April swell.'; + const summaryB = 'SUMMARY_B: April flights to the Caribbean are affordable.'; + const report = { + shortSummary: 'A combined surf-and-travel overview.', + markdownReport: `# Report\n\n${summaryA}\n\n${summaryB}`, + followUpQuestions: ['Which island has the best board rental?'], + }; + + const provider = new FakeModelProvider([ + textResponse(JSON.stringify(plan)), + textResponse(summaryA), + textResponse(summaryB), + textResponse(JSON.stringify(report)), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(researchWorkflow, { + args: ['Caribbean surfing in April'], + workflowId: 'test-multi-agent-' + Date.now(), + taskQueue, + }), + ); + + // The synthesized report incorporates both search summaries. + assert.ok(result.includes(summaryA), 'report should include first search summary'); + assert.ok(result.includes(summaryB), 'report should include second search summary'); + + // One planner call, two search calls (one per planned query), one writer call. + assert.strictEqual(provider.model.requests.length, 4, 'expected planner + 2 searches + writer model calls'); + }); +}); diff --git a/openai-agents/src/multi-agent/worker.ts b/openai-agents/src/multi-agent/worker.ts new file mode 100644 index 00000000..b5dad450 --- /dev/null +++ b/openai-agents/src/multi-agent/worker.ts @@ -0,0 +1,42 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +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-multi-agent', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/multi-agent/workflows.ts b/openai-agents/src/multi-agent/workflows.ts new file mode 100644 index 00000000..ad2ca5c2 --- /dev/null +++ b/openai-agents/src/multi-agent/workflows.ts @@ -0,0 +1,76 @@ +import { Agent } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import z from 'zod'; + +const WebSearchItem = z.object({ + reason: z.string().describe('Why this search is important to the query.'), + query: z.string().describe('The search term to use for the web search.'), +}); + +const WebSearchPlan = z.object({ + searches: z.array(WebSearchItem).describe('The web searches to perform to best answer the query.'), +}); + +const ReportData = z.object({ + shortSummary: z.string().describe('A short 2-3 sentence summary of the findings.'), + markdownReport: z.string().describe('The final report in markdown.'), + followUpQuestions: z.array(z.string()).describe('Suggested topics to research further.'), +}); + +function plannerAgent() { + return new Agent({ + name: 'PlannerAgent', + instructions: + 'You are a helpful research assistant. Given a query, come up with a set of web searches ' + + 'to perform to best answer the query. Output between 5 and 20 terms to query for.', + outputType: WebSearchPlan, + }); +} + +function searchAgent() { + return new Agent({ + name: 'SearchAgent', + instructions: + 'You are a research assistant. Given a search term, summarize the web results for that term ' + + 'in 2-3 short paragraphs under 300 words. Capture only the essence; ignore fluff.', + }); +} + +function writerAgent() { + return new Agent({ + name: 'WriterAgent', + instructions: + 'You are a senior researcher writing a cohesive report for a research query. You are given the ' + + 'original query and summaries from a research assistant. Produce a detailed markdown report that ' + + 'synthesizes the summaries.', + outputType: ReportData, + }); +} + +export async function researchWorkflow(query: string): Promise { + const runner = new TemporalOpenAIRunner(); + + const planResult = await runner.run(plannerAgent(), `Query: ${query}`); + const plan = planResult.finalOutput; + if (!plan) { + return ''; + } + + const summaries = await Promise.all( + plan.searches.map(async (item) => { + const result = await runner.run( + searchAgent(), + `Search term: ${item.query}\nReason for searching: ${item.reason}`, + ); + return result.finalOutput ?? ''; + }), + ); + + const writeResult = await runner.run( + writerAgent(), + `Original query: ${query}\nSummarized search results: ${JSON.stringify(summaries)}`, + ); + const report = writeResult.finalOutput; + + return report?.markdownReport ?? ''; +} diff --git a/openai-agents/src/nexus-tools/README.md b/openai-agents/src/nexus-tools/README.md new file mode 100644 index 00000000..79a6eb64 --- /dev/null +++ b/openai-agents/src/nexus-tools/README.md @@ -0,0 +1,44 @@ +# OpenAI Agents: Nexus Tools + +Exposes a [Nexus](https://docs.temporal.io/nexus) Operation as an agent tool with +`@temporalio/openai-agents`. The agent answers weather questions by calling a `weather.getWeather` +Nexus Operation through `nexusOperationAsTool`; the Workflow runs the Operation and feeds the result +back to the agent. + +The package defines: + +- a `nexus-rpc` weather service (`src/nexus-tools/api.ts`), +- a synchronous service handler (`src/nexus-tools/handler.ts`), registered on the Worker via `nexusServices`, +- a Workflow whose agent uses `nexusOperationAsTool` (`src/nexus-tools/workflows.ts`). + +## Run + +Start the Temporal dev server: + +``` +temporal server start-dev +``` + +Set your OpenAI key and start the Worker (run from the `openai-agents/` root, after `npm install` there): + +``` +export OPENAI_API_KEY=sk-... +npx ts-node src/nexus-tools/worker.ts +``` + +In another shell, run the Workflow. The client creates the Nexus endpoint if needed, then starts the +Workflow (optionally pass a prompt): + +``` +npx ts-node src/nexus-tools/client.ts "What is the weather in Tokyo?" +``` + +## Test + +``` +npx mocha --exit --require ts-node/register --require source-map-support/register "src/nexus-tools/mocha/*.test.ts" +``` + +The test uses `TestWorkflowEnvironment`, `env.createNexusEndpoint(...)`, a real Worker, and a +`FakeModelProvider`, so it runs without an API key. It asserts the Nexus operation result reaches the +model. diff --git a/openai-agents/src/nexus-tools/api.ts b/openai-agents/src/nexus-tools/api.ts new file mode 100644 index 00000000..87c17289 --- /dev/null +++ b/openai-agents/src/nexus-tools/api.ts @@ -0,0 +1,15 @@ +import * as nexus from 'nexus-rpc'; + +export interface GetWeatherInput { + city: string; +} + +export interface GetWeatherOutput { + city: string; + temperatureC: number; + conditions: string; +} + +export const weatherService = nexus.service('weather', { + getWeather: nexus.operation(), +}); diff --git a/openai-agents/src/nexus-tools/client.ts b/openai-agents/src/nexus-tools/client.ts new file mode 100644 index 00000000..12ba6202 --- /dev/null +++ b/openai-agents/src/nexus-tools/client.ts @@ -0,0 +1,52 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { nexusToolWorkflow, WEATHER_ENDPOINT } from './workflows'; +import { nanoid } from 'nanoid'; + +const TASK_QUEUE = 'openai-agents-nexus-tools'; + +async function ensureEndpoint(connection: Connection): Promise { + try { + await connection.operatorService.createNexusEndpoint({ + spec: { + name: WEATHER_ENDPOINT, + target: { worker: { namespace: 'default', taskQueue: TASK_QUEUE } }, + }, + }); + } catch (err) { + if (!String((err as { message?: string }).message).includes('already')) { + throw err; + } + } +} + +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(); + await ensureEndpoint(connection); + + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const prompt = process.argv[2] ?? 'What is the weather in Tokyo?'; + const handle = await client.workflow.start(nexusToolWorkflow, { + taskQueue: TASK_QUEUE, + workflowId: 'openai-agents-nexus-' + nanoid(), + args: [prompt], + }); + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/nexus-tools/handler.ts b/openai-agents/src/nexus-tools/handler.ts new file mode 100644 index 00000000..0659769a --- /dev/null +++ b/openai-agents/src/nexus-tools/handler.ts @@ -0,0 +1,15 @@ +import * as nexus from 'nexus-rpc'; +import { weatherService, GetWeatherInput, GetWeatherOutput } from './api'; + +const WEATHER: Record> = { + tokyo: { temperatureC: 22, conditions: 'Sunny' }, + london: { temperatureC: 14, conditions: 'Cloudy' }, + cairo: { temperatureC: 33, conditions: 'Clear' }, +}; + +export const weatherServiceHandler = nexus.serviceHandler(weatherService, { + getWeather: async (_ctx, input: GetWeatherInput): Promise => { + const found = WEATHER[input.city.toLowerCase()] ?? { temperatureC: 20, conditions: 'Unknown' }; + return { city: input.city, ...found }; + }, +}); diff --git a/openai-agents/src/nexus-tools/mocha/fake-model.ts b/openai-agents/src/nexus-tools/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/nexus-tools/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/nexus-tools/mocha/workflows.test.ts b/openai-agents/src/nexus-tools/mocha/workflows.test.ts new file mode 100644 index 00000000..e377ebf2 --- /dev/null +++ b/openai-agents/src/nexus-tools/mocha/workflows.test.ts @@ -0,0 +1,72 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { nexusToolWorkflow, WEATHER_ENDPOINT } from '../workflows'; +import { weatherServiceHandler } from '../handler'; + +describe('openai-agents/nexus-tools workflow', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('agent calls a Nexus operation as a tool and the result reaches the model', async () => { + const taskQueue = 'test-nexus-tools'; + await testEnv.createNexusEndpoint(WEATHER_ENDPOINT, taskQueue); + + const provider = new FakeModelProvider([ + toolCallResponse('getWeather', { city: 'Tokyo' }), + textResponse('It is 22C and sunny in Tokyo.'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + nexusServices: [weatherServiceHandler], + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(nexusToolWorkflow, { + args: ['What is the weather in Tokyo?'], + workflowId: 'test-nexus-tools-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result, 'It is 22C and sunny in Tokyo.'); + + // The Nexus operation result is sent back to the model on the second turn as a + // function_call_result item, proving the operation ran and its output reached the model. + const toolResults = provider.model.requests + .flatMap((req) => (Array.isArray(req.input) ? req.input : [])) + .filter((item) => item.type === 'function_call_result' && item.name === 'getWeather'); + assert.strictEqual(toolResults.length, 1, 'getWeather op result should reach the model exactly once'); + + const output = (toolResults[0] as { output: { type: string; text: string } }).output; + const opResult = JSON.parse(output.text); + assert.strictEqual(opResult.city, 'Tokyo'); + assert.strictEqual(opResult.temperatureC, 22); + assert.strictEqual(opResult.conditions, 'Sunny'); + }); +}); diff --git a/openai-agents/src/nexus-tools/worker.ts b/openai-agents/src/nexus-tools/worker.ts new file mode 100644 index 00000000..d641155f --- /dev/null +++ b/openai-agents/src/nexus-tools/worker.ts @@ -0,0 +1,44 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { weatherServiceHandler } from './handler'; + +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-nexus-tools', + workflowsPath: require.resolve('./workflows'), + nexusServices: [weatherServiceHandler], + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/nexus-tools/workflows.ts b/openai-agents/src/nexus-tools/workflows.ts new file mode 100644 index 00000000..a0efa191 --- /dev/null +++ b/openai-agents/src/nexus-tools/workflows.ts @@ -0,0 +1,31 @@ +import { Agent } from '@openai/agents-core'; +import { nexusOperationAsTool, TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import { weatherService } from './api'; + +export const WEATHER_ENDPOINT = 'openai-agents-weather-endpoint'; + +export async function nexusToolWorkflow(prompt: string): Promise { + const weatherTool = nexusOperationAsTool( + weatherService.operations.getWeather, + { + name: 'getWeather', + description: 'Get the current weather for a city.', + parameters: { + type: 'object', + properties: { city: { type: 'string', description: 'The city name' } }, + required: ['city'], + additionalProperties: false, + }, + }, + { service: weatherService, endpoint: WEATHER_ENDPOINT, scheduleToCloseTimeout: '1 minute' }, + ); + + const agent = new Agent({ + name: 'WeatherAgent', + instructions: 'You are a weather assistant. Always use the getWeather tool to answer weather questions.', + tools: [weatherTool], + }); + + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/reasoning-content/README.md b/openai-agents/src/reasoning-content/README.md new file mode 100644 index 00000000..19b6978d --- /dev/null +++ b/openai-agents/src/reasoning-content/README.md @@ -0,0 +1,54 @@ +# OpenAI Agents: Reasoning Content + +Extracts a reasoning model's **reasoning summary** alongside its final answer. Unlike the other +samples, this one does **not** use `TemporalOpenAIRunner`. An Activity calls the `openai` SDK +directly using the Responses API with `reasoning: { summary: 'auto' }`, reads the reasoning summary +from the `reasoning` item in the response `output`, and returns it together with the final answer; +the Workflow runs that Activity and returns both. + +Note that OpenAI returns a reasoning **summary**, not the model's raw chain-of-thought. The +returned field is named `reasoningContent` to match the sample name. + +Mirrors the Python `openai_agents/reasoning_content` sample. + +## Prerequisites + +Reasoning summaries are only returned to **verified OpenAI organizations**. If your organization is +not verified, the Responses API returns an HTTP 400 with `Your organization must be verified to +generate reasoning summaries`. Verify your organization at + before running this sample. + +## Run + +Start a Temporal dev server: + +```bash +temporal server start-dev +``` + +The default model is `gpt-5.5`. Set your OpenAI credentials (override the model with `OPENAI_MODEL` +if you want a different reasoning-capable model): + +```bash +export OPENAI_API_KEY=sk-... +``` + +Then start the Worker and run the Workflow (run from the `openai-agents/` root, after `npm install` there): + +```bash +npx ts-node src/reasoning-content/worker.ts +``` + +```bash +npx ts-node src/reasoning-content/client.ts "What is the square root of 841? Please explain your reasoning." +``` + +## Test + +The test runs fully offline by overriding the Activity's `openai` client factory with a stub via +`setReasoningClientFactory`. It asserts the Workflow returns the reasoning summary and content the +stub provides, and that the prompt and model were forwarded to the client. + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/reasoning-content/mocha/*.test.ts" +``` diff --git a/openai-agents/src/reasoning-content/activities.ts b/openai-agents/src/reasoning-content/activities.ts new file mode 100644 index 00000000..5b9c5bac --- /dev/null +++ b/openai-agents/src/reasoning-content/activities.ts @@ -0,0 +1,68 @@ +import OpenAI from 'openai'; + +export interface ReasoningResponse { + reasoningContent: string | null; + content: string | null; +} + +interface ReasoningOutputItem { + type: 'reasoning'; + summary: { text: string }[]; +} + +type MessageContentPart = { type: 'output_text'; text: string } | { type: 'refusal'; refusal: string }; + +interface MessageOutputItem { + type: 'message'; + content: MessageContentPart[]; +} + +type ResponseOutputItem = ReasoningOutputItem | MessageOutputItem | { type: string }; + +export interface ReasoningClient { + responses: { + create(body: { + model: string; + instructions: string; + input: string; + reasoning: { summary: 'auto' | 'concise' | 'detailed' }; + }): Promise<{ output: ResponseOutputItem[]; output_text?: string }>; + }; +} + +let clientFactory: () => ReasoningClient = () => new OpenAI() as unknown as ReasoningClient; + +export function setReasoningClientFactory(factory: () => ReasoningClient): void { + clientFactory = factory; +} + +export async function getReasoningResponse(prompt: string, model: string): Promise { + const client = clientFactory(); + const response = await client.responses.create({ + model, + instructions: 'You are a helpful assistant that explains your reasoning step by step.', + input: prompt, + reasoning: { summary: 'auto' }, + }); + + const reasoningSummaries: string[] = []; + let content: string | null = null; + for (const item of response.output) { + if (item.type === 'reasoning') { + for (const part of (item as ReasoningOutputItem).summary) { + reasoningSummaries.push(part.text); + } + } else if (item.type === 'message') { + for (const part of (item as MessageOutputItem).content) { + if (part.type === 'output_text' && typeof part.text === 'string') { + content = (content ?? '') + part.text; + } + } + } + } + + return { + reasoningContent: reasoningSummaries.length > 0 ? reasoningSummaries.join('\n\n') : null, + content: content ?? response.output_text ?? null, + }; +} diff --git a/openai-agents/src/reasoning-content/client.ts b/openai-agents/src/reasoning-content/client.ts new file mode 100644 index 00000000..e912d233 --- /dev/null +++ b/openai-agents/src/reasoning-content/client.ts @@ -0,0 +1,32 @@ +import { Connection, Client } from '@temporalio/client'; +import { reasoningContent } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + if (!process.env.OPENAI_API_KEY) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const prompt = process.argv[2] ?? 'What is the square root of 841? Please explain your reasoning.'; + const model = process.env.OPENAI_MODEL ?? 'gpt-5.5'; + + const connection = await Connection.connect(); + const client = new Client({ connection }); + + const handle = await client.workflow.start(reasoningContent, { + taskQueue: 'openai-agents-reasoning-content', + workflowId: 'openai-agents-' + nanoid(), + args: [prompt, model], + }); + + console.log(`Started workflow ${handle.workflowId}`); + const result = await handle.result(); + console.log(`\nPrompt: ${result.prompt}`); + console.log(`\nReasoning:\n${result.reasoningContent ?? 'No reasoning content provided'}`); + console.log(`\nAnswer:\n${result.content ?? 'No content provided'}`); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/reasoning-content/mocha/workflows.test.ts b/openai-agents/src/reasoning-content/mocha/workflows.test.ts new file mode 100644 index 00000000..03f7dfa6 --- /dev/null +++ b/openai-agents/src/reasoning-content/mocha/workflows.test.ts @@ -0,0 +1,66 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import assert from 'assert'; +import * as activities from '../activities'; +import { reasoningContent } from '../workflows'; + +describe('openai-agents/reasoning-content', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('returns the reasoning and content from the model response', async () => { + const taskQueue = 'test-reasoning-content'; + const seenRequests: { model: string; prompt: string }[] = []; + activities.setReasoningClientFactory(() => ({ + responses: { + create: async (body) => { + seenRequests.push({ model: body.model, prompt: body.input }); + return { + output: [ + { + type: 'reasoning', + summary: [{ text: 'sqrt(841): 29 * 29 = 841, so the answer is 29.' }], + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'The square root of 841 is 29.' }], + }, + ], + }; + }, + }, + })); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(reasoningContent, { + args: ['What is the square root of 841?', 'fake-reasoning-model'], + workflowId: 'test-reasoning-content-' + Date.now(), + taskQueue, + }), + ); + + assert.strictEqual(result.prompt, 'What is the square root of 841?'); + assert.strictEqual(result.reasoningContent, 'sqrt(841): 29 * 29 = 841, so the answer is 29.'); + assert.strictEqual(result.content, 'The square root of 841 is 29.'); + assert.deepStrictEqual(seenRequests, [ + { model: 'fake-reasoning-model', prompt: 'What is the square root of 841?' }, + ]); + }); +}); diff --git a/openai-agents/src/reasoning-content/worker.ts b/openai-agents/src/reasoning-content/worker.ts new file mode 100644 index 00000000..33165c22 --- /dev/null +++ b/openai-agents/src/reasoning-content/worker.ts @@ -0,0 +1,26 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import * as activities from './activities'; + +async function run() { + if (!process.env.OPENAI_API_KEY) { + 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-reasoning-content', + workflowsPath: require.resolve('./workflows'), + activities, + }); + await worker.run(); + } finally { + await connection.close(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/reasoning-content/workflows.ts b/openai-agents/src/reasoning-content/workflows.ts new file mode 100644 index 00000000..dd1555a0 --- /dev/null +++ b/openai-agents/src/reasoning-content/workflows.ts @@ -0,0 +1,16 @@ +import { proxyActivities } from '@temporalio/workflow'; +import type * as activities from './activities'; +import type { ReasoningResponse } from './activities'; + +const { getReasoningResponse } = proxyActivities({ startToCloseTimeout: '5 minutes' }); + +export interface ReasoningResult { + prompt: string; + reasoningContent: string | null; + content: string | null; +} + +export async function reasoningContent(prompt: string, model: string): Promise { + const response: ReasoningResponse = await getReasoningResponse(prompt, model); + return { prompt, reasoningContent: response.reasoningContent, content: response.content }; +} diff --git a/openai-agents/src/sessions/README.md b/openai-agents/src/sessions/README.md new file mode 100644 index 00000000..00ce31fe --- /dev/null +++ b/openai-agents/src/sessions/README.md @@ -0,0 +1,33 @@ +# OpenAI Agents: Sessions + +Demonstrates conversation sessions with the Temporal OpenAI Agents integration using +`WorkflowSafeMemorySession`, whose history lives on the Workflow heap and is rebuilt by replay. + +Scenarios (`src/sessions/workflows.ts`): + +- **multi-turn-chat** — runs several prompts over one shared session; later turns see earlier history. +- **carryover-chat** — carries session history across a `continueAsNew` boundary by capturing + `session.getItems()` and re-seeding the next run via `new WorkflowSafeMemorySession({ initialItems })`. + +## 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/sessions/worker.ts + +# In another terminal, start a scenario: +npx ts-node src/sessions/client.ts multi-turn-chat +npx ts-node src/sessions/client.ts carryover-chat +``` + +## Test + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/sessions/mocha/*.test.ts" +``` + +Tests run a real Worker against `TestWorkflowEnvironment` with a scripted fake model, so no +`OPENAI_API_KEY` is required. They assert that a later turn's model input contains the prior +turn's history — including across the continue-as-new boundary. diff --git a/openai-agents/src/sessions/activities.ts b/openai-agents/src/sessions/activities.ts new file mode 100644 index 00000000..d69b8fbb --- /dev/null +++ b/openai-agents/src/sessions/activities.ts @@ -0,0 +1,3 @@ +export async function ping(): Promise { + return 'pong'; +} diff --git a/openai-agents/src/sessions/client.ts b/openai-agents/src/sessions/client.ts new file mode 100644 index 00000000..0d4e6f65 --- /dev/null +++ b/openai-agents/src/sessions/client.ts @@ -0,0 +1,52 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { multiTurnChat, carryoverChat } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'multi-turn-chat'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-sessions'; + const workflowId = 'openai-agents-sessions-' + nanoid(); + + let handle; + switch (scenario) { + case 'multi-turn-chat': + handle = await client.workflow.start(multiTurnChat, { + taskQueue, + workflowId, + args: [['What is the capital of France?', 'And what is it known for?']], + }); + break; + case 'carryover-chat': + handle = await client.workflow.start(carryoverChat, { + taskQueue, + workflowId, + args: [{ prompts: ['What is the capital of France?', 'And what is it known for?', 'What is the population?'] }], + }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/sessions/mocha/fake-model.ts b/openai-agents/src/sessions/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/sessions/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/sessions/mocha/workflows.test.ts b/openai-agents/src/sessions/mocha/workflows.test.ts new file mode 100644 index 00000000..dfac9262 --- /dev/null +++ b/openai-agents/src/sessions/mocha/workflows.test.ts @@ -0,0 +1,153 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import * as activities from '../activities'; +import assert from 'assert'; +import { FakeModelProvider, textResponse } from './fake-model'; +import { multiTurnChat, carryoverChat } from '../workflows'; + +describe('openai-agents/sessions workflow scenarios', function () { + this.timeout(60_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + activities, + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('multiTurnChat: second turn input includes the first turn history', async () => { + const taskQueue = 'test-multi-turn-chat'; + const firstPrompt = 'What is the capital of France?'; + const firstReply = 'The capital of France is Paris.'; + const secondPrompt = 'What is it known for?'; + const secondReply = 'Paris is known for the Eiffel Tower.'; + + const { worker, provider } = await makeWorker(taskQueue, [textResponse(firstReply), textResponse(secondReply)]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(multiTurnChat, { + args: [[firstPrompt, secondPrompt]], + workflowId: 'test-multi-turn-chat-' + Date.now(), + taskQueue, + }), + ); + + assert.deepStrictEqual(result, [firstReply, secondReply]); + assert.strictEqual(provider.model.requests.length, 2, 'should have made one model call per prompt'); + + const turn1Input = provider.model.requests[0]!.input; + assert.ok( + turn1Input === firstPrompt || (Array.isArray(turn1Input) && JSON.stringify(turn1Input).includes(firstPrompt)), + 'turn-1 input should contain the first prompt', + ); + + const turn2Input = provider.model.requests[1]!.input; + assert.ok(Array.isArray(turn2Input), 'turn-2 input should be an array because the session prepends history items'); + + const turn2Text = JSON.stringify(turn2Input); + assert.ok( + turn2Text.includes(firstPrompt), + 'turn-2 input should include the turn-1 user prompt from session history', + ); + assert.ok( + turn2Text.includes(firstReply), + 'turn-2 input should include the turn-1 assistant reply from session history', + ); + + const userItems = (turn2Input as unknown[]).filter( + (item): item is { role: string; content: unknown } => + typeof item === 'object' && item !== null && 'role' in item && (item as { role: string }).role === 'user', + ); + const assistantItems = (turn2Input as unknown[]).filter( + (item): item is { role: string; content: unknown } => + typeof item === 'object' && item !== null && 'role' in item && (item as { role: string }).role === 'assistant', + ); + + assert.ok(userItems.length >= 2, 'turn-2 input should have at least two user items: history + new prompt'); + assert.ok(assistantItems.length >= 1, 'turn-2 input should have at least one assistant item from session history'); + + const firstUserContent = userItems[0]!.content; + const firstUserText = typeof firstUserContent === 'string' ? firstUserContent : JSON.stringify(firstUserContent); + assert.ok( + firstUserText.includes(firstPrompt), + 'first user item in turn-2 should be the turn-1 prompt (replayed from session)', + ); + + const assistantContent = assistantItems[0]!.content; + const assistantText = typeof assistantContent === 'string' ? assistantContent : JSON.stringify(assistantContent); + assert.ok( + assistantText.includes(firstReply), + 'assistant item in turn-2 should contain the turn-1 reply (replayed from session)', + ); + + const lastUserContent = userItems[userItems.length - 1]!.content; + const lastUserText = typeof lastUserContent === 'string' ? lastUserContent : JSON.stringify(lastUserContent); + assert.ok(lastUserText.includes(secondPrompt), 'last user item in turn-2 should be the new second prompt'); + }); + + it('carryoverChat: session history survives a continue-as-new boundary', async () => { + const taskQueue = 'test-carryover-chat'; + const firstPrompt = 'What is the capital of France?'; + const firstReply = 'The capital of France is Paris.'; + const secondPrompt = 'What is it known for?'; + const secondReply = 'Paris is known for the Eiffel Tower.'; + const thirdPrompt = 'What is the population?'; + const thirdReply = 'The population is about two million.'; + + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse(firstReply), + textResponse(secondReply), + textResponse(thirdReply), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(carryoverChat, { + args: [{ prompts: [firstPrompt, secondPrompt, thirdPrompt] }], + workflowId: 'test-carryover-chat-' + Date.now(), + taskQueue, + }), + ); + + assert.deepStrictEqual(result, [firstReply, secondReply, thirdReply]); + assert.strictEqual( + provider.model.requests.length, + 3, + 'should have made one model call per prompt across continue-as-new runs', + ); + + const thirdTurnInput = provider.model.requests[2]!.input; + const thirdTurnText = Array.isArray(thirdTurnInput) ? JSON.stringify(thirdTurnInput) : String(thirdTurnInput); + assert.ok( + thirdTurnText.includes(firstPrompt), + 'third-run model input should include the first-run user prompt, proving history crossed the continue-as-new boundary', + ); + assert.ok( + thirdTurnText.includes(firstReply), + 'third-run model input should include the first-run assistant reply, proving history crossed the continue-as-new boundary', + ); + }); +}); diff --git a/openai-agents/src/sessions/worker.ts b/openai-agents/src/sessions/worker.ts new file mode 100644 index 00000000..1c1fa52b --- /dev/null +++ b/openai-agents/src/sessions/worker.ts @@ -0,0 +1,44 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import * as activities from './activities'; + +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-sessions', + workflowsPath: require.resolve('./workflows'), + activities, + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/sessions/workflows.ts b/openai-agents/src/sessions/workflows.ts new file mode 100644 index 00000000..67bf86ff --- /dev/null +++ b/openai-agents/src/sessions/workflows.ts @@ -0,0 +1,48 @@ +import { Agent } from '@openai/agents-core'; +import type { AgentInputItem } from '@openai/agents-core'; +import { TemporalOpenAIRunner, WorkflowSafeMemorySession } from '@temporalio/openai-agents/workflow'; +import { continueAsNew } from '@temporalio/workflow'; + +export async function multiTurnChat(prompts: string[]): Promise { + const agent = new Agent({ name: 'ChatAgent', instructions: 'You are a helpful assistant.' }); + const session = new WorkflowSafeMemorySession(); + const runner = new TemporalOpenAIRunner(); + const replies: string[] = []; + for (const prompt of prompts) { + const result = await runner.run(agent, prompt, { session }); + replies.push(result.finalOutput ?? ''); + } + return replies; +} + +export interface CarryoverChatInput { + prompts: string[]; + initialItems?: AgentInputItem[]; + accumulated?: string[]; +} + +export async function carryoverChat(input: CarryoverChatInput): Promise { + const agent = new Agent({ name: 'ChatAgent', instructions: 'You are a helpful assistant.' }); + const session = new WorkflowSafeMemorySession({ initialItems: input.initialItems }); + const runner = new TemporalOpenAIRunner(); + const accumulated = input.accumulated ?? []; + + const [prompt, ...remaining] = input.prompts; + if (prompt === undefined) { + return accumulated; + } + + const result = await runner.run(agent, prompt, { session }); + accumulated.push(result.finalOutput ?? ''); + + if (remaining.length === 0) { + return accumulated; + } + + const items = await session.getItems(); + await continueAsNew({ + prompts: remaining, + initialItems: items, + accumulated, + }); +} diff --git a/openai-agents/src/stateful-conversation/README.md b/openai-agents/src/stateful-conversation/README.md new file mode 100644 index 00000000..c736e335 --- /dev/null +++ b/openai-agents/src/stateful-conversation/README.md @@ -0,0 +1,44 @@ +# OpenAI Agents: Stateful Conversation + +A stateful, multi-turn customer-service Workflow built with `@temporalio/openai-agents`. It mirrors +the OpenAI Agents SDK airline customer-service sample: + +- The Workflow stays running and accepts user messages via an **Update** (`processUserMessage`), each + returning the agent's reply. +- A **Query** (`getHistory`) returns the running conversation transcript. +- A **Triage** agent hands off to specialist **FAQ** and **SeatBooking** agents; the seat-booking + handoff seeds a shared `RunContext` with a flight number, and the seat tool updates that context. +- The Workflow calls `continueAsNew` once Temporal suggests it, carrying the conversation state across + the boundary to bound history growth. + +## Run + +Start the Temporal dev server: + +``` +temporal server start-dev +``` + +Set your OpenAI key and start the Worker (run from the `openai-agents/` root, after `npm install` there): + +``` +export OPENAI_API_KEY=sk-... +npx ts-node src/stateful-conversation/worker.ts +``` + +In another shell, start the interactive chat client: + +``` +npx ts-node src/stateful-conversation/client.ts +``` + +Type messages to chat. Type `history` to print the transcript, or `exit` to quit. + +## Test + +``` +npx mocha --exit --require ts-node/register --require source-map-support/register "src/stateful-conversation/mocha/*.test.ts" +``` + +The test uses `TestWorkflowEnvironment`, a real Worker, and a `FakeModelProvider`, so it runs without +an API key. It drives two Updates, asserts a handoff occurred, and reads the transcript via Query. diff --git a/openai-agents/src/stateful-conversation/agents.ts b/openai-agents/src/stateful-conversation/agents.ts new file mode 100644 index 00000000..b15d9265 --- /dev/null +++ b/openai-agents/src/stateful-conversation/agents.ts @@ -0,0 +1,98 @@ +import { Agent, handoff, tool } from '@openai/agents-core'; +import type { RunContext } from '@openai/agents-core'; +import z from 'zod'; + +export interface AirlineAgentContext { + passengerName?: string; + confirmationNumber?: string; + seatNumber?: string; + flightNumber?: string; +} + +const faqLookupTool = tool({ + name: 'faqLookupTool', + description: 'Lookup frequently asked questions.', + parameters: z.object({ question: z.string() }), + execute: async ({ question }) => { + const q = question.toLowerCase(); + if (q.includes('bag') || q.includes('baggage')) { + return 'You are allowed to bring one bag. It must be under 50 pounds and 22 x 14 x 9 inches.'; + } + if (q.includes('seat') || q.includes('plane')) { + return 'There are 120 seats: 22 business and 98 economy. Exit rows are 4 and 16.'; + } + if (q.includes('wifi')) { + return 'We have free wifi on the plane; join Airline-Wifi.'; + } + return "I'm sorry, I don't know the answer to that question."; + }, +}); + +const updateSeatTool = tool({ + name: 'updateSeat', + description: 'Update the seat for a given confirmation number.', + parameters: z.object({ + confirmationNumber: z.string().describe('The confirmation number for the flight.'), + newSeat: z.string().describe('The new seat to update to.'), + }), + execute: async ({ confirmationNumber, newSeat }, runCtx?: RunContext) => { + const ctx = runCtx?.context; + if (ctx) { + ctx.confirmationNumber = confirmationNumber; + ctx.seatNumber = newSeat; + } + return `Updated seat to ${newSeat} for confirmation number ${confirmationNumber}`; + }, +}); + +export function initAgents(): { + startingAgent: Agent; + agentsByName: Map>; +} { + const faqAgent = new Agent({ + name: 'FAQ', + model: 'gpt-4o-mini', + handoffDescription: 'A helpful agent that can answer questions about the airline.', + instructions: + 'You are an FAQ agent. Use the faqLookupTool to answer the question; do not rely on your own ' + + 'knowledge. If you cannot answer, hand off back to the Triage agent.', + tools: [faqLookupTool], + }); + + const seatBookingAgent = new Agent({ + name: 'SeatBooking', + model: 'gpt-4o-mini', + handoffDescription: 'A helpful agent that can update a seat on a flight.', + instructions: + 'You are a seat booking agent. Ask for the confirmation number and desired seat, then use the ' + + 'updateSeat tool. If the question is unrelated, hand off back to the Triage agent.', + tools: [updateSeatTool], + }); + + const triageAgent = new Agent({ + name: 'Triage', + model: 'gpt-4o-mini', + handoffDescription: 'A triage agent that delegates a request to the appropriate agent.', + instructions: + 'You are a triage agent. Delegate FAQ questions to the FAQ agent and seat changes to the ' + 'SeatBooking agent.', + handoffs: [ + faqAgent, + handoff(seatBookingAgent, { + onHandoff: (runCtx: RunContext) => { + runCtx.context.flightNumber = `FLT-${100 + Math.floor(Math.random() * 900)}`; + }, + }), + ], + }); + + faqAgent.handoffs.push(triageAgent); + seatBookingAgent.handoffs.push(triageAgent); + + const agentsByName = new Map>([ + [faqAgent.name, faqAgent], + [seatBookingAgent.name, seatBookingAgent], + [triageAgent.name, triageAgent], + ]); + + return { startingAgent: triageAgent, agentsByName }; +} diff --git a/openai-agents/src/stateful-conversation/client.ts b/openai-agents/src/stateful-conversation/client.ts new file mode 100644 index 00000000..ba2987af --- /dev/null +++ b/openai-agents/src/stateful-conversation/client.ts @@ -0,0 +1,53 @@ +import * as readline from 'node:readline'; +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { statefulConversationWorkflow, processUserMessage, getHistory } from './workflows'; +import { nanoid } from 'nanoid'; + +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 }) })], + }); + + const workflowId = 'openai-agents-stateful-conversation-' + nanoid(); + const handle = await client.workflow.start(statefulConversationWorkflow, { + taskQueue: 'openai-agents-stateful-conversation', + workflowId, + }); + console.log(`Started chat workflow ${workflowId}`); + console.log('Type a message and press Enter. Type "history" to print the transcript, "exit" to quit.\n'); + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + const ask = (q: string) => new Promise((resolve) => rl.question(q, resolve)); + + try { + for (;;) { + const line = (await ask('you> ')).trim(); + if (!line) continue; + if (line === 'exit') break; + if (line === 'history') { + const history = await handle.query(getHistory); + console.log(history.join('\n')); + continue; + } + const reply = await handle.executeUpdate(processUserMessage, { args: [line] }); + console.log(`agent> ${reply}\n`); + } + } finally { + rl.close(); + await connection.close(); + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/stateful-conversation/mocha/fake-model.ts b/openai-agents/src/stateful-conversation/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/stateful-conversation/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/stateful-conversation/mocha/workflows.test.ts b/openai-agents/src/stateful-conversation/mocha/workflows.test.ts new file mode 100644 index 00000000..0e3b0951 --- /dev/null +++ b/openai-agents/src/stateful-conversation/mocha/workflows.test.ts @@ -0,0 +1,72 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { statefulConversationWorkflow, processUserMessage, getHistory } from '../workflows'; + +describe('openai-agents/stateful-conversation workflow', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('drives two updates, hands off to a specialist, and exposes history via query', async () => { + const taskQueue = 'test-stateful-conversation'; + + // Turn 1: Triage hands off to the FAQ agent, which then answers. + // Turn 2: the current agent is now FAQ, which answers directly. + const provider = new FakeModelProvider([ + toolCallResponse('transfer_to_FAQ', {}), + textResponse('You may bring one carry-on bag.'), + textResponse('We offer free wifi; join Airline-Wifi.'), + ]); + + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + + await worker.runUntil(async () => { + const handle = await testEnv.client.workflow.start(statefulConversationWorkflow, { + workflowId: 'test-stateful-conversation-' + Date.now(), + taskQueue, + }); + + const reply1 = await handle.executeUpdate(processUserMessage, { args: ['What is the baggage allowance?'] }); + assert.strictEqual(reply1, 'You may bring one carry-on bag.'); + + const reply2 = await handle.executeUpdate(processUserMessage, { args: ['Do you have wifi?'] }); + assert.strictEqual(reply2, 'We offer free wifi; join Airline-Wifi.'); + + const history = await handle.query(getHistory); + assert.ok( + history.some((line) => line === 'Handed off from Triage to FAQ'), + `expected a handoff line in history, got:\n${history.join('\n')}`, + ); + assert.ok(history.includes('User: What is the baggage allowance?')); + assert.ok(history.includes('FAQ: You may bring one carry-on bag.')); + assert.ok(history.includes('User: Do you have wifi?')); + assert.ok(history.includes('FAQ: We offer free wifi; join Airline-Wifi.')); + }); + }); +}); diff --git a/openai-agents/src/stateful-conversation/worker.ts b/openai-agents/src/stateful-conversation/worker.ts new file mode 100644 index 00000000..ad8ca9c3 --- /dev/null +++ b/openai-agents/src/stateful-conversation/worker.ts @@ -0,0 +1,42 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +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-stateful-conversation', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/stateful-conversation/workflows.ts b/openai-agents/src/stateful-conversation/workflows.ts new file mode 100644 index 00000000..93797122 --- /dev/null +++ b/openai-agents/src/stateful-conversation/workflows.ts @@ -0,0 +1,65 @@ +import type { AgentInputItem } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import { condition, continueAsNew, defineQuery, defineUpdate, setHandler, workflowInfo } from '@temporalio/workflow'; +import { initAgents, type AirlineAgentContext } from './agents'; + +export const processUserMessage = defineUpdate('processUserMessage'); +export const getHistory = defineQuery('getHistory'); + +export interface StatefulConversationState { + history: string[]; + currentAgentName: string; + context: AirlineAgentContext; + inputItems: AgentInputItem[]; +} + +export async function statefulConversationWorkflow(state?: StatefulConversationState): Promise { + const runner = new TemporalOpenAIRunner(); + + const history: string[] = state?.history ?? []; + const context: AirlineAgentContext = state?.context ?? {}; + let inputItems: AgentInputItem[] = state?.inputItems ?? []; + let currentAgentName = state?.currentAgentName ?? initAgents().startingAgent.name; + + setHandler(getHistory, () => history); + + setHandler(processUserMessage, async (message: string): Promise => { + history.push(`User: ${message}`); + inputItems = [...inputItems, { role: 'user', content: message }]; + + const { agentsByName } = initAgents(); + const currentAgent = agentsByName.get(currentAgentName); + if (!currentAgent) { + throw new Error(`Unknown agent: ${currentAgentName}`); + } + + const result = await runner.run(currentAgent, inputItems, { context }); + + for (const item of result.newItems) { + if (item.type === 'message_output_item') { + const text = item.rawItem.content.map((c) => ('text' in c ? c.text : '')).join(''); + history.push(`${item.agent.name}: ${text}`); + } else if (item.type === 'handoff_output_item') { + history.push(`Handed off from ${item.sourceAgent.name} to ${item.targetAgent.name}`); + } else if (item.type === 'tool_call_item') { + history.push(`${item.agent.name}: calling a tool`); + } else if (item.type === 'tool_call_output_item') { + history.push(`${item.agent.name}: tool output: ${String(item.output)}`); + } + } + + inputItems = result.history; + currentAgentName = result.lastAgent?.name ?? currentAgentName; + + return result.finalOutput ?? ''; + }); + + await condition(() => workflowInfo().continueAsNewSuggested); + + await continueAsNew({ + history, + currentAgentName, + context, + inputItems, + }); +} diff --git a/openai-agents/src/tools/README.md b/openai-agents/src/tools/README.md new file mode 100644 index 00000000..2b417664 --- /dev/null +++ b/openai-agents/src/tools/README.md @@ -0,0 +1,47 @@ +# OpenAI Agents: Hosted Tools + +Runs OpenAI Agents SDK agents as Temporal Workflows using server-side **hosted tools** from +`@openai/agents-openai`. Each hosted tool executes inside the model provider during the model +Activity, so there is no Activity to back them in your own code. + +Scenarios (one Workflow each): + +- `web-search` — agent with `webSearchTool()` +- `image-generation` — agent with `imageGenerationTool()` +- `code-interpreter` — agent with `codeInterpreterTool()` + +## Run + +Start a Temporal dev server: + +```bash +temporal server start-dev +``` + +In one shell, start the Worker (run from the `openai-agents/` root, after `npm install` there; a real `OPENAI_API_KEY` is required for hosted tools to fire): + +```bash +export OPENAI_API_KEY=sk-... +npx ts-node src/tools/worker.ts +``` + +In another shell, run a scenario: + +```bash +npx ts-node src/tools/client.ts web-search +npx ts-node src/tools/client.ts image-generation +npx ts-node src/tools/client.ts code-interpreter +``` + +Hosted tools only execute against the live OpenAI API. With a real key, the agent calls the tool +server-side and returns the model's answer. + +## Test + +The tests run fully offline with a `FakeModelProvider`. Because hosted tools run inside the real +model call, the fake provider cannot exercise them; instead each test asserts the Workflow **wires** +the hosted tool into the model request and completes with a scripted response. + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/tools/mocha/*.test.ts" +``` diff --git a/openai-agents/src/tools/client.ts b/openai-agents/src/tools/client.ts new file mode 100644 index 00000000..0a700eea --- /dev/null +++ b/openai-agents/src/tools/client.ts @@ -0,0 +1,59 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { webSearch, imageGeneration, codeInterpreter } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const scenario = process.argv[2] ?? 'web-search'; + console.log(`Running scenario: ${scenario}`); + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const taskQueue = 'openai-agents-tools'; + const workflowId = 'openai-agents-' + nanoid(); + + let handle; + switch (scenario) { + case 'web-search': + handle = await client.workflow.start(webSearch, { + taskQueue, + workflowId, + args: ['What is a notable recent development in durable execution?'], + }); + break; + case 'image-generation': + handle = await client.workflow.start(imageGeneration, { + taskQueue, + workflowId, + args: ['Generate an image of a robot orchestrating workflows.'], + }); + break; + case 'code-interpreter': + handle = await client.workflow.start(codeInterpreter, { + taskQueue, + workflowId, + args: ['What is the 20th Fibonacci number? Compute it.'], + }); + break; + default: + throw new Error(`Unknown scenario: ${scenario}`); + } + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/tools/mocha/fake-model.ts b/openai-agents/src/tools/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/tools/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/tools/mocha/workflows.test.ts b/openai-agents/src/tools/mocha/workflows.test.ts new file mode 100644 index 00000000..78ac1c0c --- /dev/null +++ b/openai-agents/src/tools/mocha/workflows.test.ts @@ -0,0 +1,100 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import assert from 'assert'; +import { FakeModelProvider, textResponse } from './fake-model'; +import { webSearch, imageGeneration, codeInterpreter } from '../workflows'; + +describe('openai-agents/tools hosted-tool scenarios', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + async function makeWorker(taskQueue: string, responses: ReturnType[]) { + const provider = new FakeModelProvider(responses); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + return { worker, provider }; + } + + it('webSearch: agent with webSearchTool runs and returns model output', async () => { + const taskQueue = 'test-web-search'; + const { worker, provider } = await makeWorker(taskQueue, [ + textResponse('Durable execution keeps state on failure.'), + ]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(webSearch, { + args: ['What is durable execution?'], + workflowId: 'test-web-search-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Durable execution keeps state on failure.'); + + const tools = provider.model.requests[0]?.tools ?? []; + assert.ok( + tools.some((t) => t.type === 'hosted_tool' && t.name === 'web_search'), + 'web_search hosted tool should be sent to the model', + ); + }); + + it('imageGeneration: agent with imageGenerationTool runs and returns model output', async () => { + const taskQueue = 'test-image-generation'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('Here is your generated image.')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(imageGeneration, { + args: ['Generate an image of a robot.'], + workflowId: 'test-image-generation-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'Here is your generated image.'); + + const tools = provider.model.requests[0]?.tools ?? []; + assert.ok( + tools.some((t) => t.type === 'hosted_tool' && t.name === 'image_generation'), + 'image_generation hosted tool should be sent to the model', + ); + }); + + it('codeInterpreter: agent with codeInterpreterTool runs and returns model output', async () => { + const taskQueue = 'test-code-interpreter'; + const { worker, provider } = await makeWorker(taskQueue, [textResponse('The answer is 6765.')]); + const result = await worker.runUntil( + testEnv.client.workflow.execute(codeInterpreter, { + args: ['What is the 20th Fibonacci number?'], + workflowId: 'test-code-interpreter-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, 'The answer is 6765.'); + + const tools = provider.model.requests[0]?.tools ?? []; + assert.ok( + tools.some((t) => t.type === 'hosted_tool' && t.name === 'code_interpreter'), + 'code_interpreter hosted tool should be sent to the model', + ); + }); +}); diff --git a/openai-agents/src/tools/worker.ts b/openai-agents/src/tools/worker.ts new file mode 100644 index 00000000..0725b814 --- /dev/null +++ b/openai-agents/src/tools/worker.ts @@ -0,0 +1,42 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; + +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-tools', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/tools/workflows.ts b/openai-agents/src/tools/workflows.ts new file mode 100644 index 00000000..7057ce09 --- /dev/null +++ b/openai-agents/src/tools/workflows.ts @@ -0,0 +1,34 @@ +import { Agent } from '@openai/agents-core'; +import { webSearchTool, imageGenerationTool, codeInterpreterTool } from '@openai/agents-openai'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; + +export async function webSearch(prompt: string): Promise { + const agent = new Agent({ + name: 'WebSearchAgent', + instructions: 'Use the web search tool to find current information, then answer concisely.', + tools: [webSearchTool()], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function imageGeneration(prompt: string): Promise { + const agent = new Agent({ + name: 'ImageAgent', + instructions: + 'Use the image generation tool to create an image for the user request, then reply with a one-sentence caption describing the image you created.', + tools: [imageGenerationTool({ outputFormat: 'webp', quality: 'low', outputCompression: 50 })], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} + +export async function codeInterpreter(prompt: string): Promise { + const agent = new Agent({ + name: 'CodeAgent', + instructions: 'Use the code interpreter tool to compute results, then explain the answer.', + tools: [codeInterpreterTool()], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/src/tracing/README.md b/openai-agents/src/tracing/README.md new file mode 100644 index 00000000..86001ce9 --- /dev/null +++ b/openai-agents/src/tracing/README.md @@ -0,0 +1,71 @@ +# OpenAI Agents: Tracing + +Shows the three tracing paths the Temporal OpenAI Agents integration supports. OpenAI Agents SDK +tracing works across Client, Workflow, Activity, Nexus, and MCP boundaries, and Workflow replay does +not duplicate spans. + +The Worker selects a tracing mode (default `custom`): + +```bash +npx ts-node src/tracing/worker.ts # mode: custom | openai | otel +# or set TRACING_MODE= +``` + +All modes also set `interceptorOptions: { addTemporalSpans: true }`, which emits `temporal:*` +orchestration spans (Workflow starts, Activities, Signals, and so on) to whichever sink is active. + +### 1. `custom` — a custom `TracingProcessor` + +Registers a `RecordingTracingProcessor` (see `src/tracing/recording-processor.ts`) via `addTraceProcessor`. +It receives every trace and span lifecycle callback, so you can record, filter, or forward spans +anywhere. This is the mode exercised by the test. + +### 2. `openai` — OpenAI hosted traces + +Registers the upstream hosted exporter before constructing the plugin, so traces appear on the +OpenAI dashboard: + +```ts +addTraceProcessor(new BatchTraceProcessor(new OpenAITracingExporter())); +``` + +Register this in the Worker process, not inside Workflow code. + +### 3. `otel` — OpenTelemetry + +Installs a replay-safe tracer provider from `@temporalio/openai-agents/otel` and enables +`useOtelInstrumentation`: + +```ts +trace.setGlobalTracerProvider(createTracerProvider()); +// plugin: interceptorOptions: { useOtelInstrumentation: true } +``` + +`createTracerProvider` uses `TemporalIdGenerator` for replay-safe span/trace IDs. This mode requires +the optional peer dependency `@opentelemetry/sdk-trace-base`. + +## Run + +```bash +temporal server start-dev +``` + +Run these from the `openai-agents/` root (run `npm install` there once first). + +```bash +export OPENAI_API_KEY=sk-... +npx ts-node src/tracing/worker.ts custom +``` + +```bash +npx ts-node src/tracing/client.ts "What is 42 plus 58?" +``` + +## Test + +The test runs offline with a `FakeModelProvider`. It registers a custom `TracingProcessor`, runs an +agent that calls a function tool, and asserts that `agent` and `function` spans were emitted. + +```bash +npx mocha --exit --require ts-node/register --require source-map-support/register "src/tracing/mocha/*.test.ts" +``` diff --git a/openai-agents/src/tracing/client.ts b/openai-agents/src/tracing/client.ts new file mode 100644 index 00000000..c8793869 --- /dev/null +++ b/openai-agents/src/tracing/client.ts @@ -0,0 +1,34 @@ +import { Connection, Client } from '@temporalio/client'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { tracedAgent } from './workflows'; +import { nanoid } from 'nanoid'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const prompt = process.argv[2] ?? 'What is 42 plus 58?'; + + const connection = await Connection.connect(); + const client = new Client({ + connection, + plugins: [new OpenAIAgentsPlugin({ modelProvider: new OpenAIProvider({ apiKey }) })], + }); + + const handle = await client.workflow.start(tracedAgent, { + taskQueue: 'openai-agents-tracing', + workflowId: 'openai-agents-' + nanoid(), + args: [prompt], + }); + + console.log(`Started workflow ${handle.workflowId}`); + console.log(await handle.result()); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/openai-agents/src/tracing/mocha/fake-model.ts b/openai-agents/src/tracing/mocha/fake-model.ts new file mode 100644 index 00000000..f8e0ceae --- /dev/null +++ b/openai-agents/src/tracing/mocha/fake-model.ts @@ -0,0 +1,69 @@ +import { + Usage, + type AgentOutputItem, + type Model, + type ModelProvider, + type ModelRequest, + type ModelResponse, + type StreamEvent, +} from '@openai/agents-core'; + +export class FakeModel implements Model { + readonly requests: ModelRequest[] = []; + private getNext: () => ModelResponse; + constructor(source: ModelResponse[]) { + let index = 0; + this.getNext = () => { + if (index >= source.length) { + throw new Error( + `FakeModel: no more canned responses (called ${index + 1} times, only ${source.length} provided)`, + ); + } + return source[index++]!; + }; + } + async getResponse(request: ModelRequest): Promise { + this.requests.push(request); + return this.getNext(); + } + // eslint-disable-next-line require-yield + async *getStreamedResponse(_request: ModelRequest): AsyncIterable { + throw new Error('Streaming not supported in FakeModel'); + } +} + +export class FakeModelProvider implements ModelProvider { + readonly requestedModelNames: (string | undefined)[] = []; + readonly model: FakeModel; + constructor(source: ModelResponse[]) { + this.model = new FakeModel(source); + } + getModel(name?: string): Model { + this.requestedModelNames.push(name); + return this.model; + } +} + +function fakeUsage(outputTokens: number): Usage { + return new Usage({ requests: 1, inputTokens: 10, outputTokens, totalTokens: 10 + outputTokens }); +} + +export function textResponse(text: string): ModelResponse { + const output: AgentOutputItem[] = [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], status: 'completed' }, + ]; + return { output, usage: fakeUsage(text.length) }; +} + +export function toolCallResponse(toolName: string, args: Record): ModelResponse { + const output: AgentOutputItem[] = [ + { + type: 'function_call', + name: toolName, + arguments: JSON.stringify(args), + callId: `call_fake_${toolName}`, + status: 'completed', + }, + ]; + return { output, usage: fakeUsage(20) }; +} diff --git a/openai-agents/src/tracing/mocha/workflows.test.ts b/openai-agents/src/tracing/mocha/workflows.test.ts new file mode 100644 index 00000000..abb7dcc1 --- /dev/null +++ b/openai-agents/src/tracing/mocha/workflows.test.ts @@ -0,0 +1,62 @@ +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { after, before, describe, it } from 'mocha'; +import { Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { addTraceProcessor } from '@openai/agents-core'; +import assert from 'assert'; +import { FakeModelProvider, textResponse, toolCallResponse } from './fake-model'; +import { RecordingTracingProcessor } from '../recording-processor'; +import { tracedAgent } from '../workflows'; + +describe('openai-agents/tracing custom TracingProcessor', function () { + this.timeout(30_000); + + let testEnv: TestWorkflowEnvironment; + + before(async () => { + testEnv = await TestWorkflowEnvironment.createLocal(); + }); + + after(async () => { + await testEnv?.teardown(); + }); + + it('captures agent and function spans emitted during the run', async () => { + const taskQueue = 'test-tracing-custom'; + const recorder = new RecordingTracingProcessor(); + addTraceProcessor(recorder); + + const provider = new FakeModelProvider([ + toolCallResponse('add', { a: 42, b: 58 }), + textResponse('42 plus 58 is 100.'), + ]); + const worker = await Worker.create({ + connection: testEnv.nativeConnection, + taskQueue, + workflowsPath: require.resolve('../workflows'), + plugins: [new OpenAIAgentsPlugin({ modelProvider: provider, interceptorOptions: { addTemporalSpans: true } })], + bundlerOptions: { + webpackConfigHook: (config) => ({ + ...config, + resolve: { + ...config.resolve, + conditionNames: ['require', 'browser', 'default'], + }, + }), + }, + }); + + const result = await worker.runUntil( + testEnv.client.workflow.execute(tracedAgent, { + args: ['What is 42 plus 58?'], + workflowId: 'test-tracing-custom-' + Date.now(), + taskQueue, + }), + ); + assert.strictEqual(result, '42 plus 58 is 100.'); + + assert.ok(recorder.traceIds.length >= 1, 'at least one trace should be recorded'); + assert.ok(recorder.spanTypes.includes('agent'), 'an agent span should be recorded'); + assert.ok(recorder.spanTypes.includes('function'), 'a function tool span should be recorded'); + }); +}); diff --git a/openai-agents/src/tracing/recording-processor.ts b/openai-agents/src/tracing/recording-processor.ts new file mode 100644 index 00000000..98bb4551 --- /dev/null +++ b/openai-agents/src/tracing/recording-processor.ts @@ -0,0 +1,22 @@ +import type { Span, SpanData, Trace, TracingProcessor } from '@openai/agents-core'; + +export class RecordingTracingProcessor implements TracingProcessor { + readonly spanTypes: string[] = []; + readonly traceIds: string[] = []; + + async onTraceStart(trace: Trace): Promise { + this.traceIds.push(trace.traceId); + } + + async onTraceEnd(_trace: Trace): Promise {} + + async onSpanStart(_span: Span): Promise {} + + async onSpanEnd(span: Span): Promise { + this.spanTypes.push(span.spanData.type); + } + + async shutdown(_timeout?: number): Promise {} + + async forceFlush(): Promise {} +} diff --git a/openai-agents/src/tracing/worker.ts b/openai-agents/src/tracing/worker.ts new file mode 100644 index 00000000..aa7896da --- /dev/null +++ b/openai-agents/src/tracing/worker.ts @@ -0,0 +1,69 @@ +import { NativeConnection, Worker } from '@temporalio/worker'; +import { OpenAIAgentsPlugin } from '@temporalio/openai-agents'; +import { OpenAIProvider } from '@openai/agents-openai'; +import { OpenAITracingExporter } from '@openai/agents-openai'; +import { addTraceProcessor, BatchTraceProcessor } from '@openai/agents-core'; +import { trace } from '@opentelemetry/api'; +import { createTracerProvider } from '@temporalio/openai-agents/otel'; +import { RecordingTracingProcessor } from './recording-processor'; + +type TracingMode = 'openai' | 'custom' | 'otel'; + +async function run() { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error('OPENAI_API_KEY environment variable is required'); + } + + const mode = (process.argv[2] ?? process.env.TRACING_MODE ?? 'custom') as TracingMode; + console.log(`Tracing mode: ${mode}`); + + let useOtelInstrumentation = false; + switch (mode) { + case 'openai': + addTraceProcessor(new BatchTraceProcessor(new OpenAITracingExporter())); + break; + case 'custom': + addTraceProcessor(new RecordingTracingProcessor()); + break; + case 'otel': + trace.setGlobalTracerProvider(createTracerProvider()); + useOtelInstrumentation = true; + break; + default: + throw new Error(`Unknown tracing mode: ${mode}`); + } + + const connection = await NativeConnection.connect({ address: 'localhost:7233' }); + try { + const worker = await Worker.create({ + connection, + taskQueue: 'openai-agents-tracing', + workflowsPath: require.resolve('./workflows'), + plugins: [ + new OpenAIAgentsPlugin({ + modelProvider: new OpenAIProvider({ apiKey }), + modelParams: { useLocalActivity: true }, + interceptorOptions: { useOtelInstrumentation, addTemporalSpans: true }, + }), + ], + 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); +}); diff --git a/openai-agents/src/tracing/workflows.ts b/openai-agents/src/tracing/workflows.ts new file mode 100644 index 00000000..074171f1 --- /dev/null +++ b/openai-agents/src/tracing/workflows.ts @@ -0,0 +1,20 @@ +import { Agent, tool } from '@openai/agents-core'; +import { TemporalOpenAIRunner } from '@temporalio/openai-agents/workflow'; +import z from 'zod'; + +export async function tracedAgent(prompt: string): Promise { + const addTool = tool({ + name: 'add', + description: 'Add two numbers together.', + parameters: z.object({ a: z.number().describe('First number'), b: z.number().describe('Second number') }), + execute: async ({ a, b }) => String(a + b), + }); + + const agent = new Agent({ + name: 'TracedAgent', + instructions: 'You are a math assistant. Use the add tool to compute sums.', + tools: [addTool], + }); + const result = await new TemporalOpenAIRunner().run(agent, prompt); + return result.finalOutput ?? ''; +} diff --git a/openai-agents/tsconfig.json b/openai-agents/tsconfig.json new file mode 100644 index 00000000..488f2c62 --- /dev/null +++ b/openai-agents/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "version": "5.6.3", + "compilerOptions": { + "lib": ["es2021"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./lib" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0793ebf8..8f3b1b88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,7 +252,7 @@ importers: version: 3.0.2 '@modelcontextprotocol/sdk': specifier: ^1.10.2 - version: 1.25.1(hono@4.11.1)(zod@3.25.76) + version: 1.25.1(hono@4.12.25)(zod@3.25.76) '@temporalio/activity': specifier: ^1.18.1 version: 1.18.1 @@ -2722,6 +2722,97 @@ importers: specifier: ^5.6.3 version: 5.7.3 + openai-agents: + dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) + '@openai/agents-core': + specifier: ^0.11.6 + version: 0.11.6(ws@8.18.0)(zod@4.4.3) + '@openai/agents-openai': + specifier: ^0.11.6 + version: 0.11.6(ws@8.18.0)(zod@4.4.3) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@temporalio/activity': + specifier: ^1.18.0 + version: 1.18.1 + '@temporalio/client': + specifier: ^1.18.0 + version: 1.18.1 + '@temporalio/nexus': + specifier: ^1.18.0 + version: 1.18.1 + '@temporalio/openai-agents': + specifier: ^1.18.0 + version: 1.18.1(@openai/agents-core@0.11.6(ws@8.18.0)(zod@4.4.3))(@openai/agents-openai@0.11.6(ws@8.18.0)(zod@4.4.3))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.15)(openai@6.42.0(ws@8.18.0)(zod@4.4.3)) + '@temporalio/worker': + specifier: ^1.18.0 + version: 1.18.1(@swc/helpers@0.5.15) + '@temporalio/workflow': + specifier: ^1.18.0 + version: 1.18.1 + nanoid: + specifier: 3.x + version: 3.3.12 + nexus-rpc: + specifier: ^0.0.2 + version: 0.0.2 + openai: + specifier: ^6.0.0 + version: 6.42.0(ws@8.18.0)(zod@4.4.3) + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@opentelemetry/sdk-trace-base': + specifier: ^1.30.0 + version: 1.30.1(@opentelemetry/api@1.9.0) + '@temporalio/testing': + specifier: ^1.18.0 + version: 1.18.1(@swc/helpers@0.5.15) + '@tsconfig/node22': + specifier: ^22.0.0 + version: 22.0.5 + '@types/mocha': + specifier: 8.x + version: 8.2.3 + '@types/node': + specifier: ^22.9.1 + version: 22.12.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.18.0 + version: 8.22.0(@typescript-eslint/parser@8.22.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^8.18.0 + version: 8.22.0(eslint@8.57.1)(typescript@5.7.3) + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-plugin-deprecation: + specifier: ^3.0.0 + version: 3.0.0(eslint@8.57.1)(typescript@5.7.3) + mocha: + specifier: 8.x + version: 8.4.0(ts-node@10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3)) + prettier: + specifier: ^3.4.2 + version: 3.4.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@swc/core@1.10.11(@swc/helpers@0.5.15))(@types/node@22.12.0)(typescript@5.7.3) + typescript: + specifier: ^5.6.3 + version: 5.7.3 + patching-api: dependencies: '@temporalio/activity': @@ -5401,6 +5492,12 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.7': resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} engines: {node: '>=18.14.1'} @@ -5948,6 +6045,16 @@ packages: '@cfworker/json-schema': optional: true + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@nestjs/axios@3.1.2': resolution: {integrity: sha512-pFlfi4ZQsZtTNNhvgssbxjCHUd1nMpV3sXy/xOOB2uEJhw3M8j8SFR08gjFNil2we2Har7VCsXLfCkwbMHECFQ==} peerDependencies: @@ -6169,6 +6276,19 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@openai/agents-core@0.11.6': + resolution: {integrity: sha512-jWeo4mF+zjp9R80OPg+9prAnwF53dIpok2ymp9OkC3DpK2qcqBO8CfoEgocNp+E5HXXFW4uvCaY0olNCCcmTFw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@openai/agents-openai@0.11.6': + resolution: {integrity: sha512-63zXy+EPmg3WErLO12jLEjCTqGBZ/f0ApsW/t5wpccqUYCtImIT6IYZDgGM+zSEafHNGJmNOwGtEqHjz/Pyl+A==} + peerDependencies: + zod: ^4.0.0 + '@opentelemetry/api-logs@0.52.1': resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} engines: {node: '>=14'} @@ -7224,6 +7344,18 @@ packages: '@temporalio/workflow': 1.18.1 webpack: ^5.106.2 + '@temporalio/openai-agents@1.18.1': + resolution: {integrity: sha512-/t8lWy1Ae/h9Pvb2THGmgAo87trQZ7uHtR5A2QfjRnF3GqtIDhZ3Cfy6DBBMxkrGX24l9AAt/yDoyYIHX/q2uw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@openai/agents-core': ~0.11.6 + '@openai/agents-openai': ~0.11.6 + '@opentelemetry/sdk-trace-base': ^1.25.1 + openai: ^6.35.0 + peerDependenciesMeta: + '@opentelemetry/sdk-trace-base': + optional: true + '@temporalio/plugin@1.18.1': resolution: {integrity: sha512-sIAZtpSjVgkbfLqhTaJAu1rWxDQ/y5CNpu3v9atJagfGQAA7HF8974JI2bfTtL5OozhcclYbXOo9AciELFsuAA==} engines: {node: '>= 20.0.0'} @@ -7796,6 +7928,9 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} @@ -10035,6 +10170,12 @@ packages: peerDependencies: express: '>= 4.11' + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@4.20.0: resolution: {integrity: sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==} engines: {node: '>= 0.10.0'} @@ -10551,8 +10692,8 @@ packages: resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} engines: {node: '>=8'} - hono@4.11.1: - resolution: {integrity: sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} hoopy@0.1.4: @@ -12571,6 +12712,17 @@ packages: zod: optional: true + openai@6.42.0: + resolution: {integrity: sha512-1WFEt/uXMXOLhYRNkgJWo08Y2YNvNwpVU72K7ibrWgWpNOXd4VojXLbe6SQ4bLiUQ3Y8jz4IiyVkylJCL1DtZg==} + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -13480,6 +13632,7 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. + (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qs@6.11.0: @@ -15589,9 +15742,17 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zwitch@1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} @@ -16985,9 +17146,13 @@ snapshots: dependencies: react: 18.3.1 - '@hono/node-server@1.19.7(hono@4.11.1)': + '@hono/node-server@1.19.14(hono@4.12.25)': dependencies: - hono: 4.11.1 + hono: 4.12.25 + + '@hono/node-server@1.19.7(hono@4.12.25)': + dependencies: + hono: 4.12.25 '@humanwhocodes/config-array@0.13.0': dependencies: @@ -17755,9 +17920,9 @@ snapshots: - encoding - supports-color - '@modelcontextprotocol/sdk@1.25.1(hono@4.11.1)(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.25.1(hono@4.12.25)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.7(hono@4.11.1) + '@hono/node-server': 1.19.7(hono@4.12.25) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -17777,6 +17942,28 @@ snapshots: - hono - supports-color + '@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.25) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.25 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + '@nestjs/axios@3.1.2(@nestjs/common@10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.7.9)(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.15(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17990,6 +18177,29 @@ snapshots: transitivePeerDependencies: - encoding + '@openai/agents-core@0.11.6(ws@8.18.0)(zod@4.4.3)': + dependencies: + debug: 4.4.3(supports-color@5.5.0) + openai: 6.42.0(ws@8.18.0)(zod@4.4.3) + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + + '@openai/agents-openai@0.11.6(ws@8.18.0)(zod@4.4.3)': + dependencies: + '@openai/agents-core': 0.11.6(ws@8.18.0)(zod@4.4.3) + debug: 4.4.3(supports-color@5.5.0) + openai: 6.42.0(ws@8.18.0)(zod@4.4.3) + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + - ws + '@opentelemetry/api-logs@0.52.1': dependencies: '@opentelemetry/api': 1.9.0 @@ -19237,6 +19447,37 @@ snapshots: - supports-color - typescript + '@temporalio/openai-agents@1.18.1(@openai/agents-core@0.11.6(ws@8.18.0)(zod@4.4.3))(@openai/agents-openai@0.11.6(ws@8.18.0)(zod@4.4.3))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(@swc/helpers@0.5.15)(openai@6.42.0(ws@8.18.0)(zod@4.4.3))': + dependencies: + '@openai/agents-core': 0.11.6(ws@8.18.0)(zod@4.4.3) + '@openai/agents-openai': 0.11.6(ws@8.18.0)(zod@4.4.3) + '@opentelemetry/api': 1.9.0 + '@temporalio/activity': 1.18.1 + '@temporalio/common': 1.18.1 + '@temporalio/plugin': 1.18.1 + '@temporalio/worker': 1.18.1(@swc/helpers@0.5.15) + '@temporalio/workflow': 1.18.1 + '@ungap/structured-clone': 1.3.1 + headers-polyfill: 4.0.3 + openai: 6.42.0(ws@8.18.0)(zod@4.4.3) + web-streams-polyfill: 4.2.0 + optionalDependencies: + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + '@temporalio/plugin@1.18.1': {} '@temporalio/proto@1.18.1': @@ -20052,6 +20293,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@ungap/structured-clone@1.3.1': {} + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': dependencies: '@babel/core': 7.29.7 @@ -22860,6 +23103,11 @@ snapshots: dependencies: express: 5.2.1 + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + express@4.20.0: dependencies: accepts: 1.3.8 @@ -23586,7 +23834,7 @@ snapshots: hexoid@1.0.0: {} - hono@4.11.1: {} + hono@4.12.25: {} hoopy@0.1.4: {} @@ -26584,6 +26832,11 @@ snapshots: transitivePeerDependencies: - encoding + openai@6.42.0(ws@8.18.0)(zod@4.4.3): + optionalDependencies: + ws: 8.18.0 + zod: 4.4.3 + optionator@0.8.3: dependencies: deep-is: 0.1.4 @@ -30510,8 +30763,14 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod@3.25.76: {} + zod@4.4.3: {} + zwitch@1.0.5: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3a2996bd..91640cae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,6 +9,7 @@ packages: - 'monorepo-folders/packages/*' - 'food-delivery/apps/*' - 'food-delivery/packages/*' + - 'openai-agents/*' - 'polling/*' allowBuilds: '@nestjs/core': true