diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile new file mode 100644 index 000000000..8e0ec22df --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 00_sync/050_openai_agents_local_sandbox/pyproject.toml /app/050_openai_agents_local_sandbox/pyproject.toml +COPY 00_sync/050_openai_agents_local_sandbox/README.md /app/050_openai_agents_local_sandbox/README.md + +WORKDIR /app/050_openai_agents_local_sandbox + +# Copy the project code +COPY 00_sync/050_openai_agents_local_sandbox/project /app/050_openai_agents_local_sandbox/project + +# Copy the test files +COPY 00_sync/050_openai_agents_local_sandbox/tests /app/050_openai_agents_local_sandbox/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=s050-openai-agents-local-sandbox + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md new file mode 100644 index 000000000..9c2c81d7d --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/README.md @@ -0,0 +1,113 @@ +# Tutorial 050: Sync OpenAI Agents SDK with a Local Sandbox + +This tutorial demonstrates how to build a **synchronous** agent on AgentEx using the +[OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) and its +**sandbox** runtime, running with the **local** (`unix_local`) backend. + +The agent is a "local sandbox assistant": it answers questions by actually running +real shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) +instead of guessing. + +## Key Concepts + +### Sync ACP +The sync ACP model uses HTTP request/response for communication. The +`@acp.on_message_send` handler receives a message, runs the agent, and returns the +agent's final answer as a `TextContent`. + +### OpenAI Agents SDK Sandbox +The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent +**capabilities** (instead of hand-written tools) that the runtime turns into real +tools backed by a sandbox: + +- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. +- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): + each capability expands into a set of real tools. This tutorial uses `Shell`, which + lets the model run real shell commands. +- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools + actually execute. + +### The LOCAL sandbox (`UnixLocalSandboxClient`) +This tutorial uses the local backend +(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), +`backend_id="unix_local"`. The local sandbox runs shell commands **ON THE HOST** — +the agent's own container/process. There is **no Docker, no Temporal, and no remote +sandbox infrastructure** involved. This makes it the simplest way to give an agent a +real shell. + +The sandbox is wired up through the SDK's `RunConfig`: + +```python +from agents import Runner, set_tracing_disabled +from agents.run_config import RunConfig +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.sandbox.capabilities import Shell +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway + +agent = SandboxAgent( + name="Local Sandbox Assistant", + instructions="...use the shell tools to actually run commands...", + capabilities=[Shell()], +) +run_config = RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) +) +result = await Runner.run(agent, input="what's the python version?", run_config=run_config) +print(result.final_output) +``` + +`Runner.run` drives the full tool-call loop internally: the model issues shell +commands, the local sandbox runs them on the host, the output is fed back, and the +loop continues until the model produces a final answer. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | ACP server and message handler (runs the sandbox agent) | +| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | +| `project/tools.py` | Sandbox capability factory (`Shell`) | +| `tests/test_agent.py` | Integration tests | +| `manifest.yaml` | Agent configuration | + +## Running Locally + +```bash +# From this directory +agentex agents run +``` + +Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM +gateway) in your environment or in a `.env` file in `project/` so the agent can call +the model. + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` + +## Notes + +- **No infra required.** Because this uses the `unix_local` backend, the shell tools + run directly in the agent's process — no Docker daemon, no Temporal, no remote + sandbox. Swap the client for a remote/containerized backend to isolate execution. +- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native + tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest + also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the + tracing manager configured in `acp.py` when SGP credentials are present. +- **Capabilities are the tools.** To let the agent do more, add capabilities in + `project/tools.py` (e.g. `Filesystem()`, `Memory()`). + +## Further Reading + +- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents +- The next evolution of the Agents SDK: https://openai.com/index/the-next-evolution-of-the-agents-sdk/ diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml new file mode 100644 index 000000000..8ae5b98a1 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/manifest.yaml @@ -0,0 +1,61 @@ +build: + context: + root: ../../ + include_paths: + - 00_sync/050_openai_agents_local_sandbox + - test_utils + dockerfile: 00_sync/050_openai_agents_local_sandbox/Dockerfile + dockerignore: 00_sync/050_openai_agents_local_sandbox/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: sync + name: s050-openai-agents-local-sandbox + description: A sync OpenAI Agents SDK agent using a local (unix_local) sandbox + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + + env: + OPENAI_AGENTS_DISABLE_TRACING: "1" + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "s050-openai-agents-local-sandbox" + description: "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py new file mode 100644 index 000000000..005d679bf --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/acp.py @@ -0,0 +1,77 @@ +"""ACP (Agent Communication Protocol) handler for Agentex. + +This is the API layer — it owns the agent lifecycle and runs the OpenAI Agents +SDK *sandbox* agent for each incoming message, returning the agent's final +answer to the Agentex frontend. + +The agent uses the LOCAL sandbox backend (``UnixLocalSandboxClient``), which runs +shell commands on the host (this process/container). The OpenAI Agents SDK runs +its tool-call loop internally via ``Runner.run`` and returns the final output, so +this sync handler returns a single ``TextContent`` rather than streaming tokens. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib import adk +from project.agent import run_agent +from agentex.lib.types.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +logger = make_logger(__name__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same example works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +SGP_CLIENT_BASE_URL = os.environ.get("SGP_CLIENT_BASE_URL", "") + +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=SGP_CLIENT_BASE_URL, + ) + ) + +acp = FastACP.create(acp_type="sync") + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent: + """Handle incoming messages by running the local-sandbox agent.""" + task_id = params.task.id + user_message = params.content.content + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + final_output = await run_agent(user_message) + if turn_span: + turn_span.output = {"final_output": final_output} + + return TextContent(author="agent", content=final_output) diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py new file mode 100644 index 000000000..d674d14c9 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/agent.py @@ -0,0 +1,92 @@ +"""OpenAI Agents SDK local-sandbox agent definition. + +This mirrors the Pydantic AI tutorial (040): the agent is the boundary between +this module and the API layer (acp.py). The difference is the runtime — here we +use the OpenAI Agents SDK ``SandboxAgent`` together with the **local** sandbox +backend (``UnixLocalSandboxClient``). + +The local sandbox runs shell commands ON THE HOST — the agent's own +container/process. There is no Docker, no Temporal, and no remote sandbox +infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: +when the model decides to run a shell command, the sandbox executes it locally +and feeds the output back to the model until it produces a final answer. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Runner, set_tracing_disabled +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run_config import RunConfig +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +from project.tools import get_capabilities + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would +# 401). Agentex tracing still runs via the tracing manager configured in acp.py. +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o-mini" +INSTRUCTIONS = """You are a local sandbox assistant. + +Current date and time: {timestamp} + +You have access to shell tools that run real commands on the local machine. + +Guidelines: +- ALWAYS use the shell tools to actually run commands — never guess or make up + output. If the user asks for the Python version, run `python3 --version`. If + they ask to list files, run `ls`. If they ask you to compute something, use + `python3 -c "..."`. +- Run the minimal command(s) needed to answer the question. +- Report the real command output back to the user, concisely. +""" + + +def create_agent() -> SandboxAgent: + """Build and return the OpenAI Agents SDK sandbox agent. + + The agent is granted shell capabilities (see ``project.tools``). The actual + sandbox backend (where the shell commands run) is supplied at run time via + the ``RunConfig`` returned by ``create_run_config``. + """ + return SandboxAgent( + name="Local Sandbox Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ), + capabilities=get_capabilities(), + ) + + +def create_run_config() -> RunConfig: + """Build the RunConfig that points the agent at the LOCAL sandbox backend. + + ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on + the host — the agent's own process — so no Docker or remote infra is needed. + """ + return RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) + ) + + +async def run_agent(user_message: str) -> str: + """Run the sandbox agent on a single user message and return the final text. + + The OpenAI Agents SDK handles the full tool-call loop internally: the model + issues shell commands, the local sandbox runs them on the host, and the + output is fed back until the model produces a final answer. + """ + agent = create_agent() + run_config = create_run_config() + result = await Runner.run(agent, input=user_message, run_config=run_config, max_turns=10) + return result.final_output diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py new file mode 100644 index 000000000..0ad8f25ac --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/project/tools.py @@ -0,0 +1,29 @@ +"""Sandbox capabilities for the OpenAI Agents SDK local-sandbox agent. + +Unlike the Pydantic AI tutorial (040), this agent does not register hand-written +Python functions as tools. Instead it is given *capabilities* — the OpenAI Agents +SDK sandbox runtime turns each capability into a real set of tools (run a shell +command, read a file, etc.) backed by an actual sandbox backend. + +Here we use the ``Shell`` capability, which lets the model run real shell commands. +With the local (``unix_local``) backend those commands execute ON THE HOST — the +agent's own process/container — so there is no Docker, Temporal, or remote infra +involved. This module hosts the capability factory so the agent wiring in +``project.agent`` stays readable and the capability set is easy to extend +(e.g. add ``Filesystem()`` or ``Memory()``). +""" + +from __future__ import annotations + +from agents.sandbox.capabilities import Shell + + +def get_capabilities() -> list: + """Return the sandbox capabilities the agent is allowed to use. + + Returns: + A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so + the agent can run real shell commands on the local machine. Add + ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. + """ + return [Shell()] diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml new file mode 100644 index 000000000..472a6bef7 --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "s050-openai-agents-local-sandbox" +version = "0.1.0" +description = "A sync OpenAI Agents SDK agent using a local (unix_local) sandbox" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents>=0.14.3,<0.15", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py new file mode 100644 index 000000000..52ed1bf2f --- /dev/null +++ b/examples/tutorials/00_sync/050_openai_agents_local_sandbox/tests/test_agent.py @@ -0,0 +1,148 @@ +"""Tests for the sync OpenAI Agents SDK local-sandbox agent. + +This test suite validates: +- Sending a message that requires the agent to actually run a shell command in + the LOCAL sandbox (unix_local backend) and receiving a non-empty response. + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: s050-openai-agents-local-sandbox) +""" + +import os + +import pytest +from test_utils.sync import validate_text_in_string + +from agentex import Agentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsSendMessageRequest + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "s050-openai-agents-local-sandbox") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +def _response_text(result) -> str: + """Flatten a send_message result into a single string for assertions. + + Result items may be a bare string, a ``TextContent`` (``.content`` is the + string), or a ``TaskMessage`` wrapping a ``TextContent`` (``.content`` is the + ``TextContent``, whose ``.content`` is the string). Dig through ``.content`` + until we reach a string. + """ + + def _text_of(obj, _depth: int = 0) -> str: + if isinstance(obj, str): + return obj + if _depth > 5: + return "" + inner = getattr(obj, "content", None) + if inner is None: + return "" + return _text_of(inner, _depth + 1) + + parts = [t for t in (_text_of(item) for item in result) if t] + return "\n".join(parts) + + +class TestLocalSandboxMessages: + """Test the local-sandbox OpenAI Agents SDK agent.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Hello! What can you help me with?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_shell_python_version(self, client: Agentex, agent_name: str): + """Test that the agent uses its shell to run a real command. + + We ask it to print the Python version. The agent should run + `python3 --version` in the local sandbox and report the real output, + which always starts with "Python 3". + """ + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use your shell to print the Python version on this " + "machine, then tell me what it is." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + # The sandbox runs on Python 3.12, so the real output contains "Python 3". + validate_text_in_string("Python 3", text) + + def test_shell_compute(self, client: Agentex, agent_name: str): + """Test that the agent uses python3 in the sandbox to compute a value.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use python3 in your shell to compute 21 * 2 and tell me " + "the result." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + validate_text_in_string("42", text) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile new file mode 100644 index 000000000..1272027cf --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/Dockerfile @@ -0,0 +1,50 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml +COPY 10_async/00_base/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md + +WORKDIR /app/120_openai_agents_local_sandbox + +# Copy the project code +COPY 10_async/00_base/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project + +# Copy the test files +COPY 10_async/00_base/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] pytest-asyncio httpx + +# Set environment variables +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=ab120-openai-agents-local-sandbox + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md new file mode 100644 index 000000000..58d422b39 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/README.md @@ -0,0 +1,119 @@ +# Tutorial 120: Async OpenAI Agents SDK with a Local Sandbox + +This tutorial demonstrates how to build an **async (non-Temporal)** agent on AgentEx +using the [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) +and its **sandbox** runtime, running with the **local** (`unix_local`) backend. + +The agent is a "local sandbox assistant": it answers questions by actually running +real shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) +instead of guessing. + +This mirrors the Pydantic AI async tutorial (`110_pydantic_ai`): same async ACP +model (`acp_type: async`, `temporal.enabled: false`), same per-task `adk.state` +multi-turn memory pattern. The difference is the runtime — here we use the OpenAI +Agents SDK `SandboxAgent` with the local sandbox backend. + +## Key Concepts + +### Async ACP (base) +The async ACP model is event-driven: `on_task_create` initializes per-task state, +and `on_task_event_send` handles each user message. Conversation history is +persisted across turns via `adk.state`. + +### OpenAI Agents SDK Sandbox +The OpenAI Agents SDK ships `agents.sandbox`, which lets you give an agent +**capabilities** (instead of hand-written tools) that the runtime turns into real +tools backed by a sandbox: + +- **`SandboxAgent`**: an `Agent` that is granted sandbox capabilities. +- **Capabilities** (`from agents.sandbox.capabilities import Shell, Filesystem, Memory`): + each capability expands into a set of real tools. This tutorial uses `Shell`, which + lets the model run real shell commands. +- **`SandboxRunConfig`** + a sandbox **client**: tells the runtime *where* the tools + actually execute. + +### The LOCAL sandbox (`UnixLocalSandboxClient`) +This tutorial uses the local backend +(`from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient, UnixLocalSandboxClientOptions`), +`backend_id="unix_local"`. The local sandbox runs shell commands **ON THE HOST** — +the agent's own container/process. There is **no Docker, no Temporal, and no remote +sandbox infrastructure** involved. + +The sandbox is wired up through the SDK's `RunConfig`: + +```python +from agents import Runner, set_tracing_disabled +from agents.run_config import RunConfig +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.sandbox.capabilities import Shell +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +set_tracing_disabled(True) # avoid api.openai.com tracing 401 behind a gateway + +agent = SandboxAgent( + name="Local Sandbox Assistant", + instructions="...use the shell tools to actually run commands...", + capabilities=[Shell()], +) +run_config = RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) +) +result = await Runner.run(agent, input=input_list, run_config=run_config) +print(result.final_output) +``` + +`Runner.run` drives the full tool-call loop internally: the model issues shell +commands, the local sandbox runs them on the host, the output is fed back, and the +loop continues until the model produces a final answer. Because the loop is +self-contained, the async handler runs the agent and persists a single final +`TextContent` rather than streaming tokens. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | Async ACP server + handlers (`adk.state` multi-turn, runs the sandbox agent) | +| `project/agent.py` | `SandboxAgent` + `RunConfig(sandbox=...)` wiring + `run_agent` | +| `project/tools.py` | Sandbox capability factory (`Shell`) | +| `tests/test_agent.py` | Integration tests (polling pattern) | +| `manifest.yaml` | Agent configuration | + +## Running Locally + +```bash +# From this directory +agentex agents run +``` + +Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM +gateway) in your environment or in a `.env` file in `project/` so the agent can call +the model. + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` + +## Notes + +- **No infra required.** Because this uses the `unix_local` backend, the shell tools + run directly in the agent's process — no Docker daemon, no Temporal, no remote + sandbox. Swap the client for a remote/containerized backend to isolate execution. +- **Tracing.** `set_tracing_disabled(True)` turns off the OpenAI Agents SDK's native + tracer (which would otherwise try to ship traces to `api.openai.com`). The manifest + also sets `OPENAI_AGENTS_DISABLE_TRACING=1`. AgentEx/SGP tracing still runs via the + tracing manager configured in `acp.py` when SGP credentials are present. +- **Capabilities are the tools.** To let the agent do more, add capabilities in + `project/tools.py` (e.g. `Filesystem()`, `Memory()`). + +## Further Reading + +- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents +- The Temporal variant of this tutorial: `10_async/10_temporal/120_openai_agents_local_sandbox` diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml new file mode 100644 index 000000000..e0c3c0596 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/manifest.yaml @@ -0,0 +1,61 @@ +build: + context: + root: ../../../ + include_paths: + - 10_async/00_base/120_openai_agents_local_sandbox + - test_utils + dockerfile: 10_async/00_base/120_openai_agents_local_sandbox/Dockerfile + dockerignore: 10_async/00_base/120_openai_agents_local_sandbox/.dockerignore + +local_development: + agent: + port: 8000 + host_address: host.docker.internal + paths: + acp: project/acp.py + +agent: + acp_type: async + name: ab120-openai-agents-local-sandbox + description: An async OpenAI Agents SDK agent using a local (unix_local) sandbox + + temporal: + enabled: false + + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + + env: + OPENAI_AGENTS_DISABLE_TRACING: "1" + +deployment: + image: + repository: "" + tag: "latest" + + global: + agent: + name: "ab120-openai-agents-local-sandbox" + description: "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" + replicaCount: 1 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py new file mode 100644 index 000000000..6ff475873 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/acp.py @@ -0,0 +1,149 @@ +"""ACP handler for the async OpenAI Agents SDK local-sandbox agent. + +Uses the async ACP model (``acp_type: async``, ``temporal.enabled: false``), +mirroring the Pydantic AI tutorial (110). The difference is the runtime: here we +run an OpenAI Agents SDK ``SandboxAgent`` against the **local** sandbox backend +(``UnixLocalSandboxClient``), which executes real shell commands on the host. + +The OpenAI Agents SDK sandbox runtime drives the full tool-call loop internally +inside ``Runner.run`` (model -> shell command -> output -> model -> ... -> final +answer), so this handler runs the agent and persists a single final +``TextContent`` rather than streaming tokens itself. + +Multi-turn memory is persisted via ``adk.state``: on each turn we load the prior +OpenAI Agents SDK input list from state, run the agent with it, then save the +updated list (``result.to_input_list()``) back. Without this, every turn would be +a fresh stateless run and the agent would forget the prior conversation. +""" + +from __future__ import annotations + +import os +from typing import Any + +from dotenv import load_dotenv + +load_dotenv() + +import agentex.lib.adk as adk +from project.agent import run_agent +from agentex.lib.types.acp import SendEventParams, CancelTaskParams, CreateTaskParams +from agentex.lib.types.fastacp import AsyncACPConfig +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config + +logger = make_logger(__name__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same example works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""), + ) +) + +acp = FastACP.create( + acp_type="async", + config=AsyncACPConfig(type="base"), +) + + +class ConversationState(BaseModel): + """Per-task conversation state persisted via ``adk.state``. + + ``input_list`` holds the OpenAI Agents SDK conversation history — the same + structure ``Runner.run`` accepts as input and ``result.to_input_list()`` + returns. Persisting it between turns gives the agent multi-turn memory. + """ + + input_list: list[dict[str, Any]] = [] + turn_number: int = 0 + + +@acp.on_task_create +async def handle_task_create(params: CreateTaskParams): + """Initialize per-task state on task creation. + + A fresh task starts with no message history; the conversation is built up by + ``handle_task_event_send`` on each subsequent user message. + """ + logger.info(f"Task created: {params.task.id}") + await adk.state.create( + task_id=params.task.id, + agent_id=params.agent.id, + state=ConversationState(), + ) + + +@acp.on_task_event_send +async def handle_task_event_send(params: SendEventParams): + """Handle each user message: load prior history, run the agent, save updated history.""" + task_id = params.task.id + agent_id = params.agent.id + user_message = params.event.content.content + + logger.info(f"Processing message for thread {task_id}") + + # Echo the user's message into the task history so it shows up in the UI. + await adk.messages.create(task_id=task_id, content=params.event.content) + + # Load the previous conversation history from state. If state is missing + # (e.g. task wasn't initialised via on_task_create), fall back to a fresh + # one so the agent still responds — just without memory of prior turns. + task_state = await adk.state.get_by_task_and_agent(task_id=task_id, agent_id=agent_id) + if task_state is None: + state = ConversationState() + task_state = await adk.state.create(task_id=task_id, agent_id=agent_id, state=state) + else: + state = ConversationState.model_validate(task_state.state) + + state.turn_number += 1 + state.input_list.append({"role": "user", "content": user_message}) + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name=f"Turn {state.turn_number}", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + # The OpenAI Agents SDK sandbox runtime runs the full tool-call loop + # internally (model -> shell command on the local host -> output -> + # model -> ... -> final answer), so we get a single final result. + result = await run_agent(state.input_list) + final_output = result.final_output + + # Persist the assistant's final answer as a TaskMessage so it shows up + # in the UI. (Unlike the streaming Pydantic AI tutorial, the sandbox run + # is non-streaming, so we post the final text ourselves.) + await adk.messages.create( + task_id=task_id, + content=TextContent(author="agent", content=final_output), + ) + + # Save the updated message history so the next turn picks up here. + state.input_list = result.to_input_list() + await adk.state.update( + state_id=task_state.id, + task_id=task_id, + agent_id=agent_id, + state=state, + ) + + if turn_span: + turn_span.output = {"final_output": final_output} + + +@acp.on_task_cancel +async def handle_task_canceled(params: CancelTaskParams): + logger.info(f"Task canceled: {params.task.id}") diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py new file mode 100644 index 000000000..177bb287d --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/agent.py @@ -0,0 +1,95 @@ +"""OpenAI Agents SDK local-sandbox agent definition (async, non-Temporal). + +This mirrors the Pydantic AI tutorial (110): the agent is the boundary between +this module and the API layer (acp.py). The difference is the runtime — here we +use the OpenAI Agents SDK ``SandboxAgent`` together with the **local** sandbox +backend (``UnixLocalSandboxClient``). + +The local sandbox runs shell commands ON THE HOST — the agent's own +container/process. There is no Docker, no Temporal, and no remote sandbox +infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: +when the model decides to run a shell command, the sandbox executes it locally +and feeds the output back to the model until it produces a final answer. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Runner, set_tracing_disabled +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run_config import RunConfig +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +from project.tools import get_capabilities + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would +# 401). Agentex tracing still runs via the tracing manager configured in acp.py. +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o-mini" +INSTRUCTIONS = """You are a local sandbox assistant. + +Current date and time: {timestamp} + +You have access to shell tools that run real commands on the local machine. + +Guidelines: +- ALWAYS use the shell tools to actually run commands — never guess or make up + output. If the user asks for the Python version, run `python3 --version`. If + they ask to list files, run `ls`. If they ask you to compute something, use + `python3 -c "..."`. +- Run the minimal command(s) needed to answer the question. +- Report the real command output back to the user, concisely. +""" + + +def create_agent() -> SandboxAgent: + """Build and return the OpenAI Agents SDK sandbox agent. + + The agent is granted shell capabilities (see ``project.tools``). The actual + sandbox backend (where the shell commands run) is supplied at run time via + the ``RunConfig`` returned by ``create_run_config``. + """ + return SandboxAgent( + name="Local Sandbox Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ), + capabilities=get_capabilities(), + ) + + +def create_run_config() -> RunConfig: + """Build the RunConfig that points the agent at the LOCAL sandbox backend. + + ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on + the host — the agent's own process — so no Docker or remote infra is needed. + """ + return RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) + ) + + +async def run_agent(input_list: list) -> "Runner": + """Run the sandbox agent over the conversation so far and return the result. + + The OpenAI Agents SDK handles the full tool-call loop internally: the model + issues shell commands, the local sandbox runs them on the host, and the + output is fed back until the model produces a final answer. + + We pass the full ``input_list`` (prior turns + the new user message) so the + agent has conversation memory across turns; the caller persists + ``result.to_input_list()`` back into ``adk.state`` for the next turn. + """ + agent = create_agent() + run_config = create_run_config() + return await Runner.run(agent, input=input_list, run_config=run_config, max_turns=10) diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py new file mode 100644 index 000000000..a931fa273 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/project/tools.py @@ -0,0 +1,29 @@ +"""Sandbox capabilities for the async OpenAI Agents SDK local-sandbox agent. + +Unlike the Pydantic AI tutorial (110), this agent does not register hand-written +Python functions as tools. Instead it is given *capabilities* — the OpenAI Agents +SDK sandbox runtime turns each capability into a real set of tools (run a shell +command, read a file, etc.) backed by an actual sandbox backend. + +Here we use the ``Shell`` capability, which lets the model run real shell commands. +With the local (``unix_local``) backend those commands execute ON THE HOST — the +agent's own process/container — so there is no Docker, Temporal, or remote infra +involved. This module hosts the capability factory so the agent wiring in +``project.agent`` stays readable and the capability set is easy to extend +(e.g. add ``Filesystem()`` or ``Memory()``). +""" + +from __future__ import annotations + +from agents.sandbox.capabilities import Shell + + +def get_capabilities() -> list: + """Return the sandbox capabilities the agent is allowed to use. + + Returns: + A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so + the agent can run real shell commands on the local machine. Add + ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. + """ + return [Shell()] diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml new file mode 100644 index 000000000..75c6254f3 --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "ab120-openai-agents-local-sandbox" +version = "0.1.0" +description = "An async OpenAI Agents SDK agent using a local (unix_local) sandbox" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents>=0.14.3,<0.15", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py new file mode 100644 index 000000000..0c7904eac --- /dev/null +++ b/examples/tutorials/10_async/00_base/120_openai_agents_local_sandbox/tests/test_agent.py @@ -0,0 +1,122 @@ +"""Tests for the async OpenAI Agents SDK local-sandbox agent. + +This test suite validates that the agent actually runs shell commands in the +LOCAL sandbox (unix_local backend) by polling for the agent's response: +- Ask for the Python version -> response contains "Python 3" +- Ask it to compute 21 * 2 with python3 -> response contains "42" + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: ab120-openai-agents-local-sandbox) +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import send_event_and_poll_yielding + +from agentex import AsyncAgentex +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "ab120-openai-agents-local-sandbox") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +async def _send_and_collect_agent_text( + client: AsyncAgentex, agent_id: str, task_id: str, user_message: str +) -> str: + """Send a user message and accumulate all agent text responses into a string.""" + parts: list[str] = [] + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task_id, + user_message=user_message, + timeout=60, + sleep_interval=1.0, + yield_updates=True, + ): + content = message.content + if content and content.type == "text" and content.author == "agent": + if content.content and content.content not in parts: + parts.append(content.content) + return "\n".join(parts) + + +class TestLocalSandboxEvents: + """Test the async local-sandbox OpenAI Agents SDK agent.""" + + @pytest.mark.asyncio + async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): + """The agent should run `python3 --version` in the local sandbox. + + The sandbox runs on Python 3.12, so the real output contains "Python 3". + """ + task_response = await client.agents.create_task( + agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) + ) + task = task_response.result + assert task is not None + + text = await _send_and_collect_agent_text( + client, + agent_id, + task.id, + "Use your shell to print the Python version on this machine, then " + "tell me what it is.", + ) + assert text, "Expected a non-empty response from the sandbox agent." + assert "Python 3" in text + + @pytest.mark.asyncio + async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): + """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" + task_response = await client.agents.create_task( + agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) + ) + task = task_response.result + assert task is not None + + text = await _send_and_collect_agent_text( + client, + agent_id, + task.id, + "Use python3 in your shell to compute 21 * 2 and tell me the result.", + ) + assert text, "Expected a non-empty response from the sandbox agent." + assert "42" in text + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore new file mode 100644 index 000000000..c49489471 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile new file mode 100644 index 000000000..d4927d0ce --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +# Install tctl (Temporal CLI) +RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \ + tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \ + chmod +x /usr/local/bin/tctl && \ + rm /tmp/tctl.tar.gz + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy pyproject.toml and README.md to install dependencies +COPY 10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml /app/120_openai_agents_local_sandbox/pyproject.toml +COPY 10_async/10_temporal/120_openai_agents_local_sandbox/README.md /app/120_openai_agents_local_sandbox/README.md + +WORKDIR /app/120_openai_agents_local_sandbox + +# Copy the project code +COPY 10_async/10_temporal/120_openai_agents_local_sandbox/project /app/120_openai_agents_local_sandbox/project + +# Copy the test files +COPY 10_async/10_temporal/120_openai_agents_local_sandbox/tests /app/120_openai_agents_local_sandbox/tests + +# Copy shared test utilities +COPY test_utils /app/test_utils + +# Install the required Python packages with dev dependencies +RUN uv pip install --system .[dev] + +WORKDIR /app/120_openai_agents_local_sandbox + +ENV PYTHONPATH=/app + +# Set test environment variables +ENV AGENT_NAME=at120-openai-agents-local-sandbox + +# Run the ACP server using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] + +# When we deploy the worker, we will replace the CMD with the following +# CMD ["python", "-m", "run_worker"] diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md new file mode 100644 index 000000000..161bc43da --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/README.md @@ -0,0 +1,130 @@ +# Tutorial 120: Temporal OpenAI Agents SDK with a Local Sandbox + +This tutorial demonstrates running an [OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) +`SandboxAgent` inside a **Temporal** workflow, backed by the **local** +(`unix_local`) sandbox. + +The agent is a "local sandbox assistant": it answers questions by actually running +real shell commands (e.g. `python3 --version`, `ls`, `python3 -c "..."`) instead of +guessing. Because it runs inside Temporal, the sandbox tool calls become durable, +retried, and observable activities. + +This mirrors the canonical OpenAI Agents SDK Temporal example +(`060_open_ai_agents_sdk_hello_world`) and the tools example +(`070_open_ai_agents_sdk_tools`). The new piece is the **Temporal sandbox bridge**. + +## Key Concepts + +### Temporal ACP +The Temporal ACP model (`acp_type: async`, `temporal.enabled: true`) maps task +lifecycle to a Temporal workflow: +- `@workflow.run` (`on_task_create`) keeps the conversation alive. +- `@workflow.signal(name=SignalName.RECEIVE_EVENT)` (`on_task_event_send`) handles + each user message. + +No ACP handlers are registered by hand — the `TemporalACPConfig` wires them to the +workflow automatically. + +### Streaming (Interceptor + Model Provider + Hooks) +Real-time streaming uses STANDARD Temporal components — no forked plugin: +- **`ContextInterceptor`** threads `task_id` through activity headers. The workflow + sets `self._task_id` so the interceptor can read it. +- **`TemporalStreamingModelProvider`** returns a model that streams tokens to Redis + in real time while still returning the complete response to Temporal for + determinism / replay safety. +- **`TemporalStreamingHooks`** creates the lifecycle messages (tool request / + response, etc.) in the database. + +The `stream_lifecycle_content` activity must be registered on the worker alongside +`get_all_activities()`. + +### The Temporal sandbox bridge (`UnixLocalSandboxClient`) +The sandbox client is registered ON THE WORKER (and the ACP) via the standard +plugin: + +```python +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, SandboxClientProvider + +OpenAIAgentsPlugin( + model_provider=TemporalStreamingModelProvider(), + sandbox_clients=[SandboxClientProvider("local", UnixLocalSandboxClient())], +) +``` + +Inside the workflow, the run is pointed at that backend by name: + +```python +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run_config import RunConfig +from agents.sandbox.snapshot import NoopSnapshotSpec +from agents.sandbox.capabilities import Shell +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions + +agent = SandboxAgent( + name="Local Sandbox Assistant", + model="gpt-4o-mini", + instructions="...use the shell tools to actually run commands...", + capabilities=[Shell()], +) +run_config = RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("local"), + options=UnixLocalSandboxClientOptions(), + snapshot=NoopSnapshotSpec(), # skip the per-turn workspace snapshot + ) +) +result = await Runner.run( + agent, self._state.input_list, run_config=run_config, + hooks=TemporalStreamingHooks(task_id=params.task.id), +) +``` + +`temporal_sandbox_client("local")` resolves the worker-registered client, so the +sandbox shell tool calls run as Temporal activities (durable + observable in the +Temporal UI). + +## Two important lessons + +1. **Don't double-post the assistant message.** The `TemporalStreamingModelProvider` + already streams AND persists the assistant's response. If you also call + `adk.messages.create(...)` after `Runner.run`, the answer shows up twice. We only + persist conversation state for the next turn via `result.to_input_list()`. +2. **Use `NoopSnapshotSpec()`.** Without it, the sandbox tries to take a per-turn + workspace snapshot, and stopping the sandbox can raise + `WorkspaceArchiveReadError`. `NoopSnapshotSpec()` skips that snapshot. + +## Files + +| File | Description | +|------|-------------| +| `project/acp.py` | Temporal ACP server (plugin + sandbox client + interceptor) | +| `project/run_worker.py` | Temporal worker (registers workflow, activities, plugin, sandbox client) | +| `project/workflow.py` | `BaseWorkflow` that runs the `SandboxAgent` against the local sandbox | +| `tests/test_agent.py` | Integration tests (polling pattern) | +| `manifest.yaml` | Agent configuration (temporal enabled) | +| `environments.yaml` | Per-environment deployment overrides | + +## Running Locally + +```bash +# From this directory +agentex agents run +``` + +Set `OPENAI_API_KEY` (or `LITELLM_API_KEY` if you're behind the Scale LiteLLM +gateway) in your environment or in a `.env` file in `project/` so the agent can call +the model. + +## Running Tests + +```bash +pytest tests/test_agent.py -v +``` + +## Further Reading + +- OpenAI Agents SDK guide: https://developers.openai.com/api/docs/guides/agents +- The async (non-Temporal) variant: `10_async/00_base/120_openai_agents_local_sandbox` +- The canonical OpenAI Agents SDK Temporal example: `10_async/10_temporal/060_open_ai_agents_sdk_hello_world` diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml new file mode 100644 index 000000000..f90511911 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/environments.yaml @@ -0,0 +1,64 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-example-tutorial" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + # This is used to override the global helm values.yaml file in the agentex-agent helm charts + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + temporal-worker: + enabled: true + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml new file mode 100644 index 000000000..86ac89288 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/manifest.yaml @@ -0,0 +1,111 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +build: + context: + # Root directory for the build context + root: ../../../ # Up to tutorials level to include test_utils + + # Paths to include in the Docker build context + include_paths: + - 10_async/10_temporal/120_openai_agents_local_sandbox + - test_utils + + # Path to your agent's Dockerfile (relative to the root directory) + dockerfile: 10_async/10_temporal/120_openai_agents_local_sandbox/Dockerfile + + # Path to your agent's .dockerignore + dockerignore: 10_async/10_temporal/120_openai_agents_local_sandbox/.dockerignore + + +# Local Development Configuration +# ----------------------------- +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + acp: project/acp.py + # Path to temporal worker file + worker: project/run_worker.py + + +# Agent Configuration +# ----------------- +agent: + # Type of agent - either sync or async + acp_type: async + + # Unique name for your agent + name: at120-openai-agents-local-sandbox + + # Description of what your agent does + description: A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox + + # Temporal workflow configuration + temporal: + enabled: true + workflows: + # Name of the workflow class (must match the @workflow.defn name in workflow.py) + - name: at120-openai-agents-local-sandbox + + # Queue name for task distribution + queue_name: at120_openai_agents_local_sandbox_queue + + # Credentials mapping (maps Kubernetes secrets to environment variables) + credentials: + - env_var_name: OPENAI_API_KEY + secret_name: openai-api-key + secret_key: api-key + - env_var_name: REDIS_URL + secret_name: redis-url-secret + secret_key: url + - env_var_name: SGP_API_KEY + secret_name: sgp-api-key + secret_key: api-key + - env_var_name: SGP_ACCOUNT_ID + secret_name: sgp-account-id + secret_key: account-id + - env_var_name: SGP_CLIENT_BASE_URL + secret_name: sgp-client-base-url + secret_key: url + + # Environment variables for running locally and for deployment + env: + OPENAI_AGENTS_DISABLE_TRACING: "1" + + +# Deployment Configuration +# ----------------------- +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: + - name: my-registry-secret # Update with your image pull secret name + + # Global deployment settings that apply to all clusters + global: + agent: + name: "at120-openai-agents-local-sandbox" + description: "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" + + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py new file mode 100644 index 000000000..196e1e7cd --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/acp.py @@ -0,0 +1,83 @@ +import os +import sys + +from temporalio.contrib.openai_agents import ( + OpenAIAgentsPlugin, + SandboxClientProvider, +) +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient + +# === DEBUG SETUP (AgentEx CLI Debug Support) === +if os.getenv("AGENTEX_DEBUG_ENABLED") == "true": + try: + import debugpy + debug_port = int(os.getenv("AGENTEX_DEBUG_PORT", "5679")) + debug_type = os.getenv("AGENTEX_DEBUG_TYPE", "acp") + wait_for_attach = os.getenv("AGENTEX_DEBUG_WAIT_FOR_ATTACH", "false").lower() == "true" + + # Configure debugpy + debugpy.configure(subProcess=False) + debugpy.listen(debug_port) + + print(f"🐛 [{debug_type.upper()}] Debug server listening on port {debug_port}") + + if wait_for_attach: + print(f"⏳ [{debug_type.upper()}] Waiting for debugger to attach...") + debugpy.wait_for_client() + print(f"✅ [{debug_type.upper()}] Debugger attached!") + else: + print(f"📡 [{debug_type.upper()}] Ready for debugger attachment") + + except ImportError: + print("❌ debugpy not available. Install with: pip install debugpy") + sys.exit(1) + except Exception as e: + print(f"❌ Debug setup failed: {e}") + sys.exit(1) +# === END DEBUG SETUP === + +from agentex.lib.types.fastacp import TemporalACPConfig +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + ContextInterceptor, +) + +context_interceptor = ContextInterceptor() +temporal_streaming_model_provider = TemporalStreamingModelProvider() + +# Create the ACP server. We register the STANDARD OpenAIAgentsPlugin with: +# - the streaming model provider (real-time token streaming + persistence) +# - the LOCAL sandbox backend, registered under the name "local" so the +# workflow can resolve it via ``temporal_sandbox_client("local")`` +# plus the ContextInterceptor that threads task_id through activity headers. +acp = FastACP.create( + acp_type="async", + config=TemporalACPConfig( + # When deployed to the cluster, the Temporal address is set automatically. + # For local development, we set the address manually to talk to the local + # Temporal service set up via docker compose. + type="temporal", + temporal_address=os.getenv("TEMPORAL_ADDRESS", "localhost:7233"), + plugins=[ + OpenAIAgentsPlugin( + model_provider=temporal_streaming_model_provider, + sandbox_clients=[ + SandboxClientProvider("local", UnixLocalSandboxClient()), + ], + ) + ], + interceptors=[context_interceptor], + ), +) + + +# Notice that we don't need to register any handlers when we use type="temporal". +# These handlers are automatically registered when the ACP is created: +# +# @acp.on_task_create -> the workflow method decorated with @workflow.run +# @acp.on_task_event_send -> the workflow method decorated with +# @workflow.signal(name=SignalName.RECEIVE_EVENT) +# @acp.on_task_cancel -> handled by the temporal client (cancels the workflow) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py new file mode 100644 index 000000000..a2b7bdf6b --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/run_worker.py @@ -0,0 +1,80 @@ +import asyncio + +from temporalio.contrib.openai_agents import ( + OpenAIAgentsPlugin, + SandboxClientProvider, +) +from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient + +from project.workflow import At120OpenaiAgentsLocalSandboxWorkflow +from agentex.lib.utils.debug import setup_debug_if_enabled +from agentex.lib.utils.logging import make_logger +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.activities import get_all_activities +from agentex.lib.core.temporal.workers.worker import AgentexWorker +from agentex.lib.core.temporal.plugins.openai_agents.hooks.activities import ( + stream_lifecycle_content, +) +from agentex.lib.core.temporal.plugins.openai_agents.models.temporal_streaming_model import ( + TemporalStreamingModelProvider, +) +from agentex.lib.core.temporal.plugins.openai_agents.interceptors.context_interceptor import ( + ContextInterceptor, +) + +environment_variables = EnvironmentVariables.refresh() + +logger = make_logger(__name__) + + +async def main(): + # Setup debug mode if enabled + setup_debug_if_enabled() + + task_queue_name = environment_variables.WORKFLOW_TASK_QUEUE + if task_queue_name is None: + raise ValueError("WORKFLOW_TASK_QUEUE is not set") + + # Register activities. ``stream_lifecycle_content`` powers the streaming + # lifecycle hooks; the rest are the standard AgentEx activities. + all_activities = get_all_activities() + [stream_lifecycle_content] + + # ============================================================================ + # STREAMING + SANDBOX SETUP + # ============================================================================ + # 1. ContextInterceptor threads task_id through activity headers so the + # streaming model + hooks know which task to stream/persist to. + # 2. TemporalStreamingModelProvider returns a model that streams tokens to + # Redis in real time while still returning the complete response to + # Temporal for determinism / replay safety. + # 3. SandboxClientProvider registers the LOCAL sandbox backend + # (UnixLocalSandboxClient) under the name "local". The workflow resolves + # it at run time via ``temporal_sandbox_client("local")``, so the sandbox + # tool calls run as durable Temporal activities. + # + # We use the STANDARD temporalio.contrib.openai_agents.OpenAIAgentsPlugin — + # no forked plugin needed. + context_interceptor = ContextInterceptor() + temporal_streaming_model_provider = TemporalStreamingModelProvider() + + worker = AgentexWorker( + task_queue=task_queue_name, + plugins=[ + OpenAIAgentsPlugin( + model_provider=temporal_streaming_model_provider, + sandbox_clients=[ + SandboxClientProvider("local", UnixLocalSandboxClient()), + ], + ) + ], + interceptors=[context_interceptor], + ) + + await worker.run( + activities=all_activities, + workflow=At120OpenaiAgentsLocalSandboxWorkflow, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py new file mode 100644 index 000000000..45b61b04e --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/project/workflow.py @@ -0,0 +1,213 @@ +"""OpenAI Agents SDK + Temporal: Local Sandbox Tutorial + +This tutorial demonstrates running an OpenAI Agents SDK ``SandboxAgent`` inside a +Temporal workflow, backed by the **local** (``unix_local``) sandbox. The agent is +a "local sandbox assistant": it answers questions by actually running real shell +commands (e.g. ``python3 --version``, ``ls``, ``python3 -c "..."``) instead of +guessing. + +KEY CONCEPTS DEMONSTRATED: +- A ``SandboxAgent`` granted the ``Shell`` capability inside a durable Temporal + workflow. +- The Temporal sandbox bridge: ``temporal_sandbox_client("local")`` resolves to + the ``UnixLocalSandboxClient`` registered on the worker via + ``SandboxClientProvider`` (see ``run_worker.py`` / ``acp.py``). The sandbox tool + calls run as Temporal activities, so they are durable, retried, and observable. +- Real-time streaming + persistence via ``TemporalStreamingModelProvider`` + + ``ContextInterceptor`` (configured on the worker) and ``TemporalStreamingHooks``. + +IMPORTANT LESSONS (applied below): + (a) Do NOT post the assistant message yourself with ``adk.messages.create`` + after ``Runner.run``. The ``TemporalStreamingModelProvider`` already streams + and persists the assistant's response — posting it again would duplicate the + answer in the UI. We only persist conversation state for the next turn via + ``result.to_input_list()``. + (b) Use ``NoopSnapshotSpec()`` so the per-turn workspace snapshot is skipped. + Without it, stopping the sandbox can raise ``WorkspaceArchiveReadError``. +""" + +from __future__ import annotations + +import os +import json + +from agents import Runner +from temporalio import workflow + +from agentex.lib import adk +from agentex.lib.types.acp import SendEventParams, CreateTaskParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.utils.model_utils import BaseModel +from agentex.lib.environment_variables import EnvironmentVariables +from agentex.lib.core.temporal.types.workflow import SignalName +from agentex.lib.core.temporal.workflows.workflow import BaseWorkflow +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) +from agentex.lib.core.temporal.plugins.openai_agents.hooks.hooks import ( + TemporalStreamingHooks, +) + +# OpenAI Agents SDK sandbox imports. These are safe to import at workflow module +# load time; the actual sandbox client is resolved at run time via +# ``temporal_sandbox_client`` (which maps to the worker-registered backend). +with workflow.unsafe.imports_passed_through(): + from agents.sandbox import SandboxAgent, SandboxRunConfig + from agents.run_config import RunConfig + from agents.sandbox.snapshot import NoopSnapshotSpec + from agents.sandbox.capabilities import Shell + from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClientOptions + from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client + +# Configure tracing processor (optional - only if you have SGP credentials) +add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=os.environ.get("SGP_API_KEY", ""), + sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""), + ) +) + +environment_variables = EnvironmentVariables.refresh() + +if environment_variables.WORKFLOW_NAME is None: + raise ValueError("Environment variable WORKFLOW_NAME is not set") + +if environment_variables.AGENT_NAME is None: + raise ValueError("Environment variable AGENT_NAME is not set") + +logger = make_logger(__name__) + +MODEL_NAME = "gpt-4o-mini" +INSTRUCTIONS = """You are a local sandbox assistant. + +You have access to shell tools that run real commands on the local machine. + +Guidelines: +- ALWAYS use the shell tools to actually run commands — never guess or make up + output. If the user asks for the Python version, run `python3 --version`. If + they ask to list files, run `ls`. If they ask you to compute something, use + `python3 -c "..."`. +- Run the minimal command(s) needed to answer the question. +- Report the real command output back to the user, concisely. +""" + + +class StateModel(BaseModel): + """State model for preserving conversation history across turns.""" + + input_list: list = [] + turn_number: int = 0 + + +@workflow.defn(name=environment_variables.WORKFLOW_NAME) +class At120OpenaiAgentsLocalSandboxWorkflow(BaseWorkflow): + """Long-running Temporal workflow that runs a SandboxAgent against the local sandbox.""" + + def __init__(self): + super().__init__(display_name=environment_variables.AGENT_NAME) + self._complete_task = False + self._state: StateModel | None = None + self._task_id = None + self._trace_id = None + self._parent_span_id = None + + @workflow.signal(name=SignalName.RECEIVE_EVENT) + async def on_task_event_send(self, params: SendEventParams) -> None: + logger.info(f"Received task event: {params.task.id}") + + if self._state is None: + raise ValueError("State is not initialized") + + self._state.turn_number += 1 + + # The ContextInterceptor reads ``self._task_id`` off the workflow + # instance and threads it through activity headers so the streaming + # model + hooks know which task to stream/persist to. + self._task_id = params.task.id + self._trace_id = params.task.id + + # Add the user message to conversation history. + self._state.input_list.append({"role": "user", "content": params.event.content.content}) + + # Echo back the client's message so it shows up in the UI. + await adk.messages.create(task_id=params.task.id, content=params.event.content) + + async with adk.tracing.span( + trace_id=params.task.id, + name=f"Turn {self._state.turn_number}", + input=self._state.model_dump(), + ) as span: + self._parent_span_id = span.id if span else None + + # Build the sandbox agent. The Shell capability becomes real shell + # tools backed by the sandbox client resolved at run time. + agent = SandboxAgent( + name="Local Sandbox Assistant", + model=MODEL_NAME, + instructions=INSTRUCTIONS, + capabilities=[Shell()], + ) + + # Point the run at the LOCAL sandbox backend registered on the worker + # under the name "local". ``temporal_sandbox_client`` resolves that + # registration so the sandbox tool calls execute as Temporal + # activities (durable + observable). + # + # IMPORTANT: ``NoopSnapshotSpec()`` skips the per-turn workspace + # snapshot — otherwise stopping the sandbox can raise + # ``WorkspaceArchiveReadError``. + run_config = RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("local"), + options=UnixLocalSandboxClientOptions(), + snapshot=NoopSnapshotSpec(), + ) + ) + + # TemporalStreamingHooks creates the lifecycle messages (tool + # request/response, etc.) and works with the streaming model + # provider to stream tokens to the UI in real time. + result = await Runner.run( + agent, + self._state.input_list, + run_config=run_config, + hooks=TemporalStreamingHooks(task_id=params.task.id), + max_turns=10, + ) + + # IMPORTANT: We do NOT post the assistant message ourselves here. + # The TemporalStreamingModelProvider already streamed and persisted + # the assistant's response. We only persist conversation state for + # the next turn. + self._state.input_list = result.to_input_list() + + if span: + span.output = self._state.model_dump() + + @workflow.run + async def on_task_create(self, params: CreateTaskParams) -> str: + logger.info(f"Task created: {params.task.id}") + + self._state = StateModel(input_list=[], turn_number=0) + + await adk.messages.create( + task_id=params.task.id, + content=TextContent( + author="agent", + content=( + f"Task initialized with params:\n{json.dumps(params.params, indent=2)}\n" + f"Send me a message and I'll run real shell commands in a local " + f"sandbox (backed by Temporal) to answer." + ), + ), + ) + + await workflow.wait_condition(lambda: self._complete_task, timeout=None) + return "Task completed" + + @workflow.signal + async def complete_task_signal(self) -> None: + logger.info("Received complete_task signal") + self._complete_task = True diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml new file mode 100644 index 000000000..696894e32 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "at120_openai_agents_local_sandbox" +version = "0.1.0" +description = "A Temporal OpenAI Agents SDK agent using a local (unix_local) sandbox" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk>=0.6.0", + "openai-agents>=0.14.3,<0.15", + "temporalio>=1.18.2", + "scale-gp", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "black", + "isort", + "flake8", + "debugpy>=1.8.15", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py new file mode 100644 index 000000000..5e161c061 --- /dev/null +++ b/examples/tutorials/10_async/10_temporal/120_openai_agents_local_sandbox/tests/test_agent.py @@ -0,0 +1,144 @@ +"""Tests for the Temporal OpenAI Agents SDK local-sandbox agent. + +This test suite validates that the agent actually runs shell commands in the +LOCAL sandbox (unix_local backend) via the Temporal sandbox bridge, by polling +for the agent's response: +- Ask for the Python version -> response contains "Python 3" +- Ask it to compute 21 * 2 with python3 -> response contains "42" + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: at120-openai-agents-local-sandbox) +""" + +import os +import uuid + +import pytest +import pytest_asyncio +from test_utils.async_utils import ( + poll_messages, + send_event_and_poll_yielding, +) + +from agentex import AsyncAgentex +from agentex.types.task_message import TaskMessage +from agentex.types.agent_rpc_params import ParamsCreateTaskRequest + +# Configuration from environment variables +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "at120-openai-agents-local-sandbox") + + +@pytest_asyncio.fixture +async def client(): + """Create an AsyncAgentex client instance for testing.""" + client = AsyncAgentex(base_url=AGENTEX_API_BASE_URL) + yield client + await client.close() + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest_asyncio.fixture +async def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = await client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +async def _create_task_and_await_welcome(client: AsyncAgentex, agent_id: str) -> str: + """Create a task and wait for the workflow's welcome message; return the task id.""" + task_response = await client.agents.create_task( + agent_id, params=ParamsCreateTaskRequest(name=uuid.uuid1().hex) + ) + task = task_response.result + assert task is not None + + welcome_found = False + async for message in poll_messages( + client=client, + task_id=task.id, + timeout=30, + sleep_interval=1.0, + ): + assert isinstance(message, TaskMessage) + if message.content and message.content.type == "text" and message.content.author == "agent": + welcome_found = True + break + assert welcome_found, "Task creation (welcome) message not found" + return task.id + + +async def _send_and_collect_agent_text( + client: AsyncAgentex, agent_id: str, task_id: str, user_message: str +) -> str: + """Send a user message and accumulate the streamed agent text into a string.""" + final_message = None + async for message in send_event_and_poll_yielding( + client=client, + agent_id=agent_id, + task_id=task_id, + user_message=user_message, + timeout=60, + sleep_interval=1.0, + yield_updates=True, # Get updates as streaming writes chunks + ): + if message.content and message.content.type == "text" and message.content.author == "agent": + final_message = message + if message.streaming_status == "DONE": + break + + assert final_message is not None, "Should have received an agent text message" + assert final_message.content is not None, "Final message should have content" + return final_message.content.content or "" + + +class TestLocalSandboxEvents: + """Test the Temporal local-sandbox OpenAI Agents SDK agent.""" + + @pytest.mark.asyncio + async def test_shell_python_version(self, client: AsyncAgentex, agent_id: str): + """The agent should run `python3 --version` in the local sandbox. + + The sandbox runs on Python 3.12, so the real output contains "Python 3". + """ + task_id = await _create_task_and_await_welcome(client, agent_id) + text = await _send_and_collect_agent_text( + client, + agent_id, + task_id, + "Use your shell to print the Python version on this machine, then " + "tell me what it is.", + ) + assert text, "Expected a non-empty response from the sandbox agent." + assert "Python 3" in text + + @pytest.mark.asyncio + async def test_shell_compute(self, client: AsyncAgentex, agent_id: str): + """The agent should use python3 in the sandbox to compute 21 * 2 == 42.""" + task_id = await _create_task_and_await_welcome(client, agent_id) + text = await _send_and_collect_agent_text( + client, + agent_id, + task_id, + "Use python3 in your shell to compute 21 * 2 and tell me the result.", + ) + assert text, "Expected a non-empty response from the sandbox agent." + assert "42" in text + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/src/agentex/lib/cli/commands/init.py b/src/agentex/lib/cli/commands/init.py index f6d715fd4..69b18e8e7 100644 --- a/src/agentex/lib/cli/commands/init.py +++ b/src/agentex/lib/cli/commands/init.py @@ -30,6 +30,7 @@ class TemplateType(str, Enum): DEFAULT_PYDANTIC_AI = "default-pydantic-ai" SYNC = "sync" SYNC_OPENAI_AGENTS = "sync-openai-agents" + SYNC_OPENAI_AGENTS_LOCAL_SANDBOX = "sync-openai-agents-local-sandbox" SYNC_LANGGRAPH = "sync-langgraph" SYNC_PYDANTIC_AI = "sync-pydantic-ai" @@ -68,6 +69,7 @@ def create_project_structure( TemplateType.DEFAULT_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], TemplateType.SYNC: ["acp.py"], TemplateType.SYNC_OPENAI_AGENTS: ["acp.py"], + TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX: ["acp.py", "agent.py", "tools.py"], TemplateType.SYNC_LANGGRAPH: ["acp.py", "graph.py", "tools.py"], TemplateType.SYNC_PYDANTIC_AI: ["acp.py", "agent.py", "tools.py"], }[template_type] @@ -203,6 +205,7 @@ def validate_agent_name(text: str) -> bool | str: choices=[ {"name": "Basic Sync ACP", "value": TemplateType.SYNC}, {"name": "Sync ACP + OpenAI Agents SDK (Recommended)", "value": TemplateType.SYNC_OPENAI_AGENTS}, + {"name": "Sync ACP + OpenAI Agents SDK + Local Sandbox", "value": TemplateType.SYNC_OPENAI_AGENTS_LOCAL_SANDBOX}, {"name": "Sync ACP + LangGraph", "value": TemplateType.SYNC_LANGGRAPH}, {"name": "Sync ACP + Pydantic AI", "value": TemplateType.SYNC_PYDANTIC_AI}, ], diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 new file mode 100644 index 000000000..c2d7fca4d --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.dockerignore.j2 @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environments +.env** +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Git +.git +.gitignore + +# Misc +.DS_Store diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 new file mode 100644 index 000000000..015f49ef7 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/.env.example.j2 @@ -0,0 +1,13 @@ +# {{ agent_name }} - Environment Variables +# Copy this file to .env and fill in the values + +# API key for your LLM provider +LITELLM_API_KEY= + +# LLM base URL (optional - override to use a different provider) +# OPENAI_BASE_URL= + +# SGP Configuration (optional - for tracing) +# SGP_API_KEY= +# SGP_ACCOUNT_ID= +# SGP_CLIENT_BASE_URL= diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 new file mode 100644 index 000000000..582434ac9 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile-uv.j2 @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + nodejs \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/** + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy +ENV UV_HTTP_TIMEOUT=1000 + +WORKDIR /app/{{ project_path_from_build_root }} + +# Copy dependency files for layer caching +COPY {{ project_path_from_build_root }}/pyproject.toml {{ project_path_from_build_root }}/uv.lock ./ + +# Install dependencies (without project itself, for layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-install-project --no-dev + +# Copy the project code +COPY {{ project_path_from_build_root }}/project ./project + +# Install the project +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +ENV PATH="/app/{{ project_path_from_build_root }}/.venv/bin:$PATH" +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 new file mode 100644 index 000000000..4d9f41d45 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/Dockerfile.j2 @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.3 +FROM python:3.12-slim +COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/ + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + htop \ + vim \ + curl \ + tar \ + python3-dev \ + postgresql-client \ + build-essential \ + libpq-dev \ + gcc \ + cmake \ + netcat-openbsd \ + node \ + npm \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN uv pip install --system --upgrade pip setuptools wheel + +ENV UV_HTTP_TIMEOUT=1000 + +# Copy just the requirements file to optimize caching +COPY {{ project_path_from_build_root }}/requirements.txt /app/{{ project_path_from_build_root }}/requirements.txt + +WORKDIR /app/{{ project_path_from_build_root }} + +# Install the required Python packages +RUN uv pip install --system -r requirements.txt + +# Copy the project code +COPY {{ project_path_from_build_root }}/project /app/{{ project_path_from_build_root }}/project + + +# Set environment variables +ENV PYTHONPATH=/app + +# Run the agent using uvicorn +CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 new file mode 100644 index 000000000..9416f2477 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/README.md.j2 @@ -0,0 +1,324 @@ +# {{ agent_name }} - AgentEx Sync ACP + OpenAI Agents SDK (Local Sandbox) + +This is a starter template for building a **synchronous** AgentEx agent powered by the +[OpenAI Agents SDK](https://developers.openai.com/api/docs/guides/agents) and its +**sandbox** runtime, running with the **local** (`unix_local`) backend. + +The agent is a "local sandbox assistant": it answers questions by actually running real +shell commands (e.g. `python3 --version`, `ls /tmp`, `python3 -c "..."`) instead of +guessing. The local sandbox runs those commands **ON THE HOST** — the agent's own +process/container — so there is **no Docker, no Temporal, and no remote sandbox infra** +involved. + +## What You'll Learn + +- **Tasks**: A task is a grouping mechanism for related messages. Think of it as a conversation thread or a session. +- **Messages**: Messages are communication objects within a task. They can contain text, data, or instructions. +- **Sync ACP**: Synchronous Agent Communication Protocol that returns the agent's final answer per message. +- **OpenAI Agents SDK Sandbox**: Give an agent **capabilities** (e.g. `Shell`) that the runtime turns into real tools backed by a sandbox. +- **Local Sandbox (`UnixLocalSandboxClient`)**: Run those tools directly on the host with no extra infrastructure. + +## Running the Agent + +1. Run the agent locally: +```bash +agentex agents run --manifest manifest.yaml +``` + +The agent will start on port 8000 and respond immediately to any messages it receives. + +## What's Inside + +This template: +- Sets up a basic sync ACP server +- Handles incoming messages with immediate responses +- Provides a foundation for building real-time agents +- Can include streaming support for long responses + +## Next Steps + +For more advanced agent development, check out the AgentEx tutorials: + +- **Tutorials 00-08**: Learn about building synchronous agents with ACP +- **Tutorials 09-10**: Learn how to use Temporal to power asynchronous agents + - Tutorial 09: Basic Temporal workflow setup + - Tutorial 10: Advanced Temporal patterns and best practices + +These tutorials will help you understand: +- How to handle long-running tasks +- Implementing state machines +- Managing complex workflows +- Best practices for async agent development + +## The Manifest File + +The `manifest.yaml` file is your agent's configuration file. It defines: +- How your agent should be built and packaged +- What files are included in your agent's Docker image +- Your agent's name and description +- Local development settings (like the port your agent runs on) + +This file is essential for both local development and deployment of your agent. + +## Project Structure + +``` +{{ project_name }}/ +├── project/ # Your agent's code +│ ├── __init__.py +│ ├── acp.py # ACP server and message handler (runs the sandbox agent) +│ ├── agent.py # SandboxAgent + RunConfig(sandbox=...) wiring + run_agent +│ └── tools.py # Sandbox capability factory (Shell) +├── Dockerfile # Container definition +├── manifest.yaml # Deployment config +├── dev.ipynb # Development notebook for testing +{% if use_uv %} +└── pyproject.toml # Dependencies (uv) +{% else %} +└── requirements.txt # Dependencies (pip) +{% endif %} +``` + +## Development + +### 1. Customize Message Handlers +- Modify the handlers in `acp.py` to implement your agent's logic +- Add your own tools and capabilities +- Implement custom response generation + +### 2. Test Your Agent with the Development Notebook +Use the included `dev.ipynb` Jupyter notebook to test your agent interactively: + +```bash +# Start Jupyter notebook (make sure you have jupyter installed) +jupyter notebook dev.ipynb + +# Or use VS Code to open the notebook directly +code dev.ipynb +``` + +The notebook includes: +- **Setup**: Connect to your local AgentEx backend +- **Non-streaming tests**: Send messages and get complete responses +- **Streaming tests**: Test real-time streaming responses +- **Task management**: Optional task creation and management + +The notebook automatically uses your agent name (`{{ agent_name }}`) and provides examples for both streaming and non-streaming message handling. + +### 3. Manage Dependencies + +{% if use_uv %} +You chose **uv** for package management. Here's how to work with dependencies: + +```bash +# Add new dependencies +agentex uv add requests openai anthropic + +# Install/sync dependencies +agentex uv sync + +# Run commands with uv +uv run agentex agents run --manifest manifest.yaml +``` + +**Benefits of uv:** +- Faster dependency resolution and installation +- Better dependency isolation +- Modern Python packaging standards + +{% else %} +You chose **pip** for package management. Here's how to work with dependencies: + +```bash +# Edit requirements.txt manually to add dependencies +echo "requests" >> requirements.txt +echo "openai" >> requirements.txt + +# Install dependencies +pip install -r requirements.txt +``` + +**Benefits of pip:** +- Familiar workflow for most Python developers +- Simple requirements.txt management +- Wide compatibility +{% endif %} + +### 4. Configure Credentials +Options: +1. Add any required credentials to your manifest.yaml via the `env` section +2. Export them in your shell: `export LITELLM_API_KEY=...` +3. For local development, create a `.env.local` file in the project directory + +## Local Development + +### 1. Start the Agentex Backend +```bash +# Navigate to the backend directory +cd agentex + +# Start all services using Docker Compose +make dev + +# Optional: In a separate terminal, use lazydocker for a better UI (everything should say "healthy") +lzd +``` + +### 3. Run Your Agent +```bash +# From this directory +export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml +``` + +### 4. Interact with Your Agent + +**Option 1: Web UI (Recommended)** +```bash +# Start the local web interface +cd agentex-web +make dev + +# Then open http://localhost:3000 in your browser to chat with your agent +``` + +**Option 2: CLI (Deprecated)** +```bash +# Submit a task via CLI +agentex tasks submit --agent {{ agent_name }} --task "Your task here" +``` + +## Development Tips + +### Environment Variables +- Set environment variables in project/.env for any required credentials +- Or configure them in the manifest.yaml under the `env` section +- The `.env` file is automatically loaded in development mode + +### Local Testing +- Use `export ENVIRONMENT=development` before running your agent +- This enables local service discovery and debugging features +- Your agent will automatically connect to locally running services + +### Sync ACP Considerations +- Responses must be immediate (no long-running operations) +- Use streaming for longer responses +- Keep processing lightweight and fast +- Consider caching for frequently accessed data + +### Debugging +- Check agent logs in the terminal where you ran the agent +- Use the web UI to inspect task history and responses +- Monitor backend services with `lzd` (LazyDocker) +- Test response times and optimize for speed + +### To build the agent Docker image locally (normally not necessary): + +1. Build the agent image: +```bash +agentex agents build --manifest manifest.yaml +``` +{% if use_uv %} +```bash +# Build with uv +agentex agents build --manifest manifest.yaml --push +``` +{% else %} +```bash +# Build with pip +agentex agents build --manifest manifest.yaml --push +``` +{% endif %} + + +## Advanced Features + +### Streaming Responses +Handle long responses with streaming: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # For streaming responses + async def stream_response(): + for chunk in generate_response_chunks(): + yield TaskMessageUpdate( + content=chunk, + is_complete=False + ) + yield TaskMessageUpdate( + content="", + is_complete=True + ) + + return stream_response() +``` + +### Custom Response Logic +Add sophisticated response generation: + +```python +# In project/acp.py +@acp.on_message_send +async def handle_message_send(params: SendMessageParams): + # Analyze input + user_message = params.content.content + + # Generate response + response = await generate_intelligent_response(user_message) + + return TextContent( + author=MessageAuthor.AGENT, + content=response + ) +``` + +### Integration with External Services +{% if use_uv %} +```bash +# Add service clients +agentex uv add httpx requests-oauthlib + +# Add AI/ML libraries +agentex uv add openai anthropic transformers + +# Add fast processing libraries +agentex uv add numpy pandas +``` +{% else %} +```bash +# Add to requirements.txt +echo "httpx" >> requirements.txt +echo "openai" >> requirements.txt +echo "numpy" >> requirements.txt +pip install -r requirements.txt +``` +{% endif %} + +## Troubleshooting + +### Common Issues + +1. **Agent not appearing in web UI** + - Check if agent is running on port 8000 + - Verify `ENVIRONMENT=development` is set + - Check agent logs for errors + +2. **Slow response times** + - Profile your message handling code + - Consider caching expensive operations + - Optimize database queries and API calls + +3. **Dependency issues** +{% if use_uv %} + - Run `agentex uv sync` to ensure all dependencies are installed +{% else %} + - Run `pip install -r requirements.txt` + - Check if all dependencies are correctly listed in requirements.txt +{% endif %} + +4. **Port conflicts** + - Check if another service is using port 8000 + - Use `lsof -i :8000` to find conflicting processes + +Happy building with Sync ACP! 🚀⚡ \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 new file mode 100644 index 000000000..d8c10a65a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/dev.ipynb.j2 @@ -0,0 +1,167 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "36834357", + "metadata": {}, + "outputs": [], + "source": [ + "from agentex import Agentex\n", + "\n", + "client = Agentex(base_url=\"http://localhost:5003\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1c309d6", + "metadata": {}, + "outputs": [], + "source": [ + "AGENT_NAME = \"{{ agent_name }}\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9f6e6ef0", + "metadata": {}, + "outputs": [], + "source": [ + "# # (Optional) Create a new task. If you don't create a new task, each message will be sent to a new task. The server will create the task for you.\n", + "\n", + "# import uuid\n", + "\n", + "# TASK_ID = str(uuid.uuid4())[:8]\n", + "\n", + "# rpc_response = client.agents.rpc_by_name(\n", + "# agent_name=AGENT_NAME,\n", + "# method=\"task/create\",\n", + "# params={\n", + "# \"name\": f\"{TASK_ID}-task\",\n", + "# \"params\": {}\n", + "# }\n", + "# )\n", + "\n", + "# task = rpc_response.result\n", + "# print(task)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b03b0d37", + "metadata": {}, + "outputs": [], + "source": [ + "# Test non streaming response\n", + "from agentex.types import TextContent\n", + "\n", + "# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n", + "# - TextContent: A message with just text content \n", + "# - DataContent: A message with JSON-serializable data content\n", + "# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n", + "# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n", + "\n", + "# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "rpc_response = client.agents.send_message(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": False\n", + " }\n", + ")\n", + "\n", + "if not rpc_response or not rpc_response.result:\n", + " raise ValueError(\"No result in response\")\n", + "\n", + "# Extract and print just the text content from the response\n", + "for task_message in rpc_response.result:\n", + " content = task_message.content\n", + " if isinstance(content, TextContent):\n", + " text = content.content\n", + " print(text)\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79688331", + "metadata": {}, + "outputs": [], + "source": [ + "# Test streaming response\n", + "from agentex.types.task_message_update import StreamTaskMessageDelta, StreamTaskMessageFull\n", + "from agentex.types.text_delta import TextDelta\n", + "\n", + "\n", + "# The result object of message/send will be a TaskMessageUpdate which is a union of the following types:\n", + "# - StreamTaskMessageStart: \n", + "# - An indicator that a streaming message was started, doesn't contain any useful content\n", + "# - StreamTaskMessageDelta: \n", + "# - A delta of a streaming message, contains the text delta to aggregate\n", + "# - StreamTaskMessageDone: \n", + "# - An indicator that a streaming message was done, doesn't contain any useful content\n", + "# - StreamTaskMessageFull: \n", + "# - A non-streaming message, there is nothing to aggregate, since this contains the full message, not deltas\n", + "\n", + "# Whenn processing StreamTaskMessageDelta, if you are expecting more than TextDeltas, such as DataDelta, ToolRequestDelta, or ToolResponseDelta, you can process them as well\n", + "# Whenn processing StreamTaskMessageFull, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n", + "\n", + "for agent_rpc_response_chunk in client.agents.send_message_stream(\n", + " agent_name=AGENT_NAME,\n", + " params={\n", + " \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n", + " \"stream\": True\n", + " }\n", + "):\n", + " # We know that the result of the message/send when stream is set to True will be a TaskMessageUpdate\n", + " task_message_update = agent_rpc_response_chunk.result\n", + " # Print oly the text deltas as they arrive or any full messages\n", + " if isinstance(task_message_update, StreamTaskMessageDelta):\n", + " delta = task_message_update.delta\n", + " if isinstance(delta, TextDelta):\n", + " print(delta.text_delta, end=\"\", flush=True)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in streaming message.\")\n", + " elif isinstance(task_message_update, StreamTaskMessageFull):\n", + " content = task_message_update.content\n", + " if isinstance(content, TextContent):\n", + " print(content.content)\n", + " else:\n", + " print(f\"Found non-text {type(task_message)} object in full message.\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5e7e042", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.9" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 new file mode 100644 index 000000000..73924abdd --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/environments.yaml.j2 @@ -0,0 +1,53 @@ +# Agent Environment Configuration +# ------------------------------ +# This file defines environment-specific settings for your agent. +# This DIFFERS from the manifest.yaml file in that it is used to program things that are ONLY per environment. + +# ********** EXAMPLE ********** +# schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +# environments: +# dev: +# auth: +# principal: +# user_id: "1234567890" +# user_name: "John Doe" +# user_email: "john.doe@example.com" +# user_role: "admin" +# user_permissions: "read, write, delete" +# helm_overrides: # This is used to override the global helm values.yaml file in the agentex-agent helm charts +# replicas: 3 +# resources: +# requests: +# cpu: "1000m" +# memory: "2Gi" +# limits: +# cpu: "2000m" +# memory: "4Gi" +# env: +# - name: LOG_LEVEL +# value: "DEBUG" +# - name: ENVIRONMENT +# value: "staging" +# kubernetes: +# # OPTIONAL - Otherwise it will be derived from separately. However, this can be used to override the derived +# # namespace and deploy it with in the same namespace that already exists for a separate agent. +# namespace: "team-{{agent_name}}" +# ********** END EXAMPLE ********** + +schema_version: "v1" # This is used to validate the file structure and is not used by the agentex CLI +environments: + dev: + auth: + principal: + user_id: # TODO: Fill in + account_id: # TODO: Fill in + helm_overrides: + replicaCount: 2 + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" + diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 new file mode 100644 index 000000000..bc2910f2a --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/manifest.yaml.j2 @@ -0,0 +1,118 @@ +# Agent Manifest Configuration +# --------------------------- +# This file defines how your agent should be built and deployed. + +# Build Configuration +# ------------------ +# The build config defines what gets packaged into your agent's Docker image. +# This same configuration is used whether building locally or remotely. +# +# When building: +# 1. All files from include_paths are collected into a build context +# 2. The context is filtered by dockerignore rules +# 3. The Dockerfile uses this context to build your agent's image +# 4. The image is pushed to a registry and used to run your agent +build: + context: + # Root directory for the build context + root: ../ # Keep this as the default root + + # Paths to include in the Docker build context + # Must include: + # - Your agent's directory (your custom agent code) + # These paths are collected and sent to the Docker daemon for building + include_paths: + - {{ project_path_from_build_root }} + + # Path to your agent's Dockerfile + # This defines how your agent's image is built from the context + # Relative to the root directory + dockerfile: {{ project_path_from_build_root }}/Dockerfile + + # Path to your agent's .dockerignore + # Filters unnecessary files from the build context + # Helps keep build context small and builds fast + dockerignore: {{ project_path_from_build_root }}/.dockerignore + + +# Local Development Configuration +# ----------------------------- +# Only used when running the agent locally +local_development: + agent: + port: 8000 # Port where your local ACP server is running + host_address: host.docker.internal # Host address for Docker networking (host.docker.internal for Docker, localhost for direct) + + # File paths for local development (relative to this manifest.yaml) + paths: + # Path to ACP server file + # Examples: + # project/acp.py (standard) + # src/server.py (custom structure) + # ../shared/acp.py (shared across projects) + # /absolute/path/acp.py (absolute path) + acp: project/acp.py + + +# Agent Configuration +# ----------------- +agent: + acp_type: sync + # Unique name for your agent + # Used for task routing and monitoring + name: {{ agent_name }} + + # Description of what your agent does + # Helps with documentation and discovery + description: {{ description }} + + # Temporal workflow configuration + # Set enabled: true to use Temporal workflows for long-running tasks + temporal: + enabled: false + + # Optional: Credentials mapping + # Maps Kubernetes secrets to environment variables + # Common credentials include: + credentials: [] # Update with your credentials + # - env_var_name: LITELLM_API_KEY + # secret_name: litellm-api-key + # secret_key: api-key + + # Optional: Set Environment variables for running your agent locally as well + # as for deployment later on + env: + # Disable the OpenAI Agents SDK's native tracer (it would otherwise try to + # ship traces to api.openai.com and 401 behind a LiteLLM/proxy key). + OPENAI_AGENTS_DISABLE_TRACING: "1" + # LITELLM_API_KEY: "" + # OPENAI_BASE_URL: "" + # OPENAI_ORG_ID: "" + + +# Deployment Configuration +# ----------------------- +# Configuration for deploying your agent to Kubernetes clusters +deployment: + # Container image configuration + image: + repository: "" # Update with your container registry + tag: "latest" # Default tag, should be versioned in production + + imagePullSecrets: [] # Update with your image pull secret names + # - name: my-registry-secret + + # Global deployment settings that apply to all clusters + # These can be overridden in cluster-specific environments (environments.yaml) + global: + # Default replica count + replicaCount: 1 + + # Default resource requirements + resources: + requests: + cpu: "500m" + memory: "1Gi" + limits: + cpu: "1000m" + memory: "2Gi" \ No newline at end of file diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 new file mode 100644 index 000000000..e394e14c2 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/acp.py.j2 @@ -0,0 +1,80 @@ +"""ACP (Agent Communication Protocol) handler for Agentex. + +This is the API layer — it owns the agent lifecycle and runs the OpenAI Agents +SDK *sandbox* agent for each incoming message, returning the agent's final +answer to the Agentex frontend. + +The agent uses the LOCAL sandbox backend (``UnixLocalSandboxClient``), which runs +shell commands on the host (this process/container). The OpenAI Agents SDK runs +its tool-call loop internally via ``Runner.run`` and returns the final output, so +this sync handler returns a single ``TextContent`` rather than streaming tokens. +""" + +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from agentex.lib import adk +from project.agent import run_agent +from agentex.protocol.acp import SendMessageParams +from agentex.lib.types.tracing import SGPTracingProcessorConfig +from agentex.lib.utils.logging import make_logger +from agentex.types.text_content import TextContent +from agentex.lib.sdk.fastacp.fastacp import FastACP +from agentex.types.task_message_content import TaskMessageContent +from agentex.lib.core.tracing.tracing_processor_manager import ( + add_tracing_processor_config, +) + +logger = make_logger(__name__) + +# LiteLLM proxy auth: copy LITELLM_API_KEY to OPENAI_API_KEY for OpenAI client +# compatibility, so the same agent works behind the Scale LiteLLM gateway. +_litellm_key = os.environ.get("LITELLM_API_KEY") +if _litellm_key and not os.environ.get("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = _litellm_key + +SGP_API_KEY = os.environ.get("SGP_API_KEY", "") +SGP_ACCOUNT_ID = os.environ.get("SGP_ACCOUNT_ID", "") +SGP_CLIENT_BASE_URL = os.environ.get("SGP_CLIENT_BASE_URL", "") + +if SGP_API_KEY and SGP_ACCOUNT_ID: + add_tracing_processor_config( + SGPTracingProcessorConfig( + sgp_api_key=SGP_API_KEY, + sgp_account_id=SGP_ACCOUNT_ID, + sgp_base_url=SGP_CLIENT_BASE_URL, + ) + ) + +AGENT_NAME = "{{ agent_name }}" + +# Create an ACP server +acp = FastACP.create(acp_type="sync") + + +@acp.on_message_send +async def handle_message_send( + params: SendMessageParams, +) -> TaskMessageContent: + """Handle incoming messages by running the local-sandbox agent.""" + task_id = params.task.id + user_message = params.content.content + logger.info(f"Processing message for task {task_id}") + + async with adk.tracing.span( + trace_id=task_id, + task_id=task_id, + name="message", + input={"message": user_message}, + data={"__span_type__": "AGENT_WORKFLOW"}, + ) as turn_span: + final_output = await run_agent(user_message) + if turn_span: + turn_span.output = {"final_output": final_output} + + return TextContent(author="agent", content=final_output) diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 new file mode 100644 index 000000000..07546bffb --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/agent.py.j2 @@ -0,0 +1,91 @@ +"""OpenAI Agents SDK local-sandbox agent definition. + +The agent is the boundary between this module and the API layer (acp.py). The +runtime is the OpenAI Agents SDK ``SandboxAgent`` together with the **local** +sandbox backend (``UnixLocalSandboxClient``). + +The local sandbox runs shell commands ON THE HOST — the agent's own +container/process. There is no Docker, no Temporal, and no remote sandbox +infrastructure. The OpenAI Agents SDK runs its own tool-call loop internally: +when the model decides to run a shell command, the sandbox executes it locally +and feeds the output back to the model until it produces a final answer. +""" + +from __future__ import annotations + +from datetime import datetime + +from agents import Runner, set_tracing_disabled +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run_config import RunConfig +from agents.sandbox.sandboxes.unix_local import ( + UnixLocalSandboxClient, + UnixLocalSandboxClientOptions, +) + +from project.tools import get_capabilities + +# Disable the openai-agents SDK's native tracer so it doesn't ship traces to +# api.openai.com using OPENAI_API_KEY (which may be a gateway/proxy key and would +# 401). Agentex tracing still runs via the tracing manager configured in acp.py. +set_tracing_disabled(True) + +MODEL_NAME = "gpt-4o-mini" +INSTRUCTIONS = """You are a local sandbox assistant. + +Current date and time: {timestamp} + +You have access to shell tools that run real commands on the local machine. + +Guidelines: +- ALWAYS use the shell tools to actually run commands — never guess or make up + output. If the user asks for the Python version, run `python3 --version`. If + they ask to list files, run `ls`. If they ask you to compute something, use + `python3 -c "..."`. +- Run the minimal command(s) needed to answer the question. +- Report the real command output back to the user, concisely. +""" + + +def create_agent() -> SandboxAgent: + """Build and return the OpenAI Agents SDK sandbox agent. + + The agent is granted shell capabilities (see ``project.tools``). The actual + sandbox backend (where the shell commands run) is supplied at run time via + the ``RunConfig`` returned by ``create_run_config``. + """ + return SandboxAgent( + name="{{ agent_name }}", + model=MODEL_NAME, + instructions=INSTRUCTIONS.format( + timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ), + capabilities=get_capabilities(), + ) + + +def create_run_config() -> RunConfig: + """Build the RunConfig that points the agent at the LOCAL sandbox backend. + + ``UnixLocalSandboxClient`` (backend_id="unix_local") runs shell commands on + the host — the agent's own process — so no Docker or remote infra is needed. + """ + return RunConfig( + sandbox=SandboxRunConfig( + client=UnixLocalSandboxClient(), + options=UnixLocalSandboxClientOptions(), + ) + ) + + +async def run_agent(user_message: str) -> str: + """Run the sandbox agent on a single user message and return the final text. + + The OpenAI Agents SDK handles the full tool-call loop internally: the model + issues shell commands, the local sandbox runs them on the host, and the + output is fed back until the model produces a final answer. + """ + agent = create_agent() + run_config = create_run_config() + result = await Runner.run(agent, input=user_message, run_config=run_config, max_turns=10) + return result.final_output diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 new file mode 100644 index 000000000..8c4a173d0 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/project/tools.py.j2 @@ -0,0 +1,29 @@ +"""Sandbox capabilities for the OpenAI Agents SDK local-sandbox agent. + +This agent does not register hand-written Python functions as tools. Instead it +is given *capabilities* — the OpenAI Agents SDK sandbox runtime turns each +capability into a real set of tools (run a shell command, read a file, etc.) +backed by an actual sandbox backend. + +Here we use the ``Shell`` capability, which lets the model run real shell commands. +With the local (``unix_local``) backend those commands execute ON THE HOST — the +agent's own process/container — so there is no Docker, Temporal, or remote infra +involved. This module hosts the capability factory so the agent wiring in +``project.agent`` stays readable and the capability set is easy to extend +(e.g. add ``Filesystem()`` or ``Memory()``). +""" + +from __future__ import annotations + +from agents.sandbox.capabilities import Shell + + +def get_capabilities() -> list: + """Return the sandbox capabilities the agent is allowed to use. + + Returns: + A list of OpenAI Agents SDK sandbox capabilities. We grant ``Shell`` so + the agent can run real shell commands on the local machine. Add + ``Filesystem()`` or ``Memory()`` here to expand what the agent can do. + """ + return [Shell()] diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 new file mode 100644 index 000000000..79e35cf0b --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/pyproject.toml.j2 @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "{{ project_name }}" +version = "0.1.0" +description = "{{ description }}" +requires-python = ">=3.12" +dependencies = [ + "agentex-sdk", + "scale-gp", + "openai-agents>=0.14.3,<0.15", + "python-dotenv", +] + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-asyncio", + "httpx", + "black", + "isort", + "flake8", +] + +[tool.hatch.build.targets.wheel] +packages = ["project"] + +[tool.black] +line-length = 88 +target-version = ['py312'] + +[tool.isort] +profile = "black" +line_length = 88 diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 new file mode 100644 index 000000000..6f73c3ae3 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/requirements.txt.j2 @@ -0,0 +1,11 @@ +# Install agentex-sdk from local path +agentex-sdk + +# Scale GenAI Platform Python SDK +scale-gp + +# OpenAI Agents SDK (provides agents.sandbox + UnixLocalSandboxClient) +openai-agents>=0.14.3,<0.15 + +# Loads .env for local development +python-dotenv diff --git a/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 new file mode 100644 index 000000000..8fa89bff8 --- /dev/null +++ b/src/agentex/lib/cli/templates/sync-openai-agents-local-sandbox/test_agent.py.j2 @@ -0,0 +1,135 @@ +"""Tests for the sync OpenAI Agents SDK local-sandbox agent. + +This test suite validates: +- Sending a message that requires the agent to actually run a shell command in + the LOCAL sandbox (unix_local backend) and receiving a non-empty response. + +To run these tests: +1. Make sure the agent is running (via docker-compose or `agentex agents run`) +2. Set the AGENTEX_API_BASE_URL environment variable if not using default +3. Run: pytest test_agent.py -v + +Configuration: +- AGENTEX_API_BASE_URL: Base URL for the AgentEx server (default: http://localhost:5003) +- AGENT_NAME: Name of the agent to test (default: {{ agent_name }}) +""" + +import os + +import pytest + +from agentex import Agentex +from agentex.types import TextContentParam +from agentex.types.agent_rpc_params import ParamsSendMessageRequest + + +AGENTEX_API_BASE_URL = os.environ.get("AGENTEX_API_BASE_URL", "http://localhost:5003") +AGENT_NAME = os.environ.get("AGENT_NAME", "{{ agent_name }}") + + +@pytest.fixture +def client(): + """Create an AgentEx client instance for testing.""" + return Agentex(base_url=AGENTEX_API_BASE_URL) + + +@pytest.fixture +def agent_name(): + """Return the agent name for testing.""" + return AGENT_NAME + + +@pytest.fixture +def agent_id(client, agent_name): + """Retrieve the agent ID based on the agent name.""" + agents = client.agents.list() + for agent in agents: + if agent.name == agent_name: + return agent.id + raise ValueError(f"Agent with name {agent_name} not found.") + + +def _response_text(result) -> str: + """Flatten a send_message result into a single string for assertions.""" + parts = [] + for content in result: + text = getattr(content, "content", None) + if isinstance(text, str): + parts.append(text) + return "\n".join(parts) + + +class TestLocalSandboxMessages: + """Test the local-sandbox OpenAI Agents SDK agent.""" + + def test_send_simple_message(self, client: Agentex, agent_name: str): + """Test sending a simple message and receiving a response.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content="Hello! What can you help me with?", + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + def test_shell_python_version(self, client: Agentex, agent_name: str): + """Test that the agent uses its shell to run a real command. + + We ask it to print the Python version. The agent should run + `python3 --version` in the local sandbox and report the real output, + which always starts with "Python 3". + """ + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use your shell to print the Python version on this " + "machine, then tell me what it is." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + # The sandbox runs on Python 3.12, so the real output contains "Python 3". + assert "Python 3" in text + + def test_shell_compute(self, client: Agentex, agent_name: str): + """Test that the agent uses python3 in the sandbox to compute a value.""" + response = client.agents.send_message( + agent_name=agent_name, + params=ParamsSendMessageRequest( + content=TextContentParam( + author="user", + content=( + "Use python3 in your shell to compute 21 * 2 and tell me " + "the result." + ), + type="text", + ) + ), + ) + result = response.result + assert result is not None + assert len(result) >= 1 + + text = _response_text(result) + assert text, "Expected a non-empty response from the sandbox agent." + assert "42" in text + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])