Skip to content

Commit 41f34e2

Browse files
Merge branch 'next' into dm/sdk-webhook-helper
2 parents b03fdd0 + 7f6d70a commit 41f34e2

223 files changed

Lines changed: 22566 additions & 804 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/agentex-tutorials-test.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ jobs:
4949
curl -LsSf https://astral.sh/uv/install.sh | sh
5050
echo "$HOME/.local/bin" >> $GITHUB_PATH
5151
52+
# Subprocess-CLI harnesses: install the relevant CLI only for the
53+
# claude-code / codex tutorials (no-op for every other tutorial). npm is
54+
# preinstalled on ubuntu runners. Versions mirror the golden agent's
55+
# sandbox image (teams/sgp/agents/golden_agent/sandbox/Dockerfile): claude-code
56+
# is pinned to the same CLAUDE_CODE_VERSION; codex is left unpinned there,
57+
# so it is left unpinned here too. Bump CLAUDE_CODE_VERSION in lockstep
58+
# with the sandbox Dockerfile.
59+
- name: Install harness CLI (claude-code / codex only)
60+
if: ${{ contains(matrix.tutorial, 'claude_code') || contains(matrix.tutorial, 'codex') }}
61+
env:
62+
CLAUDE_CODE_VERSION: "2.1.142"
63+
run: |
64+
if [[ "${{ matrix.tutorial }}" == *claude_code* ]]; then
65+
echo "📦 Installing Claude Code CLI (v${CLAUDE_CODE_VERSION})..."
66+
npm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
67+
claude --version || true
68+
fi
69+
if [[ "${{ matrix.tutorial }}" == *codex* ]]; then
70+
echo "📦 Installing Codex CLI..."
71+
npm install -g @openai/codex
72+
codex --version || true
73+
fi
74+
5275
- name: Pull latest AgentEx image
5376
run: |
5477
echo "🐳 Pulling latest Scale AgentEx Docker image..."
@@ -136,6 +159,11 @@ jobs:
136159
working-directory: ./examples/tutorials
137160
env:
138161
OPENAI_API_KEY: ${{ secrets.TUTORIAL_OPENAI_API_KEY }}
162+
ANTHROPIC_API_KEY: ${{ secrets.TUTORIAL_ANTHROPIC_API_KEY }}
163+
# Enable the gated live tests only for the matching subprocess-CLI
164+
# harness tutorial (the CLI is installed for it in the step above).
165+
CLAUDE_LIVE_TESTS: ${{ contains(matrix.tutorial, 'claude_code') && '1' || '' }}
166+
CODEX_LIVE_TESTS: ${{ contains(matrix.tutorial, 'codex') && '1' || '' }}
139167
HEALTH_CHECK_PORT: 8080 # Use non-privileged port for temporal worker health checks
140168
run: |
141169
echo "Testing tutorial: ${{ matrix.tutorial }}"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Harness Integration
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
paths:
8+
- "src/agentex/lib/core/harness/**"
9+
- "src/agentex/lib/adk/_modules/**"
10+
- "tests/lib/core/harness/test_harness_pydantic_ai_*.py"
11+
- "tests/lib/core/harness/test_harness_langgraph_*.py"
12+
- ".github/workflows/harness-integration.yml"
13+
14+
jobs:
15+
conformance:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19+
20+
- name: Install uv
21+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
22+
with:
23+
version: '0.10.2'
24+
25+
- name: Bootstrap
26+
run: ./scripts/bootstrap
27+
28+
# Defer to scripts/test so the harness suite runs under the exact same
29+
# invocation as the main CI test job: DEFER_PYDANTIC_BUILD=false and
30+
# `uv run --isolated --all-packages --all-extras pytest`, across the
31+
# min/max supported Python versions. Running `uv run pytest` directly
32+
# would risk an all-extras-only dep passing locally but failing in CI.
33+
- name: Conformance suite
34+
run: ./scripts/test tests/lib/core/harness/ -v
35+
36+
# Offline harness integration tests (sync / async / temporal channels) for each
37+
# migrated harness. These use fake streams / TestModel + fake streaming/tracing
38+
# and require no live infrastructure. Future harness migration PRs (6-8) add
39+
# their harness to the matrix below and their test paths to the triggers above.
40+
live-matrix:
41+
runs-on: ubuntu-latest
42+
strategy:
43+
matrix:
44+
harness: [pydantic_ai, langgraph]
45+
channel: [sync, async, temporal]
46+
fail-fast: false
47+
name: ${{ matrix.harness }}-${{ matrix.channel }}
48+
steps:
49+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
50+
51+
- name: Install uv
52+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
53+
with:
54+
version: '0.10.2'
55+
56+
- name: Bootstrap
57+
run: ./scripts/bootstrap
58+
59+
- name: ${{ matrix.harness }} ${{ matrix.channel }} integration tests (offline)
60+
run: |
61+
./scripts/test tests/lib/core/harness/test_harness_${{ matrix.harness }}_${{ matrix.channel }}.py -v

adk/docs/harness.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Unified Harness Surface
2+
3+
The unified harness surface gives every agent harness (pydantic-ai, LangGraph, OpenAI Agents, and future parsers) a single, shared path to streaming, message persistence, and tracing. The Agentex `StreamTaskMessage*` event stream is the canonical wire format. A harness tap produces that stream once; the shared machinery delivers it and derives spans from it.
4+
5+
All public names are re-exported from `agentex.lib.adk`:
6+
7+
```python
8+
from agentex.lib.adk import (
9+
UnifiedEmitter,
10+
SpanTracer,
11+
TurnUsage,
12+
TurnResult,
13+
HarnessTurn,
14+
StreamTaskMessage,
15+
OpenSpan,
16+
CloseSpan,
17+
SpanSignal,
18+
)
19+
```
20+
21+
The implementation lives at `src/agentex/lib/core/harness/`.
22+
23+
---
24+
25+
## The canonical stream: `StreamTaskMessage`
26+
27+
`StreamTaskMessage` is a union of the four wire-protocol update types:
28+
29+
```
30+
StreamTaskMessageStart - opens a content slot (text, reasoning, tool request, ...)
31+
StreamTaskMessageDelta - appends a token/fragment to an open slot
32+
StreamTaskMessageFull - posts a complete message in one shot (tool response, ...)
33+
StreamTaskMessageDone - closes an open slot
34+
```
35+
36+
Every harness tap produces a sequence of these. Everything downstream (delivery, tracing) reads the same sequence.
37+
38+
---
39+
40+
## Per-harness taps: `convert_<harness>_to_agentex_events`
41+
42+
A tap is an async generator that translates the harness's native event stream into `StreamTaskMessage*` events. The currently shipped taps are:
43+
44+
| Harness | Tap function | Exported from |
45+
|---|---|---|
46+
| pydantic-ai | `convert_pydantic_ai_to_agentex_events` | `agentex.lib.adk` |
47+
| LangGraph | `convert_langgraph_to_agentex_events` | `agentex.lib.adk` |
48+
49+
Taps for claude-code and codex will be added in subsequent PRs (AGX1-420, AGX1-421) and exported from `agentex.lib.adk` in the same way.
50+
51+
---
52+
53+
## `HarnessTurn` protocol
54+
55+
`HarnessTurn` is the interface a harness turn object must satisfy to plug into `UnifiedEmitter`:
56+
57+
```python
58+
@runtime_checkable
59+
class HarnessTurn(Protocol):
60+
@property
61+
def events(self) -> AsyncIterator[StreamTaskMessage]: ...
62+
63+
def usage(self) -> TurnUsage: ...
64+
```
65+
66+
`events` is the canonical stream for this turn. `usage()` is valid only after `events` is exhausted (async generators cannot cleanly return a value to the consumer, so usage travels out-of-band).
67+
68+
---
69+
70+
## `TurnUsage`
71+
72+
Token counts and cost for one turn, harness-independent:
73+
74+
```python
75+
class TurnUsage(BaseModel):
76+
model: str | None = None
77+
input_tokens: int | None = None
78+
output_tokens: int | None = None
79+
cached_input_tokens: int | None = None
80+
reasoning_tokens: int | None = None
81+
total_tokens: int | None = None
82+
cost_usd: float | None = None
83+
duration_ms: int | None = None
84+
num_llm_calls: int = 0
85+
num_tool_calls: int = 0
86+
num_reasoning_blocks: int = 0
87+
```
88+
89+
Field names align with `agentex.lib.core.observability.llm_metrics` for easy conversion.
90+
91+
---
92+
93+
## `UnifiedEmitter`
94+
95+
`UnifiedEmitter` ties a turn's canonical stream, tracing context, and delivery mode together. Construct one per turn with the task/trace context from the request:
96+
97+
```python
98+
emitter = UnifiedEmitter(
99+
task_id=params.task.id,
100+
trace_id=params.task.id, # or None to disable tracing
101+
parent_span_id=turn_span.id if turn_span else None,
102+
)
103+
```
104+
105+
**Tracing is on by default** when `trace_id` is provided. To disable it explicitly, pass `tracer=False`. To inject a custom `SpanTracer` (e.g. in tests), pass it as `tracer=<instance>`.
106+
107+
### Delivery mode 1: `yield_turn` (sync HTTP ACP)
108+
109+
For sync ACP agents that return events directly over the HTTP response:
110+
111+
```python
112+
@acp.on_message_send
113+
async def handle(params):
114+
turn = MyHarnessTurn(params) # implements HarnessTurn
115+
async for event in emitter.yield_turn(turn):
116+
yield event
117+
```
118+
119+
`yield_turn` forwards each event to the caller and traces spans as a side effect. It is a passthrough when `tracer` is `None`.
120+
121+
### Delivery mode 2: `auto_send_turn` (async/Temporal)
122+
123+
For async or Temporal agents that push to the task stream via Redis:
124+
125+
```python
126+
result: TurnResult = await emitter.auto_send_turn(turn, created_at=workflow.now())
127+
```
128+
129+
`auto_send_turn` drives `adk.streaming` contexts for every message in the stream, derives and records spans, and returns a `TurnResult` with the final text and usage. Pass `created_at` under Temporal to back-date message timestamps deterministically.
130+
131+
---
132+
133+
## `TurnResult`
134+
135+
```python
136+
class TurnResult(BaseModel):
137+
final_text: str = ""
138+
usage: TurnUsage = TurnUsage()
139+
```
140+
141+
Returned by `auto_send_turn`. `final_text` is the last text segment of the turn (multi-step runs return only the final segment, matching `stream_langgraph_events` / `stream_pydantic_ai_events` semantics).
142+
143+
---
144+
145+
## Tracing: span derivation
146+
147+
Spans are derived from the canonical stream by `SpanDeriver` (pure, no `adk` dependency) and dispatched to `adk.tracing` by `SpanTracer`. The mapping:
148+
149+
- `StreamTaskMessageStart(ToolRequestContent)` + `StreamTaskMessageDone` on that index -> tool span open (keyed by `tool_call_id`)
150+
- `StreamTaskMessageFull(ToolResponseContent)` whose `tool_call_id` was opened -> tool span close
151+
- `StreamTaskMessageFull(ToolRequestContent)` (harnesses that emit tool calls as Full) -> opens a tool span; matching `Full(ToolResponseContent)` closes it
152+
- `StreamTaskMessageStart(ReasoningContent)` + `StreamTaskMessageDone` -> reasoning span
153+
154+
`SpanTracer` is `SpanDeriver`'s consumer. You can inject a custom `SpanTracer` via `UnifiedEmitter(tracer=<instance>)` for advanced use or testing.
155+
156+
---
157+
158+
## Usage examples by channel
159+
160+
### Sync ACP (pydantic-ai tap)
161+
162+
```python
163+
import agentex.lib.adk as adk
164+
from agentex.lib.adk import UnifiedEmitter, convert_pydantic_ai_to_agentex_events
165+
166+
@acp.on_message_send
167+
async def handle(params):
168+
task_id = params.task.id
169+
async with adk.tracing.span(trace_id=task_id, name="message", ...) as turn_span:
170+
emitter = UnifiedEmitter(
171+
task_id=task_id,
172+
trace_id=task_id,
173+
parent_span_id=turn_span.id if turn_span else None,
174+
)
175+
tap = convert_pydantic_ai_to_agentex_events(pydantic_stream)
176+
# wrap tap in a HarnessTurn then yield_turn, or yield directly:
177+
async for event in tap:
178+
yield event
179+
```
180+
181+
For the pre-unified sync path the tap is still yielded directly; `UnifiedEmitter.yield_turn` is the forward-looking integration point when a `HarnessTurn` wrapper is available.
182+
183+
### Async Temporal (auto-send)
184+
185+
```python
186+
from agentex.lib.adk import UnifiedEmitter
187+
188+
emitter = UnifiedEmitter(
189+
task_id=task_id,
190+
trace_id=task_id,
191+
parent_span_id=parent_span_id,
192+
)
193+
result = await emitter.auto_send_turn(turn, created_at=workflow.now())
194+
# result.final_text — last text segment
195+
# result.usage — TurnUsage (tokens, cost, ...)
196+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
build/
8+
develop-eggs/
9+
dist/
10+
downloads/
11+
eggs/
12+
.eggs/
13+
lib/
14+
lib64/
15+
parts/
16+
sdist/
17+
var/
18+
wheels/
19+
*.egg-info/
20+
.installed.cfg
21+
*.egg
22+
23+
# Environments
24+
.env**
25+
.venv
26+
env/
27+
venv/
28+
ENV/
29+
env.bak/
30+
venv.bak/
31+
32+
# IDE
33+
.idea/
34+
.vscode/
35+
*.swp
36+
*.swo
37+
38+
# Git
39+
.git
40+
.gitignore
41+
42+
# Misc
43+
.DS_Store
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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 including Node.js (required by the claude CLI)
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+
nodejs \
19+
npm \
20+
&& apt-get clean \
21+
&& rm -rf /var/lib/apt/lists/*
22+
23+
RUN uv pip install --system --upgrade pip setuptools wheel
24+
25+
# Install the claude CLI (requires Node.js)
26+
# NOTE: live runs require ANTHROPIC_API_KEY in the environment.
27+
RUN npm install -g @anthropic-ai/claude-code || true
28+
29+
ENV UV_HTTP_TIMEOUT=1000
30+
31+
COPY 00_sync/060_claude_code/pyproject.toml /app/060_claude_code/pyproject.toml
32+
COPY 00_sync/060_claude_code/README.md /app/060_claude_code/README.md
33+
34+
WORKDIR /app/060_claude_code
35+
36+
COPY 00_sync/060_claude_code/project /app/060_claude_code/project
37+
COPY 00_sync/060_claude_code/tests /app/060_claude_code/tests
38+
COPY test_utils /app/test_utils
39+
40+
RUN uv pip install --system .[dev]
41+
42+
ENV PYTHONPATH=/app
43+
44+
ENV AGENT_NAME=s060-claude-code
45+
46+
CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"]

0 commit comments

Comments
 (0)