diff --git a/openai_agents/README.md b/openai_agents/README.md index f6857d795..01c9377fd 100644 --- a/openai_agents/README.md +++ b/openai_agents/README.md @@ -39,4 +39,5 @@ Each directory contains a complete example with its own README for detailed inst - **[Customer Service](./customer_service/README.md)** - Interactive customer service agent with escalation capabilities, demonstrating conversational workflows. - **[Reasoning Content](./reasoning_content/README.md)** - Example of how to retrieve the thought process of reasoning models. - **[Financial Research Agent](./financial_research_agent/README.md)** - Multi-agent financial research system with planner, search, analyst, writer, and verifier agents collaborating. +- **[Sandbox](./sandbox/README.md)** - `SandboxAgent` with a shell and filesystem, where every sandbox operation runs as a Temporal activity. **Pre-release.** - **[Streaming](./streaming/README.md)** - `Runner.run_streamed` with buffered token streaming to external subscribers via `temporalio.contrib.workflow_streams`. **Experimental.** diff --git a/openai_agents/agent_patterns/workflows/agents_as_tools_workflow.py b/openai_agents/agent_patterns/workflows/agents_as_tools_workflow.py index db849c1cd..126c09fa8 100644 --- a/openai_agents/agent_patterns/workflows/agents_as_tools_workflow.py +++ b/openai_agents/agent_patterns/workflows/agents_as_tools_workflow.py @@ -8,6 +8,7 @@ """ +# @@@SNIPSTART python-openai-agents-agent-as-tool-workflow def orchestrator_agent() -> Agent: spanish_agent = Agent( name="spanish_agent", @@ -52,6 +53,9 @@ def orchestrator_agent() -> Agent: return orchestrator_agent +# @@@SNIPEND + + def synthesizer_agent() -> Agent: return Agent( name="synthesizer_agent", diff --git a/openai_agents/basic/activities/get_weather_activity.py b/openai_agents/basic/activities/get_weather_activity.py index c8be473c4..8afcf0a44 100644 --- a/openai_agents/basic/activities/get_weather_activity.py +++ b/openai_agents/basic/activities/get_weather_activity.py @@ -1,3 +1,4 @@ +# @@@SNIPSTART python-openai-agents-weather-activity from dataclasses import dataclass from temporalio import activity @@ -16,3 +17,6 @@ async def get_weather(city: str) -> Weather: Get the weather for a given city. """ return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + + +# @@@SNIPEND diff --git a/openai_agents/basic/run_hello_world_workflow.py b/openai_agents/basic/run_hello_world_workflow.py index 0662a4fa3..9893d4052 100644 --- a/openai_agents/basic/run_hello_world_workflow.py +++ b/openai_agents/basic/run_hello_world_workflow.py @@ -8,6 +8,7 @@ async def main(): # Create client connected to server at the given address + # @@@SNIPSTART python-openai-agents-hello-world-client client = await Client.connect( "localhost:7233", plugins=[ @@ -23,6 +24,7 @@ async def main(): task_queue="openai-agents-basic-task-queue", ) print(f"Result: {result}") + # @@@SNIPEND if __name__ == "__main__": diff --git a/openai_agents/basic/run_worker.py b/openai_agents/basic/run_worker.py index 94d6a8823..3586c8a2f 100644 --- a/openai_agents/basic/run_worker.py +++ b/openai_agents/basic/run_worker.py @@ -34,6 +34,7 @@ async def main(): # Create client connected to server at the given address + # @@@SNIPSTART python-openai-agents-hello-world-worker client = await Client.connect( "localhost:7233", plugins=[ @@ -44,6 +45,7 @@ async def main(): ), ], ) + # @@@SNIPEND worker = Worker( client, diff --git a/openai_agents/basic/workflows/hello_world_workflow.py b/openai_agents/basic/workflows/hello_world_workflow.py index dd6b2e41b..a62eb32cc 100644 --- a/openai_agents/basic/workflows/hello_world_workflow.py +++ b/openai_agents/basic/workflows/hello_world_workflow.py @@ -1,3 +1,4 @@ +# @@@SNIPSTART python-openai-agents-hello-world-workflow from agents import Agent, Runner from temporalio import workflow @@ -13,3 +14,6 @@ async def run(self, prompt: str) -> str: result = await Runner.run(agent, input=prompt) return result.final_output + + +# @@@SNIPEND diff --git a/openai_agents/basic/workflows/tools_workflow.py b/openai_agents/basic/workflows/tools_workflow.py index 70964dc09..d9c79d596 100644 --- a/openai_agents/basic/workflows/tools_workflow.py +++ b/openai_agents/basic/workflows/tools_workflow.py @@ -9,6 +9,7 @@ from openai_agents.basic.activities.get_weather_activity import get_weather +# @@@SNIPSTART python-openai-agents-activity-tool-workflow @workflow.defn class ToolsWorkflow: @workflow.run @@ -25,3 +26,6 @@ async def run(self, question: str) -> str: result = await Runner.run(agent, input=question) return result.final_output + + +# @@@SNIPEND diff --git a/openai_agents/customer_service/workflows/customer_service_workflow.py b/openai_agents/customer_service/workflows/customer_service_workflow.py index 0157d0508..49a0aa423 100644 --- a/openai_agents/customer_service/workflows/customer_service_workflow.py +++ b/openai_agents/customer_service/workflows/customer_service_workflow.py @@ -56,6 +56,7 @@ def __init__( customer_service_state.input_items if customer_service_state else [] ) + # @@@SNIPSTART python-openai-agents-continue-as-new-workflow @workflow.run async def run( self, customer_service_state: CustomerServiceWorkflowState | None = None @@ -73,6 +74,8 @@ async def run( ) ) + # @@@SNIPEND + @workflow.query def get_chat_history(self) -> list[str]: return self.printed_history diff --git a/openai_agents/hosted_mcp/workflows/approval_mcp_workflow.py b/openai_agents/hosted_mcp/workflows/approval_mcp_workflow.py index 1b5b7b6f9..9f85343a3 100644 --- a/openai_agents/hosted_mcp/workflows/approval_mcp_workflow.py +++ b/openai_agents/hosted_mcp/workflows/approval_mcp_workflow.py @@ -10,6 +10,7 @@ from temporalio import workflow +# @@@SNIPSTART python-openai-agents-hosted-mcp-approval-workflow def approval_callback(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctionResult: """Simple approval callback that logs the request and approves by default. @@ -23,6 +24,9 @@ def approval_callback(request: MCPToolApprovalRequest) -> MCPToolApprovalFunctio return result +# @@@SNIPEND + + @workflow.defn class ApprovalMCPWorkflow: @workflow.run diff --git a/openai_agents/hosted_mcp/workflows/simple_mcp_workflow.py b/openai_agents/hosted_mcp/workflows/simple_mcp_workflow.py index 2fac64bc5..ab12c1d14 100644 --- a/openai_agents/hosted_mcp/workflows/simple_mcp_workflow.py +++ b/openai_agents/hosted_mcp/workflows/simple_mcp_workflow.py @@ -4,6 +4,7 @@ from temporalio import workflow +# @@@SNIPSTART python-openai-agents-hosted-mcp-workflow @workflow.defn class SimpleMCPWorkflow: @workflow.run @@ -26,3 +27,6 @@ async def run( result = await Runner.run(agent, question) return result.final_output + + +# @@@SNIPEND diff --git a/openai_agents/mcp/run_file_system_worker.py b/openai_agents/mcp/run_file_system_worker.py index 2ed8dffdf..0deb8463e 100644 --- a/openai_agents/mcp/run_file_system_worker.py +++ b/openai_agents/mcp/run_file_system_worker.py @@ -23,6 +23,7 @@ async def main(): current_dir = os.path.dirname(os.path.abspath(__file__)) samples_dir = os.path.join(current_dir, "sample_files") + # @@@SNIPSTART python-openai-agents-stateless-mcp-worker file_system_server = StatelessMCPServerProvider( "FileSystemServer", lambda: MCPServerStdio( @@ -48,6 +49,7 @@ async def main(): ), ], ) + # @@@SNIPEND worker = Worker( client, diff --git a/openai_agents/mcp/run_memory_research_scratchpad_worker.py b/openai_agents/mcp/run_memory_research_scratchpad_worker.py index 536ab9745..ae590ad38 100644 --- a/openai_agents/mcp/run_memory_research_scratchpad_worker.py +++ b/openai_agents/mcp/run_memory_research_scratchpad_worker.py @@ -22,6 +22,7 @@ async def main(): logging.basicConfig(level=logging.INFO) + # @@@SNIPSTART python-openai-agents-stateful-mcp-worker memory_server_provider = StatefulMCPServerProvider( "MemoryServer", lambda _: MCPServerStdio( @@ -47,6 +48,7 @@ async def main(): ), ], ) + # @@@SNIPEND worker = Worker( client, diff --git a/openai_agents/mcp/workflows/file_system_workflow.py b/openai_agents/mcp/workflows/file_system_workflow.py index b3528c185..7ee6ab885 100644 --- a/openai_agents/mcp/workflows/file_system_workflow.py +++ b/openai_agents/mcp/workflows/file_system_workflow.py @@ -11,6 +11,7 @@ class FileSystemWorkflow: @workflow.run async def run(self) -> str: with trace(workflow_name="MCP File System Example"): + # @@@SNIPSTART python-openai-agents-stateless-mcp-workflow server: MCPServer = openai_agents.workflow.stateless_mcp_server( "FileSystemServer" ) @@ -19,6 +20,7 @@ async def run(self) -> str: instructions="Use the tools to read the filesystem and answer questions based on those files.", mcp_servers=[server], ) + # @@@SNIPEND # List the files it can read message = "Read the files and list them." diff --git a/openai_agents/mcp/workflows/memory_research_scratchpad_workflow.py b/openai_agents/mcp/workflows/memory_research_scratchpad_workflow.py index 1812a4521..a381843a9 100644 --- a/openai_agents/mcp/workflows/memory_research_scratchpad_workflow.py +++ b/openai_agents/mcp/workflows/memory_research_scratchpad_workflow.py @@ -43,6 +43,7 @@ class MemoryResearchScratchpadWorkflow: @workflow.run async def run(self) -> str: + # @@@SNIPSTART python-openai-agents-stateful-mcp-workflow async with temporal_openai_agents.workflow.stateful_mcp_server( "MemoryServer", ) as server: @@ -57,6 +58,7 @@ async def run(self) -> str: mcp_servers=[server], model_settings=ModelSettings(tool_choice="required"), ) + # @@@SNIPEND # Step 1: Write seed notes to memory write_prompt_lines = [ diff --git a/openai_agents/sandbox/README.md b/openai_agents/sandbox/README.md new file mode 100644 index 000000000..61ef96734 --- /dev/null +++ b/openai_agents/sandbox/README.md @@ -0,0 +1,59 @@ +# Sandbox OpenAI Agents + +> **Pre-release.** Sandbox support in `temporalio.contrib.openai_agents` is +> subject to change before general availability. + +Before running this example, be sure to review the +[prerequisites and background on the integration](../README.md). + +`SandboxAgent` from the OpenAI Agents SDK gives an agent a machine to work on: +a shell it can run commands in and a filesystem it can read and write. The +plugin runs every one of those operations as a Temporal activity against a +`SandboxClientProvider` registered on the worker, so sandbox work is +observable, retryable, and recoverable like any other activity. The sandbox +session state is serialized with the workflow, so a worker restart part-way +through a run resumes against the same session. + +The workflow refers to a backend by name. `temporal_sandbox_client("local")` +resolves to whichever `SandboxClientProvider` the worker registered under +`"local"`, and the name becomes the prefix of that backend's activity names — +which is what lets several backends coexist on one worker. Names must match +exactly. + +This sample uses `UnixLocalSandboxClient`, which runs commands on the worker +host and needs no credentials beyond `OPENAI_API_KEY`. **The agent gets a real +shell on the machine running the worker**, so treat it accordingly: for +anything you would not run locally, register a remote client such as +`DaytonaSandboxClient` or `E2BSandboxClient` from +`agents.extensions.sandbox` instead. Only the worker changes — the workflow +still just names a provider. + +## Running the Example + +First, start the worker: + +```bash +uv run openai_agents/sandbox/run_worker.py +``` + +Then, in another terminal, run the workflow: + +```bash +uv run openai_agents/sandbox/run_local_sandbox_workflow.py +``` + +The agent writes a file in the sandbox, reads it back, and reports what it +found. In the Web UI at http://localhost:8233 the run shows the model +activities interleaved with the `local-sandbox_session_*` activities that carry +out the sandbox work. + +## Notes + +* A default `SandboxAgent` already carries the `Filesystem`, `Shell`, and + `Compaction` capabilities, so this sample declares no tools of its own. +* `temporal_sandbox_client()` takes an optional `ActivityConfig` for timeouts + and retries on the sandbox activities. It defaults to a 5-minute + `start_to_close_timeout`. +* A single workflow can target several backends by calling + `temporal_sandbox_client()` once per name, as long as the worker registers a + provider for each. diff --git a/openai_agents/sandbox/run_local_sandbox_workflow.py b/openai_agents/sandbox/run_local_sandbox_workflow.py new file mode 100644 index 000000000..f3b285458 --- /dev/null +++ b/openai_agents/sandbox/run_local_sandbox_workflow.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import asyncio + +from temporalio.client import Client +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin + +from openai_agents.sandbox.shared import TASK_QUEUE +from openai_agents.sandbox.workflows.local_sandbox_workflow import ( + LocalSandboxWorkflow, +) + + +async def main() -> None: + client = await Client.connect( + "localhost:7233", + plugins=[OpenAIAgentsPlugin()], + ) + + result = await client.execute_workflow( + LocalSandboxWorkflow.run, + "Write a file holding the first 20 Fibonacci numbers, one per line, " + "then tell me how many lines it has and what the last one is.", + id="openai-agents-sandbox", + task_queue=TASK_QUEUE, + ) + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openai_agents/sandbox/run_worker.py b/openai_agents/sandbox/run_worker.py new file mode 100644 index 000000000..c970f5920 --- /dev/null +++ b/openai_agents/sandbox/run_worker.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import asyncio +from datetime import timedelta + +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient +from temporalio.client import Client +from temporalio.contrib.openai_agents import ( + ModelActivityParameters, + OpenAIAgentsPlugin, + SandboxClientProvider, +) +from temporalio.worker import Worker + +from openai_agents.sandbox.shared import SANDBOX_PROVIDER, TASK_QUEUE +from openai_agents.sandbox.workflows.local_sandbox_workflow import ( + LocalSandboxWorkflow, +) + + +async def main() -> None: + # @@@SNIPSTART python-openai-agents-sandbox-worker + client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=60) + ), + # The plugin registers one set of sandbox activities per + # provider, prefixed with the provider name. Register several + # providers to let one worker serve several backends. + sandbox_clients=[ + SandboxClientProvider(SANDBOX_PROVIDER, UnixLocalSandboxClient()), + ], + ), + ], + ) + # @@@SNIPEND + + worker = Worker( + client, + task_queue=TASK_QUEUE, + workflows=[LocalSandboxWorkflow], + ) + print("Worker started. Ctrl+C to exit.") + await worker.run() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/openai_agents/sandbox/shared.py b/openai_agents/sandbox/shared.py new file mode 100644 index 000000000..377bf972f --- /dev/null +++ b/openai_agents/sandbox/shared.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +TASK_QUEUE = "openai-agents-sandbox-task-queue" + +# Name the worker registers its SandboxClientProvider under, and the name the +# workflow passes to temporal_sandbox_client(). The two must match exactly: +# the name becomes the prefix of that backend's activity names, which is what +# lets several backends share one worker. +SANDBOX_PROVIDER = "local" diff --git a/openai_agents/sandbox/workflows/local_sandbox_workflow.py b/openai_agents/sandbox/workflows/local_sandbox_workflow.py new file mode 100644 index 000000000..54cd12086 --- /dev/null +++ b/openai_agents/sandbox/workflows/local_sandbox_workflow.py @@ -0,0 +1,65 @@ +"""A ``SandboxAgent`` whose sandbox operations run as Temporal activities. + +``SandboxAgent`` gives an agent a real machine to work on: it can run shell +commands and read and write files. The plugin routes every one of those +operations — creating the session, each ``exec``, each read and write, and the +teardown — through a Temporal activity against the ``SandboxClientProvider`` +registered on the worker under the name passed to +``temporal_sandbox_client()``. + +Two consequences worth knowing: + +1. Each sandbox operation is individually retryable and shows up in workflow + history, so a flaky command is a retried activity rather than a lost run. +2. The sandbox session state is serialized with the workflow, so a worker + restart mid-run resumes against the same session instead of starting over. + +This sample uses the local Unix backend, which runs commands on the worker +host and needs no credentials. Swap in a remote client such as +``DaytonaSandboxClient`` for anything you would not run on your own machine — +only the worker changes, the workflow just names a different provider. +""" + +from __future__ import annotations + +from agents import Runner +from agents.run import RunConfig +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions +from temporalio import workflow +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client + +from openai_agents.sandbox.shared import SANDBOX_PROVIDER + + +# @@@SNIPSTART python-openai-agents-sandbox-workflow +@workflow.defn +class LocalSandboxWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + # A default SandboxAgent already carries the Filesystem, Shell, and + # Compaction capabilities, so there are no tools to declare here. + agent = SandboxAgent[None]( + name="Sandbox Assistant", + instructions=( + "You have a sandbox with a shell and a filesystem. Use it to do " + "the work rather than answering from memory, then report what " + "the commands returned." + ), + ) + + result = await Runner.run( + starting_agent=agent, + input=prompt, + run_config=RunConfig( + sandbox=SandboxRunConfig( + # Must match the name registered on the worker. + client=temporal_sandbox_client(SANDBOX_PROVIDER), + options=UnixLocalSandboxClientOptions(), + ), + ), + ) + return result.final_output_as(str, raise_if_incorrect_type=True) + + +# @@@SNIPEND diff --git a/openai_agents/streaming/run_stream_text_workflow.py b/openai_agents/streaming/run_stream_text_workflow.py index 5b3181a2d..5f51ebeb2 100644 --- a/openai_agents/streaming/run_stream_text_workflow.py +++ b/openai_agents/streaming/run_stream_text_workflow.py @@ -51,6 +51,7 @@ async def main() -> None: task_queue=TASK_QUEUE, ) + # @@@SNIPSTART python-openai-agents-streaming-client stream = WorkflowStreamClient.create(client, workflow_id) converter = client.data_converter.payload_converter @@ -88,6 +89,7 @@ async def main() -> None: if isinstance(event, ResponseTextDeltaEvent): print(event.delta, end="", flush=True) + # @@@SNIPEND result = await handle.result() print("\n--- final result ---") diff --git a/openai_agents/streaming/workflows/stream_text_workflow.py b/openai_agents/streaming/workflows/stream_text_workflow.py index abbd8b23c..54a31228e 100644 --- a/openai_agents/streaming/workflows/stream_text_workflow.py +++ b/openai_agents/streaming/workflows/stream_text_workflow.py @@ -41,6 +41,7 @@ class StreamTextInput: stream_state: WorkflowStreamState | None = None +# @@@SNIPSTART python-openai-agents-streaming-workflow @workflow.defn class StreamTextWorkflow: @workflow.init @@ -81,3 +82,6 @@ async def run(self, input: StreamTextInput) -> str: # message output, so assert the str this signature promises rather # than letting a None through. return result.final_output_as(str, raise_if_incorrect_type=True) + + +# @@@SNIPEND diff --git a/openai_agents/tools/workflows/web_search_workflow.py b/openai_agents/tools/workflows/web_search_workflow.py index 8b505ac14..208396301 100644 --- a/openai_agents/tools/workflows/web_search_workflow.py +++ b/openai_agents/tools/workflows/web_search_workflow.py @@ -4,6 +4,7 @@ from temporalio import workflow +# @@@SNIPSTART python-openai-agents-hosted-tool-workflow @workflow.defn class WebSearchWorkflow: @workflow.run @@ -18,3 +19,6 @@ async def run(self, question: str, user_city: str = "New York") -> str: result = await Runner.run(agent, question) return result.final_output + + +# @@@SNIPEND