Skip to content

Commit af856a0

Browse files
declan-scaleclaude
andcommitted
feat(codex): sync/async/temporal tutorial agents driving the unified surface via local CLI subprocess
Adds three tutorial agents that demonstrate the full convert_codex_to_agentex_events tap + CodexTurn + UnifiedEmitter pipeline using a plain local asyncio subprocess (no Scale sandbox): examples/tutorials/00_sync/harness_codex/ — sync HTTP-yield examples/tutorials/10_async/00_base/harness_codex/ — async Redis-streaming examples/tutorials/10_async/10_temporal/harness_codex/ — Temporal-durable Each agent: - Spawns `codex exec --json` via asyncio.create_subprocess_exec with an injectable `_spawn` seam for offline testing. - Wraps the stdout line iterator in CodexTurn. - Delivers via the unified surface: sync → emitter.yield_turn(turn) async → emitter.auto_send_turn(turn) temporal → emitter.auto_send_turn(turn, created_at=workflow.now()) Each agent ships: - project/acp.py (+ workflow.py + run_worker.py for temporal) - manifest.yaml / pyproject.toml / Dockerfile / README.md - tests/test_agent.py with offline unit tests (fake async-iterator, no real CLI) + live-gated integration tests (CODEX_LIVE_TESTS=1) - conftest.py that wires sys.path + minimal env vars for offline runs The three manifest.yaml files are auto-discovered by the CI agentex-tutorials-test.yml `find . -name "manifest.yaml"` sweep. Live codex runs need `codex` CLI + OPENAI_API_KEY on the test runner. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0176be7 commit af856a0

26 files changed

Lines changed: 1811 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# syntax=docker/dockerfile:1.3
2+
FROM python:3.12-slim
3+
COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/
4+
5+
# Install system dependencies
6+
RUN apt-get update && apt-get install -y \
7+
htop \
8+
vim \
9+
curl \
10+
tar \
11+
python3-dev \
12+
postgresql-client \
13+
build-essential \
14+
libpq-dev \
15+
gcc \
16+
cmake \
17+
netcat-openbsd \
18+
&& apt-get clean \
19+
&& rm -rf /var/lib/apt/lists/*
20+
21+
RUN uv pip install --system --upgrade pip setuptools wheel
22+
23+
ENV UV_HTTP_TIMEOUT=1000
24+
25+
# Copy pyproject.toml and README.md to install dependencies
26+
COPY 00_sync/harness_codex/pyproject.toml /app/harness_codex/pyproject.toml
27+
COPY 00_sync/harness_codex/README.md /app/harness_codex/README.md
28+
29+
WORKDIR /app/harness_codex
30+
31+
# Copy the project code
32+
COPY 00_sync/harness_codex/project /app/harness_codex/project
33+
34+
# Copy the test files
35+
COPY 00_sync/harness_codex/tests /app/harness_codex/tests
36+
37+
# Copy shared test utilities
38+
COPY test_utils /app/test_utils
39+
40+
# Install the required Python packages with dev dependencies
41+
RUN uv pip install --system .[dev]
42+
43+
# Set environment variables
44+
ENV PYTHONPATH=/app
45+
46+
# Set test environment variables
47+
ENV AGENT_NAME=s-harness-codex
48+
49+
# Run the agent using uvicorn
50+
CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# harness_codex (sync)
2+
3+
Tutorial agent demonstrating the `convert_codex_to_agentex_events` tap,
4+
`CodexTurn`, and `UnifiedEmitter` for a **sync** (HTTP-yield) ACP agent.
5+
6+
## What this tutorial shows
7+
8+
- Spawning `codex exec --json` as a **local asyncio subprocess** (no Scale sandbox).
9+
- Wrapping the stdout line stream in a `CodexTurn`.
10+
- Delivering every canonical `StreamTaskMessage*` event to the HTTP caller via
11+
`UnifiedEmitter.yield_turn` (tracing as a side-effect).
12+
13+
> **Production isolation note:** A tutorial agent runs the Codex CLI locally.
14+
> Production-grade isolation (Scale sandbox, secret injection, MCP configuration)
15+
> is handled by the golden agent at
16+
> `teams/sgp/agents/golden_agent/project/harness/providers/codex.py`.
17+
18+
## Live runs
19+
20+
Live runs require:
21+
1. The `codex` CLI on PATH: `npm install -g @openai/codex`
22+
2. `OPENAI_API_KEY` set in the environment.
23+
24+
## Running offline unit tests
25+
26+
The offline tests inject a fake subprocess and never invoke the real CLI:
27+
28+
```bash
29+
cd /path/to/scale-agentex-python
30+
uv run --all-packages --all-extras pytest examples/tutorials/00_sync/harness_codex/tests/test_agent.py -q
31+
```
32+
33+
## Running live integration tests
34+
35+
```bash
36+
export CODEX_LIVE_TESTS=1
37+
export OPENAI_API_KEY=sk-...
38+
# Start the agent server first, then:
39+
pytest tests/test_agent.py -v
40+
```
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Add the agent's project root to sys.path so ``import project`` works.
2+
3+
Also sets minimal environment variables so the FastACP and tracing modules
4+
can be imported without a running agent server.
5+
"""
6+
7+
import os
8+
import sys
9+
10+
sys.path.insert(0, os.path.dirname(__file__))
11+
12+
os.environ.setdefault("AGENT_NAME", "s-harness-codex-test")
13+
os.environ.setdefault("ACP_URL", "http://localhost:8000")
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
build:
2+
context:
3+
root: ../../
4+
include_paths:
5+
- 00_sync/harness_codex
6+
- test_utils
7+
dockerfile: 00_sync/harness_codex/Dockerfile
8+
dockerignore: 00_sync/harness_codex/.dockerignore
9+
10+
local_development:
11+
agent:
12+
port: 8000
13+
host_address: host.docker.internal
14+
paths:
15+
acp: project/acp.py
16+
17+
agent:
18+
acp_type: sync
19+
name: s-harness-codex
20+
description: Sync tutorial agent driving the unified harness surface via local codex CLI subprocess
21+
22+
temporal:
23+
enabled: false
24+
25+
credentials:
26+
- env_var_name: OPENAI_API_KEY
27+
secret_name: openai-api-key
28+
secret_key: api-key
29+
- env_var_name: REDIS_URL
30+
secret_name: redis-url-secret
31+
secret_key: url
32+
- env_var_name: SGP_API_KEY
33+
secret_name: sgp-api-key
34+
secret_key: api-key
35+
- env_var_name: SGP_ACCOUNT_ID
36+
secret_name: sgp-account-id
37+
secret_key: account-id
38+
- env_var_name: SGP_CLIENT_BASE_URL
39+
secret_name: sgp-client-base-url
40+
secret_key: url
41+
42+
deployment:
43+
image:
44+
repository: ""
45+
tag: "latest"
46+
47+
global:
48+
agent:
49+
name: "s-harness-codex"
50+
description: "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess"
51+
replicaCount: 1
52+
resources:
53+
requests:
54+
cpu: "500m"
55+
memory: "1Gi"
56+
limits:
57+
cpu: "1000m"
58+
memory: "2Gi"

examples/tutorials/00_sync/harness_codex/project/__init__.py

Whitespace-only changes.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Sync ACP handler for the Codex CLI harness tutorial.
2+
3+
Demonstrates the ``convert_codex_to_agentex_events`` tap + ``CodexTurn`` +
4+
``UnifiedEmitter`` for a sync (HTTP-yield) ACP agent.
5+
6+
The handler:
7+
1. Spawns ``codex exec --json`` as a LOCAL asyncio subprocess (no sandbox).
8+
This is correct for tutorials and local development; production isolation
9+
is handled by the golden agent's Scale sandbox at
10+
``teams/sgp/agents/golden_agent/project/harness/providers/codex.py``.
11+
2. Wraps the stdout line stream in a ``CodexTurn``.
12+
3. Delivers every canonical ``StreamTaskMessage*`` event via
13+
``UnifiedEmitter.yield_turn``, which traces + yields each event back to
14+
the HTTP caller in one pass.
15+
16+
Live runs require:
17+
- ``codex`` CLI on PATH (``npm install -g @openai/codex``)
18+
- ``OPENAI_API_KEY`` set in the environment
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import os
24+
import time
25+
import asyncio
26+
from typing import AsyncGenerator
27+
from collections.abc import AsyncIterator
28+
29+
from dotenv import load_dotenv
30+
31+
load_dotenv()
32+
33+
import agentex.lib.adk as adk
34+
from agentex.lib.adk import CodexTurn
35+
from agentex.lib.types.acp import SendMessageParams
36+
from agentex.lib.core.harness import UnifiedEmitter
37+
from agentex.lib.types.tracing import SGPTracingProcessorConfig
38+
from agentex.lib.utils.logging import make_logger
39+
from agentex.lib.sdk.fastacp.fastacp import FastACP
40+
from agentex.types.task_message_update import TaskMessageUpdate
41+
from agentex.types.task_message_content import TaskMessageContent
42+
from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config
43+
44+
logger = make_logger(__name__)
45+
46+
add_tracing_processor_config(
47+
SGPTracingProcessorConfig(
48+
sgp_api_key=os.environ.get("SGP_API_KEY", ""),
49+
sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""),
50+
sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""),
51+
)
52+
)
53+
54+
acp = FastACP.create(acp_type="sync")
55+
56+
MODEL = os.environ.get("CODEX_MODEL", "o4-mini")
57+
58+
59+
async def _spawn_codex(model: str) -> asyncio.subprocess.Process:
60+
"""Spawn ``codex exec --json`` locally and return the live process.
61+
62+
Injection seam: tests replace this function with a fake that returns a
63+
mock process whose stdout yields pre-recorded event lines.
64+
65+
The flags mirror the golden agent (codex.py in the golden agent repo):
66+
--json machine-readable newline-delimited events
67+
--skip-git-repo-check safe to run outside a git repo
68+
--dangerously-bypass-approvals-and-sandbox
69+
skip interactive approval prompts in a
70+
non-interactive (server) context
71+
--model <model> which OpenAI model to use
72+
73+
The caller writes the prompt to stdin after the process starts, then
74+
closes stdin so codex knows input is complete.
75+
"""
76+
cmd = [
77+
"codex",
78+
"exec",
79+
"--json",
80+
"--skip-git-repo-check",
81+
"--dangerously-bypass-approvals-and-sandbox",
82+
"--model",
83+
model,
84+
"-", # read prompt from stdin
85+
]
86+
return await asyncio.create_subprocess_exec(
87+
*cmd,
88+
stdin=asyncio.subprocess.PIPE,
89+
stdout=asyncio.subprocess.PIPE,
90+
stderr=asyncio.subprocess.PIPE,
91+
env={**os.environ},
92+
)
93+
94+
95+
async def _process_stdout(process: asyncio.subprocess.Process) -> AsyncIterator[str]:
96+
"""Yield newline-delimited JSON lines from the process stdout."""
97+
assert process.stdout is not None
98+
buffer = ""
99+
while True:
100+
chunk = await process.stdout.read(4096)
101+
if not chunk:
102+
break
103+
buffer += chunk.decode("utf-8", errors="replace")
104+
while "\n" in buffer:
105+
line, buffer = buffer.split("\n", 1)
106+
line = line.strip()
107+
if line:
108+
yield line
109+
if buffer.strip():
110+
yield buffer.strip()
111+
112+
113+
@acp.on_message_send
114+
async def handle_message_send(
115+
params: SendMessageParams,
116+
) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]:
117+
"""Handle each message by running ``codex exec`` locally and streaming events."""
118+
task_id = params.task.id
119+
user_message = params.content.content
120+
logger.info("Processing message for task %s", task_id)
121+
122+
start_ms = int(time.monotonic() * 1000)
123+
124+
async with adk.tracing.span(
125+
trace_id=task_id,
126+
task_id=task_id,
127+
name="message",
128+
input={"message": user_message},
129+
data={"__span_type__": "AGENT_WORKFLOW"},
130+
) as turn_span:
131+
process = await _spawn_codex(MODEL)
132+
133+
# Write prompt to stdin then close it so codex knows input is done.
134+
assert process.stdin is not None
135+
process.stdin.write(user_message.encode("utf-8"))
136+
await process.stdin.drain()
137+
process.stdin.close()
138+
139+
duration_ms = int(time.monotonic() * 1000) - start_ms
140+
turn = CodexTurn(
141+
events=_process_stdout(process),
142+
model=MODEL,
143+
duration_ms=duration_ms,
144+
)
145+
146+
emitter = UnifiedEmitter(
147+
task_id=task_id,
148+
trace_id=task_id,
149+
parent_span_id=turn_span.id if turn_span else None,
150+
)
151+
152+
async for event in emitter.yield_turn(turn):
153+
yield event
154+
155+
await process.wait()
156+
157+
if turn_span:
158+
usage = turn.usage()
159+
turn_span.output = {
160+
"model": usage.model,
161+
"input_tokens": usage.input_tokens,
162+
"output_tokens": usage.output_tokens,
163+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "s-harness-codex"
7+
version = "0.1.0"
8+
description = "Sync tutorial agent driving the unified harness surface via local codex CLI subprocess"
9+
readme = "README.md"
10+
requires-python = ">=3.12"
11+
dependencies = [
12+
"agentex-sdk",
13+
"scale-gp",
14+
]
15+
16+
[project.optional-dependencies]
17+
dev = [
18+
"pytest",
19+
"pytest-asyncio",
20+
"httpx",
21+
"black",
22+
"isort",
23+
"flake8",
24+
]
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["project"]
28+
29+
[tool.black]
30+
line-length = 88
31+
target-version = ['py312']
32+
33+
[tool.isort]
34+
profile = "black"
35+
line_length = 88
36+
37+
[tool.pytest.ini_options]
38+
asyncio_mode = "auto"

0 commit comments

Comments
 (0)