Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
ee6a684
feat(pydantic-ai): optional on_result callback to expose run result f…
declan-scale Jun 18, 2026
6a9c1fc
test(pydantic-ai): assert content-equality + real await in on_result …
declan-scale Jun 18, 2026
9710c01
feat(pydantic-ai): PydanticAITurn HarnessTurn + usage normalization
declan-scale Jun 18, 2026
cbc96df
fix(pydantic-ai): preserve real zero token counts in usage normalizat…
declan-scale Jun 18, 2026
1bb517b
test(pydantic-ai): characterize stream_pydantic_ai_events output
declan-scale Jun 18, 2026
18ca560
refactor(pydantic-ai): reimplement stream_pydantic_ai_events on Unifi…
declan-scale Jun 18, 2026
b8ec8ab
refactor(pydantic-ai): make tool-request coalescing opt-in (preserve …
declan-scale Jun 18, 2026
eafb95e
docs(pydantic-ai): document unified sync path; deprecate bespoke trac…
declan-scale Jun 18, 2026
aba89e0
test(pydantic-ai): cross-channel conformance fixtures
declan-scale Jun 18, 2026
4194f4b
test(pydantic-ai): sync/async/temporal integration agents + enable CI…
declan-scale Jun 18, 2026
172f71a
feat(pydantic-ai): sync/async/temporal harness test-agent projects us…
declan-scale Jun 18, 2026
6e430c2
chore(pydantic-ai): type/lint/back-compat fixes
declan-scale Jun 18, 2026
e3bdc4b
docs: include PR 4 implementation plan alongside the implementation
declan-scale Jun 18, 2026
dc6b82f
refactor(pydantic-ai): drop coalesce_tool_requests workaround — found…
declan-scale Jun 18, 2026
3745c56
fix(pydantic-ai): drop removed coalesce_tool_requests kwarg from tuto…
declan-scale Jun 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .github/workflows/harness-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:
paths:
- "src/agentex/lib/core/harness/**"
- "src/agentex/lib/adk/_modules/**"
- "tests/lib/core/harness/test_harness_pydantic_ai_*.py"
- ".github/workflows/harness-integration.yml"

jobs:
Expand All @@ -31,10 +32,28 @@ jobs:
- name: Conformance suite
run: ./scripts/test tests/lib/core/harness/ -v

# Live integration matrix (harness x {sync, async, temporal}) is added per-harness
# in the migration plans. Placeholder job keeps the workflow valid until then.
# Offline pydantic-ai integration tests (sync / async / temporal channels).
# These use pydantic-ai TestModel + fake streaming/tracing and require no live
# infrastructure. Enabled here for PR 4 (pydantic-ai migration). Future harness
# migration PRs (5-8) should add their integration-test paths to this matrix.
live-matrix:
runs-on: ubuntu-latest
if: false # enabled once the first harness's test agents land
strategy:
matrix:
channel: [sync, async, temporal]
fail-fast: false
name: pydantic-ai-${{ matrix.channel }}
steps:
- run: echo "populated by migration PRs" # TODO(harness-migration): enable per-harness; see migration PRs 4-8
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2
with:
version: '0.10.2'

- name: Bootstrap
run: ./scripts/bootstrap

- name: pydantic-ai ${{ matrix.channel }} integration tests (offline, TestModel)
run: |
./scripts/test tests/lib/core/harness/test_harness_pydantic_ai_${{ matrix.channel }}.py -v

Large diffs are not rendered by default.

43 changes: 43 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/.dockerignore
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/Dockerfile
Original file line number Diff line number Diff line change
@@ -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/harness_pydantic_ai/pyproject.toml /app/harness_pydantic_ai/pyproject.toml
COPY 00_sync/harness_pydantic_ai/README.md /app/harness_pydantic_ai/README.md

WORKDIR /app/harness_pydantic_ai

# Copy the project code
COPY 00_sync/harness_pydantic_ai/project /app/harness_pydantic_ai/project

# Copy the test files
COPY 00_sync/harness_pydantic_ai/tests /app/harness_pydantic_ai/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=s-harness-pydantic-ai

# Run the agent using uvicorn
CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"]
54 changes: 54 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Sync Pydantic AI Harness Test Agent

A minimal **synchronous** Pydantic AI agent that drives the **unified harness
surface** (`UnifiedEmitter.yield_turn` + `PydanticAITurn`) on the sync
(HTTP-yield) channel.

## Why this agent exists

The `00_sync/040_pydantic_ai` tutorial streams via the bare
`convert_pydantic_ai_to_agentex_events` converter and does **not** exercise the
unified `yield_turn` path. This harness test agent is the sync coverage for the
unified surface: it proves an agent author can wire the sync channel through
`UnifiedEmitter` and get automatic span derivation (tool spans nested under the
per-turn span) for free, exactly like the async/temporal channels.

## How it wires the unified surface

In `project/acp.py`:

```python
emitter = UnifiedEmitter(
task_id=task_id,
trace_id=task_id,
parent_span_id=turn_span.id if turn_span else None,
)
async with agent.run_stream_events(user_message) as stream:
turn = PydanticAITurn(stream, model=MODEL_NAME) # coalesce off: stream tool-call arg tokens
async for ev in emitter.yield_turn(turn):
yield ev
```

- `coalesce_tool_requests=False` (the default) preserves token-by-token
tool-call argument streaming on the sync channel.
- The `UnifiedEmitter` is constructed from the ACP/streaming context
(`task_id` + `trace_id` + `parent_span_id`) so tool spans nest under the
per-turn `AGENT_WORKFLOW` span automatically.

## Files

- `project/acp.py` — sync ACP handler using `emitter.yield_turn(...)`.
- `project/agent.py` — builds the `pydantic_ai.Agent` with one tool.
- `project/tools.py` — `get_weather(city)` returning a constant.
- `tests/test_agent.py` — live integration test (requires a running agent).

## Tools

- `get_weather(city: str) -> str`: returns a fixed "sunny and 72°F" string so a
run deterministically exercises text + a tool call + a tool response.

## Offline coverage

Offline integration tests for the same wiring (pydantic-ai `TestModel` + fake
streaming/tracing, no network) live in the SDK repo at
`tests/lib/core/harness/test_harness_pydantic_ai_sync.py`.
58 changes: 58 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
build:
context:
root: ../../
include_paths:
- 00_sync/harness_pydantic_ai
- test_utils
dockerfile: 00_sync/harness_pydantic_ai/Dockerfile
dockerignore: 00_sync/harness_pydantic_ai/.dockerignore

local_development:
agent:
port: 8000
host_address: host.docker.internal
paths:
acp: project/acp.py

agent:
acp_type: sync
name: s-harness-pydantic-ai
description: A sync Pydantic AI harness test agent using the unified emitter surface

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

deployment:
image:
repository: ""
tag: "latest"

global:
agent:
name: "s-harness-pydantic-ai"
description: "A sync Pydantic AI harness test agent using the unified emitter surface"
replicaCount: 1
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "1000m"
memory: "2Gi"
Empty file.
92 changes: 92 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/project/acp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""ACP handler for the sync harness Pydantic AI test agent.

This agent exercises the UNIFIED HARNESS SURFACE on the sync (HTTP-yield)
channel — ``UnifiedEmitter.yield_turn(PydanticAITurn(...))`` — rather than the
bare ``convert_pydantic_ai_to_agentex_events`` converter used by the
``040_pydantic_ai`` tutorial. The unified surface gives the sync channel the
same tracing (span derivation) the async/temporal channels get for free.

Flow:
1. Open a per-turn AGENT_WORKFLOW span via ``adk.tracing.span``.
2. Construct a ``UnifiedEmitter`` from the ACP/streaming context (task_id +
trace_id + parent_span_id) so tool spans nest under the turn span.
3. Wrap ``agent.run_stream_events(...)`` in a ``PydanticAITurn`` and forward
events with ``emitter.yield_turn(turn)`` — yielding each to the client.
"""

from __future__ import annotations

import os
from typing import AsyncGenerator

from dotenv import load_dotenv

load_dotenv()

import agentex.lib.adk as adk
from project.agent import MODEL_NAME, create_agent
from agentex.lib.types.acp import SendMessageParams
from agentex.lib.core.harness import UnifiedEmitter
from agentex.lib.types.tracing import SGPTracingProcessorConfig
from agentex.lib.utils.logging import make_logger
from agentex.lib.sdk.fastacp.fastacp import FastACP
from agentex.types.task_message_update import TaskMessageUpdate
from agentex.types.task_message_content import TaskMessageContent
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
from agentex.lib.core.tracing.tracing_processor_manager import add_tracing_processor_config

logger = make_logger(__name__)

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="sync")

_agent = None


def get_agent():
"""Get or create the Pydantic AI agent instance."""
global _agent
if _agent is None:
_agent = create_agent()
return _agent


@acp.on_message_send
async def handle_message_send(
params: SendMessageParams,
) -> TaskMessageContent | list[TaskMessageContent] | AsyncGenerator[TaskMessageUpdate, None]:
"""Handle incoming messages, streaming events through the unified surface."""
agent = get_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:
# Construct the UnifiedEmitter from the ACP/streaming context so tracing
# is automatic: tool spans nest under this turn's span.
emitter = UnifiedEmitter(
task_id=task_id,
trace_id=task_id,
parent_span_id=turn_span.id if turn_span else None,
)

async with agent.run_stream_events(user_message) as stream:
# PydanticAITurn preserves token-by-token tool-call argument
# streaming (Start+Delta+Done) on the sync/HTTP channel.
turn = PydanticAITurn(stream, model=MODEL_NAME)
async for ev in emitter.yield_turn(turn):
yield ev
39 changes: 39 additions & 0 deletions examples/tutorials/00_sync/harness_pydantic_ai/project/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Pydantic AI agent definition for the sync harness test agent.

The Agent is the boundary between this module and the API layer (acp.py).
Pydantic AI handles its own tool-call loop internally — no graph required.
"""

from __future__ import annotations

from datetime import datetime

from pydantic_ai import Agent

from project.tools import get_weather

__all__ = ["create_agent", "MODEL_NAME"]

MODEL_NAME = "openai:gpt-4o-mini"
SYSTEM_PROMPT = """You are a helpful AI assistant with access to tools.

Current date and time: {timestamp}

Guidelines:
- Be concise and helpful
- Use tools when they would help answer the user's question
- If you're unsure, ask clarifying questions
- Always provide accurate information
"""


def create_agent() -> Agent:
"""Build and return the Pydantic AI agent with tools registered."""
agent = Agent(
MODEL_NAME,
system_prompt=SYSTEM_PROMPT.format(timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
)

agent.tool_plain(get_weather)

return agent
Loading
Loading