Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ updates:
- "/src/agent_env/agents/agents/openhands"
- "/src/agent_env/agents/agents/pi"
- "/src/agent_env/agents/agents/proxy"
- "/src/agent_env/agents/agents/pydantic-ai"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
Expand Down
5 changes: 4 additions & 1 deletion .github/scripts/infra_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"agentenv-openhands": "src/agent_env/agents/agents/openhands/Dockerfile",
"agentenv-pi": "src/agent_env/agents/agents/pi/Dockerfile",
"agentenv-proxy": "src/agent_env/agents/agents/proxy/Dockerfile",
"agentenv-pydantic-ai": "src/agent_env/agents/agents/pydantic-ai/Dockerfile",
}
_TAG = re.compile(r"^src-[0-9a-f]{64}$")
_DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$")
Expand All @@ -56,7 +57,8 @@

# Each image's check, beyond its build: the gateway imports its server, Postgres turns healthy, the sidecars and the
# browser answer their own commands, Claude Code's, Codex's, Gemini's, OpenCode's and pi's agents run their CLIs and
# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, and the proxy imports its server.
# import their servers, OpenHands' imports its server, which loads the OpenHands SDK, Pydantic AI's imports its
# server, which loads Pydantic AI, and the proxy imports its server.
_SMOKES = {
"agentenv-gateway": ["docker", "run", "--rm", "--entrypoint", "python", "{image}", "-c", "import gateway.entrypoint"],
"agentenv-db-web": ["docker", "run", "--rm", "--entrypoint", "/usr/bin/pgweb", "{image}", "--version"],
Expand All @@ -76,6 +78,7 @@
"agentenv-pi": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c",
'pi --version && python3 -c "import a2a_server"'],
"agentenv-proxy": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'python -c "import a2a_server"'],
"agentenv-pydantic-ai": ["docker", "run", "--rm", "--entrypoint", "sh", "{image}", "-c", 'python -c "import a2a_server"'],
}


Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ the previous release, so a clear pull request title is also the release note. A

A release also publishes the infra images agent-env ships, once the `PUBLISH_INFRA_IMAGES` repository variable is
`true`: one per Dockerfile under `src/agent_env/env`, and one per shipped agent (`agentenv-claude-code`, its CLI
included, `agentenv-codex`, `agentenv-gemini`, `agentenv-opencode`, `agentenv-pi` and `agentenv-openhands`), at `ghcr.io/scaleapi/agentenv-<name>:src-<hex>` for linux/amd64
included, `agentenv-codex`, `agentenv-gemini`, `agentenv-opencode`, `agentenv-pi`, `agentenv-openhands` and `agentenv-pydantic-ai`), at `ghcr.io/scaleapi/agentenv-<name>:src-<hex>` for linux/amd64
and linux/arm64, where the tag is the build context's source digest, so an unchanged Dockerfile is not built again.
The agents' images install the newest `agentenv-framework-protocol` at or above the floor in their
`requirements.txt`, which a release doesn't pin, so each is built again only when its folder changes: once the protocol
Expand Down
18 changes: 14 additions & 4 deletions src/agent_env/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,20 @@ calls the `[model]` endpoint through LiteLLM and takes its model from the prompt
`[model] default` (or the `agent` role) set. It declares `install/v1` too, for Debian or Ubuntu targets. Its folder's
`README.md` covers the extensions it implements.

A release publishes Codex's, Gemini's, OpenCode's, pi's and OpenHands' images too, at
`agents/pydantic-ai/` runs a [Pydantic AI](https://ai.pydantic.dev) agent with the
[Pydantic AI Harness](https://pydantic.dev/docs/ai/harness/) `Coder` tools (shell, file tools, sub-agents) behind the
protocol's A2A agent framework, in process rather than a CLI: each turn is one run of the agent continuing the
context's persisted message history, the task env's MCP servers are its toolsets, and its messages are the turn's
trajectory. Name it as `pydantic-ai`, its alias, or by its id, `@local/agentenv-framework/agents/pydantic-ai`. It calls
the `[model]` endpoint as an OpenAI-compatible one, `<base>/chat/completions`, and takes its model from the prompt, so
its smoke task needs `[model] default` (or the `agent` role) set; a `vllm:` prefix, as in `vllm:qwen3.8-27b`, fits its
requests to the model's family as a vLLM server needs. It declares `install/v1` too, for Debian or Ubuntu targets. Its
folder's `README.md` covers the extensions it implements.

A release publishes Codex's, Gemini's, OpenCode's, pi's, OpenHands' and Pydantic AI's images too, at
`ghcr.io/scaleapi/agentenv-codex:src-<hex>`, `ghcr.io/scaleapi/agentenv-gemini:src-<hex>`,
`ghcr.io/scaleapi/agentenv-opencode:src-<hex>`, `ghcr.io/scaleapi/agentenv-pi:src-<hex>` and
`ghcr.io/scaleapi/agentenv-openhands:src-<hex>`, each built again only when its folder changes, and a run deploys
`ghcr.io/scaleapi/agentenv-opencode:src-<hex>`, `ghcr.io/scaleapi/agentenv-pi:src-<hex>`,
`ghcr.io/scaleapi/agentenv-openhands:src-<hex>` and `ghcr.io/scaleapi/agentenv-pydantic-ai:src-<hex>`, each built again only when its folder changes, and a run deploys
them as it does Claude Code's: it pulls the image
published for the sources installed, by its digest, and builds it from the agent's folder only when it can't.

Expand All @@ -88,4 +98,4 @@ others'. It has no alias and no smoke task: with nobody driving it, a prompt onl

`agent-env run agents` runs every agent's smoke task, pulling or building each image; `--task claude-code-smoke` runs
Claude Code's alone, `--task codex-smoke` Codex's, `--task gemini-smoke` Gemini's, `--task opencode-smoke` OpenCode's,
`--task pi-smoke` pi's, `--task openhands-smoke` OpenHands'.
`--task pi-smoke` pi's, `--task openhands-smoke` OpenHands', `--task pydantic-ai-smoke` Pydantic AI's.
13 changes: 13 additions & 0 deletions src/agent_env/agents/agents/pydantic-ai/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# docker build -t pydantic-ai-a2a src/agent_env/agents/agents/pydantic-ai
FROM mirror.gcr.io/library/ubuntu:24.04@sha256:534baea6a22c03a63003dbc8dbe78fe34bc0d7e595d9a9dc9834884ff530eb55
WORKDIR /app
# Named sources: agent-env packs the build context install/v1 copies from the COPY sources.
COPY a2a_server.py install.sh pydantic_ai_agent.py pydantic_ai_runtime.py requirements.txt start.sh ./
RUN sh install.sh \
&& useradd --create-home agent \
&& mkdir /workspace \
&& chown agent /app /workspace
ENV PATH="/opt/pydantic-ai-a2a-venv/bin:${PATH}"
USER agent
EXPOSE 8000
CMD ["python", "a2a_server.py"]
70 changes: 70 additions & 0 deletions src/agent_env/agents/agents/pydantic-ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Pydantic AI A2A agent

A [Pydantic AI](https://ai.pydantic.dev) agent wrapped with the `agentenv_protocol.a2a_agent` framework, shipped in
the `agents` bundle as `@local/agentenv-framework/agents/pydantic-ai`. This directory is the whole build context: the
protocol package, Pydantic AI and the [Pydantic AI Harness](https://pydantic.dev/docs/ai/harness/) come from PyPI
(`requirements.txt`), and `install.sh` installs them in a Python of uv's own, with git for the agent's shell, both for
the image and for `install/v1`.

Instead of wrapping a CLI, the agent runs Pydantic AI in process. `pydantic_ai_agent.py` is the A2A side and imports
no Pydantic AI; `pydantic_ai_runtime.py` runs the agent. Each task is one run of an `Agent` with the harness's `Coder`
capability, the coding tools CLAI is built on (`shell`, `read_file`, `write_file`, `edit_file`, `list_files`, `grep`,
`delegate_task`), working in a `LocalWorkspace` on the workspace. Its shell commands get only `PATH`, `HOME` and the
locale from the agent's environment, never the model key, and the harness keeps their logs under
`.pydantic-ai-harness/` in the workspace, with a `.gitignore` of its own. The agent runs in a sandbox of its own, so the file tools
are not confined to the workspace and reach the files attached outside it. Pydantic AI's own A2A server
(`Agent.to_a2a`) is not used: it speaks plain A2A, while agent-env needs the protocol's extensions, which the framework
serves.

The model is called through the `[model]` endpoint as an OpenAI-compatible one, `<base>/chat/completions`, with
`LITELLM_EXTRA_HEADERS` sent on every call but kept out of the persisted messages, which a snapshot uploads. The model
name is passed through as is; a `vllm:` prefix, as in `vllm:qwen3.8-27b`, picks Pydantic AI's vLLM provider, which
fits the requests to the model's family (Qwen, DeepSeek, GLM, Llama, ...) as a vLLM server needs, and `openai:` names
the default.

A context maps to one conversation, whose id is derived from the context id, its message history persisted in
`~/.pydantic-ai-a2a/conversations/<id>/messages.json` once a turn succeeds, so follow-up tasks in the context continue
it, across restarts too. Images are sent to the model as images; every file part is also saved in the conversation's
directory under `inputs/<task id>/` and its path added to the prompt, for the agent's tools, and the conversation's
snapshots carry it.

| Extension | Implementation |
|---|---|
| `agent-config/v1` | `PydanticAIConfig`: `model`, `effort` (Pydantic AI's `thinking` setting), `system_prompt` (added to the agent's instructions), `request_limit` (model requests per turn, default 500), `timeout_seconds` |
| `mcp-config/v1` | SDK default; each server becomes an `MCPToolset` on its URL, with its headers, its tools named as the server names them |
| `skill-config/v1` | inline and bundle skills installed as `SKILL.md` directories under `~/.pydantic-ai-a2a/skills`, offered to the model through the harness's `Skills`, which it loads with `load_capability` |
| `trajectory/v1` | live: the task's Pydantic AI messages (`ModelMessagesTypeAdapter`, format `pydantic-ai-messages/v1`), appended as each request and response completes |
| `snapshot/v1` | `save` uploads the context's conversation directory, its messages and attached files, as a gzipped tar and, when asked, the workspace as another; `load` extracts both aside, validates the messages as Pydantic AI's and points the paths of the attached files at the target's directory before it replaces anything, then replaces the workspace's contents and installs the conversation under the target context's id, so an archive that fails to extract, or holds no conversation, changes nothing |
| `peer-agents/v1` | SDK default: the `peer_agents` MCP server reaches the agent like any other |
| `install/v1` | copies this directory into the task container as `/opt/pydantic-ai-a2a`, runs `install.sh` (Debian or Ubuntu, as root, with network access), writes the LiteLLM credentials and the lines that trust the local transfer CA to a 0600 `agent.env` through a heredoc and starts `start.sh`, which applies them |
| `triggers/v1` | SDK default |

The snapshot changelog (`enable_changelog`/`apply_changelog`) is not implemented, nor is `attribution-probe/v1`.

`PYDANTIC_AI_A2A_MODEL` sets the model when agent-config sets none, as for a peer that other agents message directly,
and `PYDANTIC_AI_A2A_WORKSPACE` the workspace (default `/workspace`, and the task container's working directory when
it's installed there). `a2a_server.py` reads these and the `LITELLM_*` variables once, at startup: a
`LITELLM_EXTRA_HEADERS` that is not a JSON object stops the agent there, while a missing `LITELLM_BASE_URL` fails each
task as `pydantic_ai.no_endpoint`.

Snapshot `load` answers 409 while a task is running, and 400 for an archive that holds no conversation. A snapshot
`save` waits for its context's running task to end; one that saves the workspace also waits for the tasks running in
other contexts, which work in the same workspace.

Usage is the run's: input, output and cached tokens, model requests and tool calls; no cost. A failed run's error code
says whose failure it was: `pydantic_ai.model_http_<status>` and `pydantic_ai.model_api` for the model endpoint and
`pydantic_ai.error` for anything else that stopped the run, such as an MCP server that can't be reached, are
`infra_error`s; `pydantic_ai.usage_limit` (the request limit) and `pydantic_ai.unexpected_model_behavior` are
`agent_error`s, and `pydantic_ai.history`, a persisted conversation that can't be read, an `infra_error`. A failed
turn leaves the conversation as it was. A timeout (`pydantic_ai.timeout`) counts reading the
task's file parts too and reports what the run had spent. A timeout, a `tasks/cancel` and the server shutting down each
cancel the run, which kills the commands its shell started. Errors are answered as `{"detail": ...}`.

```bash
docker build -t pydantic-ai-a2a src/agent_env/agents/agents/pydantic-ai
docker run -p 8000:8000 -e LITELLM_BASE_URL=... -e LITELLM_API_KEY=... pydantic-ai-a2a
pytest tst/unit/agents/pydantic_ai
```

Pydantic AI's version is pinned in `requirements.txt` and `PYDANTIC_AI_VERSION`, and the harness's beside it; a new
release is a pull request that bumps them.
24 changes: 24 additions & 0 deletions src/agent_env/agents/agents/pydantic-ai/a2a_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Serves the Pydantic AI agent, configured from its environment. ``AGENT_CARD`` is what agent-env's install/v1 reads
from ``/app/a2a_server.py``."""

import os
from pathlib import Path
from typing import Final

from a2a.types import AgentCard
from starlette.applications import Starlette

from pydantic_ai_agent import DEFAULT_MODEL_ENV, Endpoint, PydanticAIAgent
from pydantic_ai_runtime import PydanticAIRuntime

agent: Final[PydanticAIAgent] = PydanticAIAgent(
PydanticAIRuntime(),
endpoint=Endpoint.from_environ(os.environ),
default_model=os.environ.get(DEFAULT_MODEL_ENV),
workspace=Path(os.environ.get("PYDANTIC_AI_A2A_WORKSPACE", "/workspace")),
)
app: Final[Starlette] = agent.create_app()
AGENT_CARD: Final[AgentCard] = app.state.agentenv_a2a.card

if __name__ == "__main__":
agent.serve()
32 changes: 32 additions & 0 deletions src/agent_env/agents/agents/pydantic-ai/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/bin/sh
# Installs git for the agent's shell and this agent's Python environment, in uv's own Python so the target's is never
# used, on Debian or Ubuntu, as root. The image build and install/v1 both run it, so an install into a task container
# matches the image.
set -eu
UV_VERSION=0.13.1
UV_SHA256_X86_64=6c86040a8f045ab9adc9e3ec4cdafa03fd00e9a988ed2bf28971c7fc2d0debd0
UV_SHA256_AARCH64=345a8308e4d1835cdfaadf692836fd3137a6d45864773f05ad7bf59442b1ba16
PYTHON_VERSION=3.12
here=$(cd "$(dirname "$0")" && pwd)

export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl git
rm -rf /var/lib/apt/lists/*

case "$(uname -m)" in
x86_64) arch=x86_64 sha256=$UV_SHA256_X86_64 ;;
aarch64 | arm64) arch=aarch64 sha256=$UV_SHA256_AARCH64 ;;
*) echo "unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
stage=$(mktemp -d)
trap 'rm -rf "$stage"' EXIT
archive=uv-$arch-unknown-linux-musl.tar.gz
curl -fsSL --retry 3 "https://github.com/astral-sh/uv/releases/download/$UV_VERSION/$archive" -o "$stage/$archive"
echo "$sha256 $stage/$archive" | sha256sum -c -
tar -xzf "$stage/$archive" -C "$stage"
uv="$stage/uv-$arch-unknown-linux-musl/uv"

export UV_PYTHON_INSTALL_DIR=/opt/pydantic-ai-a2a-python UV_PYTHON_PREFERENCE=only-managed UV_CACHE_DIR="$stage/cache"
"$uv" venv --quiet --clear --python "$PYTHON_VERSION" /opt/pydantic-ai-a2a-venv
"$uv" pip install --quiet --python /opt/pydantic-ai-a2a-venv/bin/python -r "$here/requirements.txt"
Loading