Skip to content

Commit 5ec62c2

Browse files
authored
feat(pydantic-ai): migrate onto unified harness surface (PR4) (#415)
1 parent 694960f commit 5ec62c2

46 files changed

Lines changed: 4439 additions & 295 deletions

Some content is hidden

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

.github/workflows/harness-integration.yml

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
paths:
88
- "src/agentex/lib/core/harness/**"
99
- "src/agentex/lib/adk/_modules/**"
10+
- "tests/lib/core/harness/test_harness_pydantic_ai_*.py"
1011
- ".github/workflows/harness-integration.yml"
1112

1213
jobs:
@@ -31,10 +32,28 @@ jobs:
3132
- name: Conformance suite
3233
run: ./scripts/test tests/lib/core/harness/ -v
3334

34-
# Live integration matrix (harness x {sync, async, temporal}) is added per-harness
35-
# in the migration plans. Placeholder job keeps the workflow valid until then.
35+
# Offline pydantic-ai integration tests (sync / async / temporal channels).
36+
# These use pydantic-ai TestModel + fake streaming/tracing and require no live
37+
# infrastructure. Enabled here for PR 4 (pydantic-ai migration). Future harness
38+
# migration PRs (5-8) should add their integration-test paths to this matrix.
3639
live-matrix:
3740
runs-on: ubuntu-latest
38-
if: false # enabled once the first harness's test agents land
41+
strategy:
42+
matrix:
43+
channel: [sync, async, temporal]
44+
fail-fast: false
45+
name: pydantic-ai-${{ matrix.channel }}
3946
steps:
40-
- run: echo "populated by migration PRs" # TODO(harness-migration): enable per-harness; see migration PRs 4-8
47+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
48+
49+
- name: Install uv
50+
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
51+
with:
52+
version: '0.10.2'
53+
54+
- name: Bootstrap
55+
run: ./scripts/bootstrap
56+
57+
- name: pydantic-ai ${{ matrix.channel }} integration tests (offline, TestModel)
58+
run: |
59+
./scripts/test tests/lib/core/harness/test_harness_pydantic_ai_${{ matrix.channel }}.py -v

docs/superpowers/plans/2026-06-18-unified-harness-surface-pr4-pydantic-ai.md

Lines changed: 246 additions & 0 deletions
Large diffs are not rendered by default.
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: 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_pydantic_ai/pyproject.toml /app/harness_pydantic_ai/pyproject.toml
27+
COPY 00_sync/harness_pydantic_ai/README.md /app/harness_pydantic_ai/README.md
28+
29+
WORKDIR /app/harness_pydantic_ai
30+
31+
# Copy the project code
32+
COPY 00_sync/harness_pydantic_ai/project /app/harness_pydantic_ai/project
33+
34+
# Copy the test files
35+
COPY 00_sync/harness_pydantic_ai/tests /app/harness_pydantic_ai/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-pydantic-ai
48+
49+
# Run the agent using uvicorn
50+
CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"]
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Sync Pydantic AI Harness Test Agent
2+
3+
A minimal **synchronous** Pydantic AI agent that drives the **unified harness
4+
surface** (`UnifiedEmitter.yield_turn` + `PydanticAITurn`) on the sync
5+
(HTTP-yield) channel.
6+
7+
## Why this agent exists
8+
9+
The `00_sync/040_pydantic_ai` tutorial streams via the bare
10+
`convert_pydantic_ai_to_agentex_events` converter and does **not** exercise the
11+
unified `yield_turn` path. This harness test agent is the sync coverage for the
12+
unified surface: it proves an agent author can wire the sync channel through
13+
`UnifiedEmitter` and get automatic span derivation (tool spans nested under the
14+
per-turn span) for free, exactly like the async/temporal channels.
15+
16+
## How it wires the unified surface
17+
18+
In `project/acp.py`:
19+
20+
```python
21+
emitter = UnifiedEmitter(
22+
task_id=task_id,
23+
trace_id=task_id,
24+
parent_span_id=turn_span.id if turn_span else None,
25+
)
26+
async with agent.run_stream_events(user_message) as stream:
27+
turn = PydanticAITurn(stream, model=MODEL_NAME) # coalesce off: stream tool-call arg tokens
28+
async for ev in emitter.yield_turn(turn):
29+
yield ev
30+
```
31+
32+
- `coalesce_tool_requests=False` (the default) preserves token-by-token
33+
tool-call argument streaming on the sync channel.
34+
- The `UnifiedEmitter` is constructed from the ACP/streaming context
35+
(`task_id` + `trace_id` + `parent_span_id`) so tool spans nest under the
36+
per-turn `AGENT_WORKFLOW` span automatically.
37+
38+
## Files
39+
40+
- `project/acp.py` — sync ACP handler using `emitter.yield_turn(...)`.
41+
- `project/agent.py` — builds the `pydantic_ai.Agent` with one tool.
42+
- `project/tools.py``get_weather(city)` returning a constant.
43+
- `tests/test_agent.py` — live integration test (requires a running agent).
44+
45+
## Tools
46+
47+
- `get_weather(city: str) -> str`: returns a fixed "sunny and 72°F" string so a
48+
run deterministically exercises text + a tool call + a tool response.
49+
50+
## Offline coverage
51+
52+
Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake
53+
streaming/tracing, no network) live in the SDK repo at
54+
`tests/lib/core/harness/test_harness_pydantic_ai_sync.py`.
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_pydantic_ai
6+
- test_utils
7+
dockerfile: 00_sync/harness_pydantic_ai/Dockerfile
8+
dockerignore: 00_sync/harness_pydantic_ai/.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-pydantic-ai
20+
description: A sync Pydantic AI harness test agent using the unified emitter surface
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-pydantic-ai"
50+
description: "A sync Pydantic AI harness test agent using the unified emitter surface"
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_pydantic_ai/project/__init__.py

Whitespace-only changes.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""ACP handler for the sync harness Pydantic AI test agent.
2+
3+
This agent exercises the UNIFIED HARNESS SURFACE on the sync (HTTP-yield)
4+
channel — ``UnifiedEmitter.yield_turn(PydanticAITurn(...))`` — rather than the
5+
bare ``convert_pydantic_ai_to_agentex_events`` converter used by the
6+
``040_pydantic_ai`` tutorial. The unified surface gives the sync channel the
7+
same tracing (span derivation) the async/temporal channels get for free.
8+
9+
Flow:
10+
1. Open a per-turn AGENT_WORKFLOW span via ``adk.tracing.span``.
11+
2. Construct a ``UnifiedEmitter`` from the ACP/streaming context (task_id +
12+
trace_id + parent_span_id) so tool spans nest under the turn span.
13+
3. Wrap ``agent.run_stream_events(...)`` in a ``PydanticAITurn`` and forward
14+
events with ``emitter.yield_turn(turn)`` — yielding each to the client.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
from typing import AsyncGenerator
21+
22+
from dotenv import load_dotenv
23+
24+
load_dotenv()
25+
26+
import agentex.lib.adk as adk
27+
from project.agent import MODEL_NAME, create_agent
28+
from agentex.lib.types.acp import SendMessageParams
29+
from agentex.lib.core.harness import UnifiedEmitter
30+
from agentex.lib.types.tracing import SGPTracingProcessorConfig
31+
from agentex.lib.utils.logging import make_logger
32+
from agentex.lib.sdk.fastacp.fastacp import FastACP
33+
from agentex.types.task_message_update import TaskMessageUpdate
34+
from agentex.types.task_message_content import TaskMessageContent
35+
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
36+
from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config
37+
38+
logger = make_logger(__name__)
39+
40+
add_tracing_processor_config(
41+
SGPTracingProcessorConfig(
42+
sgp_api_key=os.environ.get("SGP_API_KEY", ""),
43+
sgp_account_id=os.environ.get("SGP_ACCOUNT_ID", ""),
44+
sgp_base_url=os.environ.get("SGP_CLIENT_BASE_URL", ""),
45+
)
46+
)
47+
48+
acp = FastACP.create(acp_type="sync")
49+
50+
_agent = None
51+
52+
53+
def get_agent():
54+
"""Get or create the Pydantic AI agent instance."""
55+
global _agent
56+
if _agent is None:
57+
_agent = create_agent()
58+
return _agent
59+
60+
61+
@acp.on_message_send
62+
async def handle_message_send(
63+
params: SendMessageParams,
64+
) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]:
65+
"""Handle incoming messages, streaming events through the unified surface."""
66+
agent = get_agent()
67+
task_id = params.task.id
68+
69+
user_message = params.content.content
70+
logger.info(f"Processing message for task {task_id}")
71+
72+
async with adk.tracing.span(
73+
trace_id=task_id,
74+
task_id=task_id,
75+
name="message",
76+
input={"message": user_message},
77+
data={"__span_type__": "AGENT_WORKFLOW"},
78+
) as turn_span:
79+
# Construct the UnifiedEmitter from the ACP/streaming context so tracing
80+
# is automatic: tool spans nest under this turn's span.
81+
emitter = UnifiedEmitter(
82+
task_id=task_id,
83+
trace_id=task_id,
84+
parent_span_id=turn_span.id if turn_span else None,
85+
)
86+
87+
async with agent.run_stream_events(user_message) as stream:
88+
# PydanticAITurn preserves token-by-token tool-call argument
89+
# streaming (Start+Delta+Done) on the sync/HTTP channel.
90+
turn = PydanticAITurn(stream, model=MODEL_NAME)
91+
async for ev in emitter.yield_turn(turn):
92+
yield ev
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""Pydantic AI agent definition for the sync harness test agent.
2+
3+
The Agent is the boundary between this module and the API layer (acp.py).
4+
Pydantic AI handles its own tool-call loop internally — no graph required.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from datetime import datetime
10+
11+
from pydantic_ai import Agent
12+
13+
from project.tools import get_weather
14+
15+
__all__ = ["create_agent", "MODEL_NAME"]
16+
17+
MODEL_NAME = "openai:gpt-4o-mini"
18+
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.
19+
20+
Current date and time: {timestamp}
21+
22+
Guidelines:
23+
- Be concise and helpful
24+
- Use tools when they would help answer the user's question
25+
- If you're unsure, ask clarifying questions
26+
- Always provide accurate information
27+
"""
28+
29+
30+
def create_agent() -> Agent:
31+
"""Build and return the Pydantic AI agent with tools registered."""
32+
agent = Agent(
33+
MODEL_NAME,
34+
system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
35+
)
36+
37+
agent.tool_plain(get_weather)
38+
39+
return agent

0 commit comments

Comments
 (0)