Pydantic AI¶
Integrate Agent Assembly with Pydantic AI to enforce governance policy on tool calls before they execute — driven fully offline.
What this example demonstrates¶
- Initializing Agent Assembly with
init_assembly()in offlinesdk-onlymode. - Installing tool-level governance hooks with
PydanticAIAdapter, which patchespydantic_ai.tools.Tool._run. - Driving a real Pydantic AI
Agentwith the built-inTestModel, so the demo runs offline with no API key. - Running an allowed tool call (
get_weather), a denied tool call (delete_records), and a pending tool call (send_email— requires approval, auto-denied offline). - How
PolicyViolationErroris raised when a tool is blocked or rejected during approval.
The framework / library¶
This example builds on Pydantic AI, the agent framework from the Pydantic team.
The Agent Assembly Pydantic AI adapter hooks the internal Tool._run entry point, which exists in the Pydantic AI 0.1.x–0.2.x line. Accordingly, pyproject.toml pins:
dependencies = [
"agent-assembly>=0.0.1a2",
# The Agent Assembly Pydantic AI adapter patches `Tool._run`, which is
# present in the 0.1.x–0.2.x line. Newer 1.x releases renamed that internal
# entry point; pin to the supported range so governance hooks attach.
"pydantic-ai>=0.1.0,<0.3.0",
]
Newer Pydantic AI 1.x releases renamed that internal API, so the governance hooks would not attach there. The example requires Python >= 3.12 and the Agent Assembly Python SDK >= 0.0.1a2.
How it works¶
init_assembly() runs in sdk-only mode and, in production, auto-detects Pydantic AI and registers the adapter automatically. The PydanticAIAdapter patches pydantic_ai.tools.Tool._run, so every tool invocation is routed through a governance check before the tool body runs.
In src/main.py, the adapter is registered before init_assembly(). Because the patch is idempotent, registering first makes init_assembly's auto-detection a no-op and keeps the offline LocalPolicyEngine wired as the governance interceptor:
adapter = PydanticAIAdapter()
adapter.set_process_agent_id("pydantic-ai-demo-agent")
adapter.register_hooks(LocalPolicyEngine())
with init_assembly(
gateway_url=gateway_url,
api_key=api_key,
agent_id="pydantic-ai-demo-agent",
mode="sdk-only",
) as ctx:
...
In src/agent.py, the agent is built with TestModel, told which tool to call, so it deterministically exercises the allow / deny / pending paths offline. In src/policy.py, LocalPolicyEngine.check_tool_start returns a decision dict in the gateway wire format ({"status": "allow" | "deny" | "pending"}). A deny, or an unapproved pending resolved by wait_for_tool_approval, raises PolicyViolationError before the tool runs.
The policy rules are:
- DENY —
delete_records,write_file(destructive operations) - PENDING —
send_email(requires human approval; auto-denied offline since no approver is available) - ALLOW — everything else
Prerequisites & running it¶
See Preparing the runtime environment for the shared prerequisites.
This demo runs fully offline — no running Agent Assembly gateway and no LLM API key are required.
Code walkthrough¶
The agent is wired with TestModel and told which tool to call, so each run deterministically triggers one tool (src/agent.py):
def build_agent(call_tool: str) -> Any:
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
agent = Agent(TestModel(call_tools=[call_tool]))
@agent.tool_plain
def get_weather(city: str) -> str:
"""Get the current weather for a city (safe — allowed by policy)."""
return f"Weather in {city}: 22C, partly cloudy (mock response)"
@agent.tool_plain
def delete_records(path: str) -> str:
"""Delete records at a path (destructive — denied by policy)."""
return f"Deleted records at {path}"
@agent.tool_plain
def send_email(to: str) -> str:
"""Send an email (requires approval — pending then denied offline)."""
return f"Email sent to {to}"
return agent
The local policy engine simulates gateway enforcement, returning decisions in the gateway wire format (src/policy.py):
class LocalPolicyEngine:
async def check_tool_start(self, **kwargs: Any) -> dict[str, str]:
tool_name = str(kwargs.get("tool_name", ""))
if tool_name in DENIED_TOOLS:
return {
"status": "deny",
"reason": (
f"Tool '{tool_name}' is blocked by policy rule "
"'deny_destructive_operations'."
),
}
if tool_name in PENDING_TOOLS:
return {
"status": "pending",
"reason": f"Tool '{tool_name}' requires human approval before execution.",
}
return {"status": "allow"}
Pending tools route to wait_for_tool_approval, which denies them offline because no approver is available (src/policy.py):
async def wait_for_tool_approval(self, **kwargs: Any) -> dict[str, str]:
"""Offline mode: no approver is available, so pending tools are denied."""
tool_name = str(kwargs.get("tool_name", ""))
return {
"status": "deny",
"reason": (
f"Tool '{tool_name}' requires approval, but no approver is available "
"in offline mode."
),
}
Notes & caveats¶
Pydantic AI version pin
The adapter hooks the internal Tool._run entry point, which exists in the Pydantic AI 0.1.x–0.2.x line. pyproject.toml pins pydantic-ai>=0.1.0,<0.3.0 so the governance hooks attach. Newer 1.x releases renamed that internal API. If governance hooks do not fire, ensure pydantic-ai resolves to the pinned 0.1.x–0.2.x range.
Offline TestModel
The agent is driven by Pydantic AI's built-in TestModel, so the demo runs deterministically with no API key and no network. No running Agent Assembly gateway is required for the offline demo.
Troubleshooting
ModuleNotFoundError: agent_assembly→ runuv syncfirst.ModuleNotFoundError: pydantic_ai→ runuv sync;pydantic-aiis a required dependency.- Governance hooks do not fire → ensure
pydantic-airesolves to the pinned0.1.x–0.2.xrange. PolicyViolationErrorin tests → expected; the deny/pending policy rules are intentional.
To move to production mode: start an Agent Assembly gateway (or use your SaaS workspace URL), copy .env.example to .env and fill in credentials, swap TestModel for a real model (e.g. openai:gpt-4o) and set OPENAI_API_KEY, then run with the gateway environment variables:
AGENT_ASSEMBLY_GATEWAY_URL=http://localhost:8080 \
AGENT_ASSEMBLY_API_KEY=your-key \
uv run python src/main.py
In production, init_assembly() auto-detects Pydantic AI and registers the adapter automatically, and the gateway enforces the policy rules — replacing LocalPolicyEngine with the gateway-backed interceptor.
Expected behavior¶
==============================================================
Agent Assembly — Pydantic AI Governed Agent Demo
==============================================================
Initializing Agent Assembly (gateway: http://localhost:8080, sdk-only mode)...
Agent: pydantic-ai-demo-agent
Gateway: http://localhost:8080
Mode: sdk-only (offline demo)
Policy rules (local simulation of gateway policy):
DENY — delete_records, write_file (destructive operations)
PENDING — send_email (requires human approval)
ALLOW — everything else
Running governed tool calls (driven offline by TestModel):
--------------------------------------------
→ agent run that calls get_weather
✅ ALLOWED — get_weather executed (mock response)
→ agent run that calls delete_records
❌ BLOCKED — Tool 'delete_records' blocked by governance policy: Tool 'delete_records' is blocked by policy rule 'deny_destructive_operations'.
→ agent run that calls send_email
❌ BLOCKED — Tool 'send_email' rejected during approval: Tool 'send_email' requires approval, but no approver is available in offline mode.
Assembly context shut down.