Pydantic AI
humanbound-firewall ships a Pydantic AI adapter in 0.3.5 and above: a Pydantic AI capability (the framework's middleware) that attaches the firewall to every boundary a Pydantic AI agent exposes, calls the firewall's gateway on each, enforces the decision, and keeps the session per conversation. Nothing is configured per tool: the policy file and from_config() decide.
Requirements
The adapter is available in humanbound-firewall 0.3.5 and above (pip install "humanbound-firewall>=0.3.5"), with Pydantic AI 2.x, which needs Python 3.11 or later. Pydantic AI is your application's dependency; the firewall has no [pydantic-ai] extra.
Two lines
from pydantic_ai import Agent
from humanbound_firewall import Firewall
firewall = Firewall.from_config("agent.yaml")
agent = Agent(model, tools=tools, capabilities=[firewall.adapt_to("pydantic_ai", tools=tools)])
adapt_to() imports the adapter lazily and returns a fresh capability bound to this firewall. tools= is optional: it only feeds the inventory.
What it attaches
| Boundary | Hook | Trust class | On a block |
|---|---|---|---|
| The user's prompt | before_run, once per run |
request |
the run ends with the replacement text as the agent's reply; the model is never called |
| Every tool's return value | wrap_tool_execute |
ingest, or recall when the policy lists the tool under tools.recall |
the result is replaced by the withheld notice before the model reads it |
| The start of the run | for_run / before_run |
— | the conversation's session is read (or created), the run counter advances, the user's request is pinned |
| The end of the run | after_run |
— | verdicts judged in the background are written back to the conversation's session |
Whatever a tool returns is screened: a string, a dict or a Pydantic model (as JSON), a ToolReturn (its return value and its content), or a list of user content. Images, audio and video carry no text and are skipped; a text file (BinaryContent with a text media type) is read as text; raw file bytes are never sent to the judge. A result with no text at all is passed on untouched.
A sub-agent called from a tool is a tool result and is judged as ingest under this agent's policy. The firewall call, which is blocking I/O, runs off the event loop: the run still waits for the verdict, but other tasks keep running.
The screening call gives the firewall the tool result, a window of recent messages (the system prompt is never included), the session, and the boundary: the tool's name and kind, its own description, the policy's expects, the arguments the model called it with, and the tool_call_id. The ingest judge uses those facts generically — see Tier 3.
Structured output
An agent with a structured output_type cannot take the replacement text as its reply. A rejected prompt then ends the run with Pydantic AI's output error (UnexpectedModelBehavior) instead; the model is still never called.
The session, per conversation
Pydantic AI gives every run a conversation_id: the one you pass, the one on the message_history, or a new one. The adapter keeps one session per conversation id:
capability = firewall.adapt_to("pydantic_ai", tools=tools)
agent = Agent(model, tools=tools, capabilities=[capability])
first = agent.run_sync("Check the competitor price")
second = agent.run_sync("And the bundle?", message_history=first.all_messages())
capability.session(second.conversation_id)
# {"v": 1, "run": 2, "pinned_request": "And the bundle?",
# "counts": {"evaluations": 7, "blocks": 1, "escalations": 0,
# "by_class": {"request": 2, "ingest": 3, "recall": 2}, "by_category": {"restriction": 1}},
# "recent": [...], "posture": "elevated", "last_seen": "2026-10-28T10:41:03Z"}
So a block in one run elevates the posture for the next run of the same conversation. Each run works on its own copy of the adapter, so concurrent runs never share state; tool calls Pydantic AI runs in parallel each record into the run's session and are merged, so nothing is lost and the posture can only rise. The adapter keeps the 1024 most recent conversations (max_conversations=), in memory. The session holds counts, payload hashes, boundaries, the posture and the run's pinned request; no tool output and no record content.
The trust-boundary inventory
capability = firewall.adapt_to("pydantic_ai", tools=tools) # tools: only for the inventory
for b in capability.boundaries:
print(b)
print(capability.report())
Humanbound Firewall · PriceWatch · adapter=pydantic_ai · classes=request,ingest,recall
boundary kind hook class mode note
human turn request before_run request block
query_catalogue tools wrap_tool_execute recall block integrity mode (declared: recall); expects: our record of one SKU
check_our_stock tools wrap_tool_execute recall block integrity mode (declared: recall); expects: our live stock for one SKU
fetch_url tools wrap_tool_execute ingest block expects: a competitor's public web page
tools= takes functions, Tool objects or names; expects falls back to each tool's docstring. As with LangChain, the adapter warns about a policy entry naming a tool the agent does not have, and lists the capabilities it cannot attach (memory, inter_agent) with no hook.
The policy, for Pydantic AI tools
The policy is the same file as for any agent. A tool's name is its function name:
name: PriceWatch
capabilities: [tools]
tools:
recall: [query_catalogue, check_our_stock] # returns records WE authored → integrity check
expects: # defaults to the tool's docstring
fetch_url: "a competitor's public web page"
Without a tools: block every tool is ingest, the strict default.
Log mode judges in the background
The deployment's choices — classes, modes, fail, on_decision — are set in from_config(), as in the LangChain staged rollout. In log mode a verdict changes nothing the agent does, so the adapter does not wait for it: the tool result — or the user's prompt — goes on at once, and the same payload is judged on a background thread with the window, the boundary and the session captured at that moment.
- Order is kept. Each conversation has one lane; its judgements run one at a time in call order, each on the session the previous one produced. Different conversations are judged in parallel.
on_decisionfires when the verdict lands, possibly afterrun()has returned.d.boundary["tool_call_id"]ties a late verdict to the call that produced it.capability.flush(timeout)waits for every pending judgement and returnsFalseon timeout. Call it before reading the session in a test, and before a request handler returns if the process may exit.adapt_to("pydantic_ai", log_blocking=True)restores waiting.
Not attachable
Pydantic AI exposes no generic hook for these; the manual tier serves them:
- memory or store reads your application makes itself;
- documents your application retrieves and puts into the prompt;
- hand-offs between agents outside a tool call.
Call firewall.inspect(content, cls=..., session=..., boundary=...) where that content is read, and carry the session yourself.
Testing an integration with a fake engine
from pydantic_ai import Agent
from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart
from pydantic_ai.models.function import FunctionModel
class FakeEngine: # Tier 2 stand-in: no network
supports_class = True
def classify(self, conversation, cls="request"):
hot = "send your unit cost" in conversation[-1]["u"]
return {
"decision": "BLOCK" if hot else "ALLOW",
"category": "restriction",
"attack_probability": 0.95 if hot else 0.05,
}
def scripted(messages, info): # one fetch_url call, then an answer
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart("fetch_url", {"url": "http://shop"})])
return ModelResponse(parts=[TextPart("Hold the price.")])
firewall = Firewall(config, scope_classifier=FakeEngine())
capability = firewall.adapt_to("pydantic_ai")
agent = Agent(FunctionModel(scripted), tools=[fetch_url], capabilities=[capability])
result = agent.run_sync("Check the price")
assert POISON not in str(result.all_messages())
assert capability.session(result.conversation_id)["counts"]["blocks"] == 1