Guardrails¶
A guard is a hook that looks at what goes into and out of your agent: tool arguments, tool results, model requests and model replies. When it says no, it blocks, redacts or asks for approval. Guards are hooks, so every integration, the tool loop, flows and the HTTP server get them through the one executor.
flowchart LR
A["tool args"] --> G{"guard"}
R["tool result"] --> G
Q["model request"] --> G
P["model reply"] --> G
G -->|allowed| OK["call goes on"]
G -->|block| X["GuardrailBlocked"]
G -->|redact| D["value rewritten<br/>(results, replies)"]
G -->|approve| AP["one-use approval<br/>(tool args)"]
| Guard | Needs | Finds |
|---|---|---|
PIIGuard |
nothing | emails, phone numbers, cards (Luhn-checked), IBANs (mod-97), API keys and secrets |
CallableGuard(fn) |
your function | whatever your guard service says |
PresidioGuard |
agent-adapter[presidio] |
Presidio's entity types |
NemoGuard |
agent-adapter[nemo] |
what your NeMo Guardrails rails block or rewrite |
All four import from agent_adapter.hooks.
Built-in PII and secret checks¶
redact_out = PIIGuard(on=("result", "llm_response"), action="redact")
block_secrets = PIIGuard(kinds=["secret", "card"], on="args")
review_contacts = PIIGuard(kinds=["email", "phone"], on="args", action="approve")
kindspicks fromemail,phone,card,ibanandsecret(all by default).patterns={"ticket": r"TK-\d+"}adds named regexes of your own.onpicks the stages:args,result,llm_request,llm_response. The default is("args", "result", "llm_response").- A phone number needs a leading
+or separators: a bare run of digits is not flagged. - Cards and IBANs are found inside longer runs (a card followed by its CVV, an IBAN in any case or followed by a code).
- Keys are checked too: an email used as a key is found. With the
secretkind, every value at any depth below a secret-named key (password,api_key,auth,passcode,pin) is flagged, and so is the value of a pair:["Authorization", "..."]or{"name": "X-Api-Key", "value": "..."}. - Redaction swaps each finding for
[EMAIL],[CARD]and so on (placeholder=).
Actions¶
| Action | Stages | Does |
|---|---|---|
block (default) |
all | raises GuardrailBlocked; the model sees the reason |
redact |
result, llm_response |
rewrites the value before anyone else sees it |
approve |
args |
sends the call to the executor's approval port |
Arguments and requests cannot be rewritten by a hook, so redact refuses those stages
with a ConfigError. Redaction rewrites each flagged string or key where it sits, inside
mappings, lists, tuples, sets, pydantic models and dataclasses; an equal string elsewhere
stays. Two keys redacted to the same text stay apart ([EMAIL], [EMAIL]#2). Lists,
tuples, sets, models and dataclasses keep their types; other mappings become plain dicts.
Each redaction sends a guardrail.redacted event: the guard, the stage, how many texts
and which kinds (email, card; GuardVerdict(kinds=, count=) for your own guard), never
the values. The audit log keeps it.
A model reply is checked in full: its text, parsed, the tool-call arguments and the
provider's raw items, which ports send back on the next turn. A redacted reply has its
text, assistant message, parsed, tool-call arguments and raw items rewritten in place,
so the original never goes back to the provider. Raw items with nothing flagged, such as
thinking blocks and thought signatures, are kept as they were. If a flagged text sits
inside a signed block, the rewrite breaks its signature and the provider refuses the next
request: the call fails, nothing leaks. Raw items are checked by their text only, since
their key names (signature) are the provider's. Requests are read the same way.
Approval for flagged calls¶
approve uses the normal one-use approval path. Give the guarded tools the guard's
confirm rule:
@registry.tool(confirm=review_contacts.confirm)
def send_newsletter(to: str, subject: str) -> str:
"""Send the newsletter; a reviewer approves when it goes to a personal address."""
return f"sent {subject!r}"
Clean calls run without asking. A flagged call goes to the approval port: with no port it
is denied. A tool without confirm=guard.confirm (or confirm=True) is blocked, never
let through.
Any guard service¶
CallableGuard(fn) wraps your moderation endpoint, policy engine or classifier.
fn(stage, payload) may be sync or async. A sync fn runs in a worker thread.
async def moderation_service(stage: str, payload: object) -> bool | str | GuardVerdict:
"""Stands in for your moderation endpoint: say no to ticket resale."""
return "resale is not allowed" if "resell" in str(payload) else True
moderated = Executor(registry, hooks=[CallableGuard(moderation_service, on="args")])
It returns True (pass), False or a reason string (no), or a GuardVerdict. To redact,
return GuardVerdict(False, reason, replacement=new_payload).
Presidio¶
from agent_adapter.hooks import PresidioGuard
guard = PresidioGuard(entities=["EMAIL_ADDRESS", "PERSON"], threshold=0.6, action="redact",
on=("result", "llm_response"))
The analyzer finds entities and the anonymizer replaces them (<PERSON>). Pass your own
analyzer= and anonymizer= engines to reuse configured ones. Presidio runs in a
worker thread. Presidio's default analyzer also needs a spaCy model, such as
en_core_web_lg.
NeMo Guardrails¶
from agent_adapter.hooks import NemoGuard
guard = NemoGuard(config="./rails", on=("args", "llm_response")) # or NemoGuard(rails)
Each text goes to check_async (NeMo Guardrails 0.24 or newer). Arguments and requests
go as the user's turn (input rails), results and replies as the assistant's turn (output
rails). BLOCKED says
no; MODIFIED says no and carries the rewrite, which redact uses. For a guardrails
server, call it from a CallableGuard.
Safe by default¶
- Fail closed. A guard that raises or returns something unexpected blocks
the payload with the reason
guard failed. - No content in logs. Guards log the stage, the tool and the exception type. Built-in
reasons name the kinds found (
found email, card), never the text. - Text guards see each distinct string once per payload. A model request is checked in full on every step, so a flagged message in the history blocks later steps too.
Writing your own¶
Subclass TextGuard and implement review(text, stage) -> GuardVerdict, or subclass
ContentGuard and implement inspect(stage, payload). For a plain pass or fail check,
the simpler GuardrailHook(check) from Hooks still works.