Guardrails¶
Prompts are not a substitute for programmatic validation. Guardrails can veto a run: input guardrails check the conversation before the first model call, and output guardrails check the final result before it returns.
from lovia import Agent
from lovia.exceptions import GuardrailTripped
async def no_email_addresses(messages, ctx):
if any("@" in str(m.content) for m in messages):
raise GuardrailTripped("Email addresses are not allowed.")
async def must_cite(output, ctx):
if "source:" not in str(output).lower():
return "Missing source citation."
agent = Agent(
name="researcher",
model="<model>",
input_guardrails=[no_email_addresses],
output_guardrails=[must_cite],
)
| The rule checks… | Put it in… |
|---|---|
| User input or prior conversation | Input guardrail |
| The final answer | Output guardrail |
| One Tool's side effect | Tool approval, not a guardrail |
The contract¶
A guardrail is any callable, sync or async:
- Input: called as
fn(messages, ctx)with the chat-format view of the fully built initial transcript (system prompt, session history, your input) — once, before the first model call. - Output: called as
fn(output, ctx)with the run's final output — after parsing/validation, so with a typedoutput_typeyou check the validated object, not raw text.
Signal a violation either way:
- raise
GuardrailTripped("reason")— explicit, carries your message; - return a truthy value — a non-empty string becomes the reason
(
"output guardrail: Missing source citation.");Trueproduces a generic one.None,False, and""mean "pass".
A tripped guardrail ends the run: Runner.run raises
GuardrailTripped; a stream closes with RunFailed carrying it. There is
no automatic retry — a guardrail is a boundary, not a nudge (if you want
"try again", catch the exception and re-run, or express the rule as an
eval check during development instead).
Both hooks receive the live ctx
(RunContext), so checks can be
tenant-aware (ctx.deps), usage-aware (ctx.usage), or transcript-aware
(ctx.entries). Guardrails run in list order; the first violation wins.
Plugins may contribute guardrails too — they run at the same
checkpoints, merged with the agent's own, and the loop (never the plugin)
owns the abort.
Recipes¶
Screen with a separate model — a guardrail is async, so it can run its own scripted classifier:
screen = Agent(name="screen", model="<model>", output_type=bool,
instructions="Answer true if the request asks for legal advice.")
async def no_legal_advice(messages, ctx):
result = await screen.run(str(messages[-1].content))
if result.output:
return "We can't provide legal advice."
Enforce output invariants the schema can't express — citation presence, banned phrases, max length:
async def short_enough(output, ctx):
if len(str(output)) > 2_000:
return "Answer exceeds the 2,000-character limit."
Redact instead of reject? Guardrails are pass/fail — they cannot rewrite values. Redaction belongs where the data flows: tool policies for tool arguments/results, your own pre-processing for input.
Sharp edges¶
- Input guardrails see history too, not just the new message — a rule
like "reject any @-sign" trips on messages from three turns ago that were
fine at the time. Check
messages[-1]when you mean "the new input". - Output guardrails don't run on checkpoint replays — they ran on the original completion; a replay returns the stored result as-is.
- Guardrail latency is run latency. Input guardrails sit before the first model call; an LLM-screening guardrail adds a full round-trip. Keep the fast checks first in the list.
- Mid-run content is out of scope by design — guardrails bracket the run. To police individual tool calls, use approval or a tool policy; to police streamed text, filter in your consumer.
See also¶
- Tool approval — per-call gating
- Eval — the development-time twin of output guardrails
- Example:
13_guardrails.py