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=.

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 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.

Diagram

block

allow / rewrite

block

allow / substitute

🤖 LLM tool call

đŸ›Ąī¸ input guardrail

â†Šī¸ reason fed to model

🔧 tool runs

đŸ›Ąī¸ output guardrail

â†Šī¸ reason fed to model

🤖 back to LLM

Quick Start

  1. 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
  2. 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")
  3. 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).

Diagram
Trust fence (external content)Per-tool output guardrailToolPer-tool input guardrailAgent-wide guardrail / policyHuman approval gateLLMTrust fence (external content)Per-tool output guardrailToolPer-tool input guardrailAgent-wide guardrail / policyHuman approval gateLLMruns LAST in — nothing canrewrite args after its verdictruns FIRST out — sees theRAW result before the fenceproposed tool callapproved args(possibly rewritten) argsallowed / rewritten argsraw resultallowed / substituted resultfenced result

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 forEvery tool callJust this tool
Configured onAgent(...)The tool itself, next to approval= and trust
Blocks byReturning (False, ...)Same
On blockReason fed back to the modelSame
Rewrite / substitute(True, value)Same
Built onGuardrailResult, GuardrailChainSame (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