Per-Tool Guardrails
Guard a single tool's arguments and results without hand-wrapping the function
Guard a single tool’s arguments and results without hand-wrapping the function â declare input_guardrails= / output_guardrails= right on the tool, next to approval=.
from praisonaiagents import Agent, tool
def internal_recipients_only(arguments: dict):
if not arguments.get("to", "").endswith("@corp.com"):
return False, "Recipient is outside the company domain."
return True, arguments
@tool(input_guardrails=[internal_recipients_only])
def send_email(to: str, body: str) -> str:
"""Send an email."""
return f"sent to {to}"
agent = Agent(instructions="Send emails on request.", tools=[send_email])
agent.start("Email alice@gmail.com the release notes")
# -> the call is blocked; the reason is fed back to the model, which can adapt.The guardrail fires on every invocation of send_email and never for any other tool.
Quick Start
- Block a tool's arguments by rule
An input guardrail returns
(False, "reason")to block. The reason is handed back to the model as the tool result, never raised at the user.python from praisonaiagents import Agent, tool def internal_recipients_only(arguments: dict): if not arguments.get("to", "").endswith("@corp.com"): return False, "Recipient is outside the company domain." return True, arguments @tool(input_guardrails=[internal_recipients_only]) def send_email(to: str, body: str) -> str: """Send an email.""" return f"sent to {to}" agent = Agent(instructions="Send emails.", tools=[send_email]) agent.start("Email bob@corp.com the agenda") # allowed - Rewrite the arguments before the tool runs
Return
(True, new_arguments)to rewrite. The rewritten dict is what the tool receives.python from praisonaiagents import Agent, tool def force_bcc_compliance(arguments: dict): args = dict(arguments) args["bcc"] = "audit@corp.com" # always CC the audit mailbox return True, args @tool(input_guardrails=[force_bcc_compliance]) def send_email(to: str, body: str, bcc: str = "") -> str: """Send an email.""" return f"sent to {to}, bcc {bcc}" agent = Agent(instructions="Send emails.", tools=[send_email]) agent.start("Email dana@corp.com the report") - Substitute the result
An output guardrail sees the raw result before it re-enters the LLM context. Return
(True, new_result)to substitute, or(False, "reason")to block.python from praisonaiagents import Agent, tool def redact_secrets(result): return True, str(result).replace("sk-live-1234", "[REDACTED]") @tool(output_guardrails=[redact_secrets]) def read_config() -> str: """Read the service config.""" return "STRIPE_KEY=sk-live-1234" agent = Agent(instructions="Report config values.", tools=[read_config]) agent.start("What is the Stripe key?") # -> the model sees STRIPE_KEY=[REDACTED]
Also apply in code mode
See Code Execution with Tools â Same gates as direct tool calls for the full code-mode gating chain.
How per-tool guardrails run
Per-tool guardrails are the layer closest to the tool in both directions. The input guardrail runs last on the way in (immediately before dispatch); the output guardrail runs first on the way out (before any agent-wide guardrail and before the trust fence).
Per-tool vs agent-wide guardrails
Per-tool guardrails share the same machinery as agent-wide ones â a guardrail written for one scope drops into the other.
Agent-wide Agent(guardrails=âĻ) | Per-tool input_guardrails= / output_guardrails= | |
|---|---|---|
| Fires for | Every tool call | Just this tool |
| Configured on | Agent(...) | The tool itself, next to approval= and trust |
| Blocks by | Returning (False, ...) | Same |
| On block | Reason fed back to the model | Same |
| Rewrite / substitute | (True, value) | Same |
| Built on | GuardrailResult, GuardrailChain | Same (shared machinery) |
A guardrail entry may be a plain callable (value) -> (ok, value), a bare True / False, a GuardrailResult, or any object exposing validate_tool_call / validate_tool_result â so an existing GuardrailChain reuses unchanged. A guardrail that raises, or returns an unreadable verdict, fails closed and blocks the call.
Best Practices
Scope the guardrail to where the rule lives
When a rule belongs to one tool â “send_email recipients must be on-domain” â declare it on that tool, not agent-wide. An agent-wide guardrail fires for every tool and has to branch on tool_name.
Prefer rewriting arguments over blocking
Returning (True, sanitised_args) is safer than (False, reason) and hoping the model retries correctly. Fix the call rather than bouncing it back.
Redact in the output guardrail rather than reject
When a result carries a secret, return (True, redacted) so the model still gets a usable answer, instead of (False, ...) which throws the whole result away.
Keep guardrail logic pure
A guardrail should inspect and transform its input with no side effects â no network calls, no writes. A guardrail that raises fails closed and blocks the tool.
On the async path (astart() / achat()), the runtime offloads sync guardrails to a worker thread (PR #5247), so a blocking network lookup inside a guardrail no longer stalls the event loop â but purity is still the safer default because a raise still fails closed and blocks the tool. See Async Tool Safety.
Related
Agent-wide output and tool guardrails.
ApprovalHuman-in-the-loop sign-off before a tool runs.
Tool ApprovalDeclare a tool needs approval in one line.
Async Tool SafetyBlocking guardrails run on a worker thread on the async path.
Per-Tool Guardrails (TypeScript)The same feature in the TypeScript SDK.