10. Pydantic AI Agents
Goal: wrap a Pydantic AI agent as an LLLM tactic, keep the runtime features Pydantic AI owns, stream it, serve it over HTTP, package it, and hand an LLLM tactic back to Pydantic AI as a tool.
PydanticAITactic puts a typed tactic boundary around a Pydantic AI agent. The
agent keeps owning models, tools, dependencies, model settings, streaming, and
instrumentation; the tactic adds validated Pydantic input and output, a
published contract, and the same run/stream/serve/package surface every
other tactic has.
Steps 1 to 9 use the offline stand-in agents in examples/pydantic_ai_tactic/.
They implement the parts of the Pydantic AI Agent interface the adapter calls
(run_sync, run_stream_sync, output_json_schema), so everything runs
without provider keys. Step 10 swaps in a real pydantic_ai.Agent driven by
Pydantic AI's offline TestModel.
Prerequisites
A checkout of the LLLM repository, with the development and Pydantic AI extras:
python -m pip install -e ".[dev,pydantic-ai]"
Run the snippets from the repository root so examples. imports resolve.
9. Serving and Composition covers serving in general;
Runtimes is the reference for
everything here.
Files used:
examples/pydantic_ai_tactic/
structured_agent.py BriefInput, BriefOutput, FakeStructuredAgent, build_tactic
surrounding_features.py ConfiguredAgent, run_demo
tests/
test_pydantic_ai_adapter.py
test_examples.py
Step 1: define typed I/O
The tactic's public contract is two Pydantic models. These are defined in
examples/pydantic_ai_tactic/structured_agent.py:
from pydantic import BaseModel
class BriefInput(BaseModel):
topic: str
audience: str = "engineers"
class BriefOutput(BaseModel):
title: str
bullets: list[str]
trace_id: str | None = None
They become the tactic's JSON schemas and are checked on every call. They
describe the tactic's contract; a real Pydantic AI agent still validates the
model's output against its own output_type.
Import them from the example module rather than redefining them. The tactic
checks the agent's output against the exact BriefOutput class, and the
stand-in agent returns the module's class:
from examples.pydantic_ai_tactic.structured_agent import BriefInput, BriefOutput
Step 2: wrap the agent
from lllm import PydanticAITactic
def build_tactic(agent) -> PydanticAITactic:
return PydanticAITactic(
agent,
name="brief-writer",
input_type=BriefInput,
output_type=BriefOutput,
description="Write a short structured brief.",
run_kwargs={"model_settings": {"temperature": 0}},
)
run_kwargs are copied into every agent run. Use them for options Pydantic AI
owns (model_settings, deps, usage_limits, and so on) instead of turning
them into tactic fields. Pass description= too: it is what /info, lllm
inspect, and package cards show.
Step 3: run it locally
from lllm import CallContext
from examples.pydantic_ai_tactic.structured_agent import FakeStructuredAgent
agent = FakeStructuredAgent()
tactic = build_tactic(agent)
output = tactic.run(
{"topic": "package refs", "audience": "robotics engineers"},
context=CallContext(trace_id="trace-brief", metadata={"caller": "tutorial"}),
)
print(repr(output))
print(agent.last_metadata)
assert output.title == "Package Refs for robotics engineers"
assert output.trace_id == "trace-brief"
BriefOutput(title='Package Refs for robotics engineers', bullets=['Define package refs', 'Make it useful for robotics engineers'], trace_id='trace-brief')
{'caller': 'tutorial', 'lllm_request_id': 'c86ee4e7-2e8a-4f6e-8d47-aaa31a7e4c91', 'lllm_trace_id': 'trace-brief'}
The input arrived as a dict, was validated into BriefInput, and reached the
agent as JSON text. Because the agent's run_sync accepts metadata, the
adapter forwarded the context's metadata with lllm_request_id and
lllm_trace_id added; the stand-in agent copies the trace id into its output.
An explicit metadata= argument to run() is never overwritten.
Step 4: choose input and output modes
The default input_mode="auto" sends a model input as JSON text. Pick another
mode when your agent expects a different shape:
dict_tactic = PydanticAITactic(
agent,
input_type=BriefInput,
output_type=BriefOutput,
input_mode="dict",
)
dict_tactic.run({"topic": "modes"})
print(agent.last_task)
{'topic': 'modes', 'audience': 'engineers'}
input_mode |
The agent receives |
|---|---|
auto, json |
The model as JSON text. |
dict |
model.model_dump(mode="json"). |
python |
The model instance. |
text |
str(value). |
By default the adapter returns result.output. result_mode="result" returns
the whole run result instead. The result is not a BriefOutput, so without an
output_type the output type becomes Any and the result is not validated:
result_tactic = PydanticAITactic(agent, input_type=BriefInput, result_mode="result")
result = result_tactic.run({"topic": "modes"})
print(type(result).__name__, result.output.title)
Result Modes for engineers
Use this mode in process only. A real agent's run result carries its private run state, such as the agent's instructions, which you would not want to serve (Runtimes).
Step 5: keep runtime-owned features
The surrounding-features example passes runtime-owned options through the tactic: model settings and deps, stand-ins for an eval hook and a tool-approval policy, and a durable run id and a graph node given at call time:
from examples.pydantic_ai_tactic.surrounding_features import run_demo
output, configured = run_demo()
print(output)
print(configured.seen["kwargs"])
assert output["durable_run_id"] == "durable-1"
assert output["graph_node"] == "plan.step"
assert configured.seen["instrumented"] is True
{'task': 'summarize package refs', 'model': 'fake-provider:small', 'instrumentation': ['logfire', 'opentelemetry'], 'trace_id': 'trace-surrounding', 'durable_run_id': 'durable-1', 'graph_node': 'plan.step'}
{'model_settings': {'temperature': 0}, 'deps': {'vector_store': 'fake'}, 'eval_hook': 'offline-score', 'tool_approval': 'runtime-owned', 'durable_run_id': 'durable-1', 'graph_node': 'plan.step'}
run_kwargs from the constructor and keyword arguments given to run() are
merged and handed to the agent unchanged. LLLM forwards them without
interpreting them, so with a real pydantic_ai.Agent use arguments its
run_sync() and run() accept, such as model_settings, deps, and
usage_limits.
Step 6: stream
An agent with run_stream_sync() or run_stream() makes the tactic stream:
print(tactic.info().capabilities)
print(list(tactic.stream(BriefInput(topic="refs"))))
('arun', 'run', 'stream')
['topic:refs', 'audience:engineers']
The streaming methods validate the input the way run() does, so
tactic.stream({"topic": "refs"}) gives the same chunks, with the default
audience filled in.
stream_mode picks the view of the stream result: "output" (the default)
reads stream_output(), "text" reads stream_text(), "response" reads
stream_response(), and "raw" yields the stream object itself:
raw_tactic = PydanticAITactic(agent, stream_mode="raw")
print(list(raw_tactic.stream(BriefInput(topic="refs"))))
[<examples.pydantic_ai_tactic.structured_agent.StreamResult object at 0x12ed39b28e00>]
When the agent has run_stream_events(), as a real pydantic_ai.Agent does,
aevents() wraps each of Pydantic AI's events in a TacticEvent whose kind
is the event's event_kind (part_start, ..., agent_run_result) and whose
data is JSON, so /stream sends plain JSON events.
Step 7: serve it
The example module has a factory, build_tactic(), that wraps a fresh
stand-in agent. Its one parameter has a default and it is annotated to return a
PydanticAITactic, so lllm serve calls it once at load instead of treating it
as a handler (Loading entrypoints).
Serve it:
lllm serve examples.pydantic_ai_tactic.structured_agent:build_tactic --port 8000
Call it with the same envelope every tactic uses:
curl -s -X POST http://127.0.0.1:8000/run \
-H 'content-type: application/json' \
-d '{"input":{"topic":"refs","audience":"engineers"},"context":{"trace_id":"trace-1"}}'
{"output":{"title":"Refs for engineers","bullets":["Define refs","Make it useful for engineers"],"trace_id":"trace-1"},"request_id":"8f69d704-bcd7-4203-ba75-4ce26a4129e0","tactic":"brief-writer"}
The trace_id traveled from the request's context into the agent's metadata
and back out in the output. /stream takes the same envelope and validates
its input too.
To test the app in-process, build it with create_tactic_app:
from fastapi.testclient import TestClient
from lllm import create_tactic_app
client = TestClient(create_tactic_app(tactic))
response = client.post(
"/run",
json={"input": {"topic": "refs"}, "context": {"trace_id": "trace-1"}},
)
print(response.json()["output"])
print(client.get("/info").json()["description"])
{'title': 'Refs for engineers', 'bullets': ['Define refs', 'Make it useful for engineers'], 'trace_id': 'trace-1'}
Write a short structured brief.
Step 8: package it
A package makes the tactic discoverable, servable by path, and publishable to a
hub. Create a folder brief-package/ with a factory module and a manifest.
brief-package/brief_tactic.py:
from examples.pydantic_ai_tactic.structured_agent import (
BriefInput,
BriefOutput,
FakeStructuredAgent,
)
from lllm import PydanticAITactic
def create_tactic() -> PydanticAITactic:
return PydanticAITactic(
FakeStructuredAgent(),
name="brief-writer",
input_type=BriefInput,
output_type=BriefOutput,
description="Write a short structured brief.",
)
brief-package/lllm.toml:
[package]
org = "demo"
name = "brief"
version = "0.1.0"
primary = "tactics.brief"
description = "Structured briefs from a Pydantic AI agent."
[schemas.brief_input]
entry = "examples.pydantic_ai_tactic.structured_agent:BriefInput"
[schemas.brief_output]
entry = "examples.pydantic_ai_tactic.structured_agent:BriefOutput"
[tactics.brief]
entry = "brief_tactic:create_tactic"
input = "brief_input"
output = "brief_output"
runtime = "pydantic-ai"
description = "Write a short structured brief."
Validate it and see what lllm serve would launch:
lllm hub validate brief-package
lllm inspect brief-package
warning: readme_missing: Package should include README.md.
warning: card_metadata_missing: Package should declare [card] metadata for generated cards.
ok
demo/brief:tactics.brief (tactic)
port: 8000
GET /health
GET /info
GET /tactics
GET /tactics/{name}/info
GET,HEAD /docs
GET,HEAD /docs/oauth2-redirect
GET,HEAD /openapi.json
GET,HEAD /redoc
POST /run
POST /stream
POST /tactics/{name}/run
POST /tactics/{name}/stream
The two warnings are about the hub card; add a README.md and a [card] table
before publishing. lllm serve brief-package now serves the primary tactic.
A package around a live agent declares its provider key, and lllm serve
checks for it before starting:
[requirements.api_keys]
OPENAI_API_KEY = "Model access for the brief writer."
Keys belong in the environment, .env.local, or the OS keyring
(lllm setup brief-package), never in the manifest.
Step 9: expose a tactic as a tool
tactic_as_tool turns any tactic into a plain, typed function:
import inspect
from lllm import tactic_as_tool
tool = tactic_as_tool(tactic, name="write_brief", parameter_mode="kwargs")
print(inspect.signature(tool))
print(repr(tool(topic="refs", audience="engineers")))
(*, topic: str, audience: str = 'engineers') -> examples.pydantic_ai_tactic.structured_agent.BriefOutput
BriefOutput(title='Refs for engineers', bullets=['Define refs', 'Make it useful for engineers'], trace_id=None)
With parameter_mode="kwargs" the function takes one keyword per input field;
the default "task" mode takes the whole input as one task argument. Native
tactics have the same helper as a method, tactic.as_tool(). The function's
annotations match its signature, and Pydantic AI reads them to build the tool
schema, so the function registers with a Pydantic AI agent as it is (Step 10).
Step 10: use a real Pydantic AI agent
build_tactic works unchanged with a real pydantic_ai.Agent. TestModel
keeps it offline by filling the output with placeholder values:
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel
real_agent = Agent(TestModel(), output_type=BriefOutput, name="brief-writer")
real_tactic = build_tactic(real_agent)
print(repr(real_tactic.run({"topic": "package refs"})))
print(real_tactic.info().capabilities)
BriefOutput(title='a', bullets=['a'], trace_id=None)
('arun', 'events', 'run', 'stream')
For live calls, give the agent a model name such as
Agent("openai:gpt-4o-mini", output_type=BriefOutput) and set
OPENAI_API_KEY.
Going the other way, a Pydantic AI agent can call an LLLM tactic as a tool. Register the function from Step 9. Pydantic AI derives the tool schema from its annotations and validates the model's arguments against it:
planner = Agent(TestModel(), tools=[tool])
print(planner.run_sync("Plan a talk about package refs.").output)
{"write_brief":{"title":"A for engineers","bullets":["Define a","Make it useful for engineers"],"trace_id":null}}
TestModel called the tool with topic="a", and the tactic filled in the
default audience. The same call hands any tactic to a Pydantic AI agent: a
native multi-agent program, a plain function, or a RemoteTactic.
The tool is synchronous and calls tactic.run(), and Pydantic AI does not
allow Agent.run_sync() inside a synchronous tool. For a tactic around a real
pydantic_ai.Agent, such as real_tactic, write an async tool that awaits
arun():
async def write_brief(request: BriefInput) -> BriefOutput:
"""Write a short structured brief about a topic."""
return await real_tactic.arun(request)
planner = Agent(TestModel(), tools=[write_brief])
print(planner.run_sync("Plan a talk about package refs.").output)
{"write_brief":{"title":"a","bullets":["a"],"trace_id":null}}
Verify
python -m pytest tests/test_pydantic_ai_adapter.py tests/test_examples.py
........................................................................ [ 96%]
... [100%]
75 passed in 0.54s
Summary
| Concept | API |
|---|---|
| Wrap an agent | PydanticAITactic(agent, input_type=..., output_type=..., description=...) |
| Runtime options | run_kwargs={...} and keyword arguments to run() |
| Context | run(..., context=CallContext(...)) reaches the agent as metadata |
| Input shape | input_mode="auto", "json", "dict", "python", or "text" |
| Full run result | result_mode="result", in process only |
| Stream | tactic.stream(input), stream_mode=... |
| Serve | lllm serve module:factory, create_tactic_app(tactic) |
| Package | [tactics.NAME] with entry and runtime = "pydantic-ai" |
| Tactic as a tool | tactic_as_tool(tactic); an async tool over arun() for a tactic around a real agent |
See Runtimes for the full adapter reference and Services for the HTTP contract.
Next: 11. Channels with SSSN