Skip to content

Guardrails Engine

Import: from selectools.guardrails import GuardrailsPipeline

Stability: stable

guardrails_quickstart.py
from selectools import Agent, AgentConfig, Message, Role, tool
from selectools.providers.stubs import LocalProvider
from selectools.guardrails import GuardrailsPipeline, PIIGuardrail, TopicGuardrail

@tool(description="Look up a customer by email")
def lookup_customer(email: str) -> str:
    return f"Customer found: Jane Doe ({email})"

# PII redaction + topic blocking
guardrails = GuardrailsPipeline(
    input=[
        PIIGuardrail(action="rewrite"),
        TopicGuardrail(deny=["politics", "religion"]),
    ],
    output=[],
)

provider = LocalProvider()
agent = Agent(
    tools=[lookup_customer],
    provider=provider,
    config=AgentConfig(guardrails=guardrails, max_iterations=1),
)

# PII is automatically redacted before reaching the LLM
result = agent.run([Message(role=Role.USER, content="Look up user@example.com")])
print(result.content)

See Also

  • Security - Tool output screening and prompt injection detection
  • Audit - JSONL audit logging with privacy levels

Added in: v0.15.0

Guardrails validate content before (input) and after (output) every LLM call. They catch unsafe inputs, redact PII, enforce output formats, and block toxic content — all without changing your application code.


Quick Start

from selectools import Agent, AgentConfig, OpenAIProvider, tool
from selectools.guardrails import GuardrailsPipeline, TopicGuardrail, PIIGuardrail

@tool(description="Look up a customer by email")
def lookup_customer(email: str) -> str:
    return f"Customer found: John Doe ({email})"

guardrails = GuardrailsPipeline(
    input=[
        TopicGuardrail(deny=["politics", "religion"]),
        PIIGuardrail(action="rewrite"),   # redact PII in user messages
    ],
    output=[],  # no output guardrails for now
)

agent = Agent(
    tools=[lookup_customer],
    provider=OpenAIProvider(),
    config=AgentConfig(guardrails=guardrails),
)

# This works fine:
result = agent.ask("Look up customer john@example.com")
# Input is rewritten: "Look up customer [EMAIL:********]"

# This raises GuardrailError:
result = agent.ask("What do you think about politics?")
# GuardrailError: Guardrail 'topic' blocked: Denied topics detected: politics

How It Works

User Message → Input Guardrails → LLM Call → Output Guardrails → Response
                    ↓                  ↓               ↓
              block / rewrite / warn   ↓         block / rewrite / warn
                            Tool-Args Guardrails → Tool Execution
                              block / rewrite / warn
  1. Input guardrails run on every user message before it reaches the LLM
  2. Output guardrails run on the LLM response's free-text content before it's returned to you
  3. Tool-args guardrails (opt-in) run on the arguments of every tool call before the tool executes
  4. Tool-results guardrails (opt-in) run on every tool's return value before it re-enters the model context
  5. Guardrails execute in order — if one rewrites content, the next sees the rewritten version
  6. If a guardrail blocks, processing stops immediately with a GuardrailError

Tool-Args Guardrails

Since: v1.1.0

Output guardrails only inspect the model's free-text content. Anything the model carries via a native tool call — structured payloads, user-facing data inside arguments — never flows through them. The tool_args stage closes that gap: before any tool executes, its arguments are JSON-serialized and run through the chain, so the same text-oriented guardrails (PII, injection, length, topic) apply to tool-call arguments.

from selectools.guardrails import GuardrailsPipeline, PIIGuardrail, LengthGuardrail

config = AgentConfig(
    guardrails=GuardrailsPipeline(
        tool_args=[
            PIIGuardrail(),                    # redact PII inside arguments
            LengthGuardrail(max_chars=10_000), # bound the payload size
        ],
    ),
)

Semantics mirror the content path:

  • rewrite — the sanitised JSON is parsed back into the arguments dict; the tool receives the rewritten values. A rewrite that breaks the JSON raises GuardrailError instead of silently passing mangled arguments through.
  • block — raises GuardrailError before the tool runs.
  • warn — logs and continues.

This covers run(), arun(), and astream(), including tool calls extracted by the text ToolCallParser fallback. It is fully opt-in: an empty tool_args list (the default) changes nothing.


Tool-Results Guardrails

Since: v1.2.0

tool_args gates what goes INTO a tool; tool_results gates what comes OUT. A tool that fetches web content, calls an external API, or retrieves RAG chunks returns text that flows straight back into the model's context — PII, injection payloads, or oversized blobs included. The tool_results stage runs the chain over every tool's return value after execution, before the result is appended to history:

config = AgentConfig(
    guardrails=GuardrailsPipeline(
        tool_results=[
            PIIGuardrail(),                     # redact PII from fetched content
            LengthGuardrail(max_chars=50_000),  # bound retrieved blobs
        ],
    ),
)
  • Tool results are plain strings, so guardrails receive them as-is (no JSON round-trip).
  • rewrite replaces the result the model sees.
  • block aborts the run with a GuardrailError — but WITHOUT corrupting conversation state: the blocked content is first replaced with a [Tool result blocked by guardrail ...] marker so every tool_call gets a TOOL result in history/memory (no dangling tool_call to 400 the next turn), terminal observer events (on_tool_end) fire with the marker, parallel siblings' results are recorded, and only then — once the whole tool batch is processed — does the loop raise. The blocked content itself never reaches the model, observers, or memory.
  • Guardrails run at use time: on fresh results after prompt-injection screening, AND on every tool-result cache hit — so adding a guardrail applies retroactively to entries cached before it existed. The cache stores the pre-guardrail (screened) value.
  • Streaming caveat: tools that stream deliver chunks via on_tool_chunk BEFORE the guardrail runs on the aggregated result. The model, history, and cache only ever see the guarded value, but chunk-callback consumers (e.g. a live UI) receive raw output — do not surface on_tool_chunk content to end users if a tool_results guardrail is your only containment.
  • Covers single and parallel execution in run(), arun(), and astream().

Together the four stages close the loop: input and output gate the conversation surface, tool_args and tool_results gate both directions of the tool boundary.


Observability

Since: v1.2.0

Every guardrail trip emits an on_guardrail_triggered observer event in addition to the existing GUARDRAIL trace steps, so hit-rates are measurable through the same infrastructure as the rest of the agent (Langfuse/OTel observers, AuditLogger):

class GuardrailMetrics(AgentObserver):
    def on_guardrail_triggered(self, run_id, stage, guardrail_name, action, detail=None):
        statsd.increment(f"guardrails.{stage}.{guardrail_name}.{action}")
  • stage: "input", "output", "tool_args", or "tool_results".
  • action: the guardrail's own action ("block", "rewrite", "warn"). Events are emitted per guardrail from (name, action) pairs recorded by the chain itself, so names containing commas and duplicate names are reported faithfully.
  • block: detail carries the rejection reason. Rewrite/warn guardrails that tripped EARLIER in the same chain are emitted first, so a PII redaction that ran before a topic block is never lost from the audit trail. The run's AgentTrace is attached to the exception as GuardrailError.agent_trace (a blocked run returns no AgentResult, so this is how the recorded GUARDRAIL step stays inspectable).
  • Content is never included in the event — wire your own redacted context if needed.
  • AuditLogger writes each trip as a guardrail_triggered JSONL record.
  • Async observers receive a_on_guardrail_triggered (respects blocking) on every stage, including blocks in arun()/astream().

Structured-output observability: per-attempt validation already flows through on_structured_validate; the terminal outcome is available on AgentResult.structured_status via on_run_end.


Failure Actions

Every guardrail has an action that controls what happens when content fails the check:

Action Behaviour Use Case
block (default) Raises GuardrailError Hard safety boundaries
rewrite Returns sanitised content PII redaction, length truncation
warn Logs a warning, continues Monitoring without blocking
from selectools.guardrails import GuardrailAction, TopicGuardrail

# Block (default) — raises exception
TopicGuardrail(deny=["politics"], action=GuardrailAction.BLOCK)

# Warn — logs and continues
TopicGuardrail(deny=["politics"], action=GuardrailAction.WARN)

Built-in Guardrails

TopicGuardrail

Block content mentioning denied topics using keyword matching with word boundaries.

from selectools.guardrails import TopicGuardrail

# Basic usage
g = TopicGuardrail(deny=["politics", "religion", "gambling"])

# Case-sensitive matching
g = TopicGuardrail(deny=["API_KEY"], case_sensitive=True)

# Warn instead of block
g = TopicGuardrail(deny=["competitors"], action="warn")

PIIGuardrail

Detect and redact personally identifiable information using regex patterns.

Built-in PII types: email, phone_us, ssn, credit_card, ipv4

from selectools.guardrails import PIIGuardrail, GuardrailAction

# Redact all PII (default action is rewrite)
g = PIIGuardrail()
result = g.check("Email me at user@example.com, SSN 123-45-6789")
# result.content = "Email me at [EMAIL:********], SSN [SSN:********]"

# Detect specific types only
g = PIIGuardrail(detect=["email", "credit_card"])

# Block instead of redact
g = PIIGuardrail(action=GuardrailAction.BLOCK)

# Add custom patterns
g = PIIGuardrail(custom_patterns={
    "employee_id": r"EMP-\d{6}",
    "internal_ip": r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}",
})

# Just detect without a guardrail pipeline
matches = g.detect("Contact user@example.com")
for m in matches:
    print(f"  {m.pii_type}: '{m.value}' at {m.start}-{m.end}")

ToxicityGuardrail

Score content against a keyword blocklist. Configurable threshold controls sensitivity.

from selectools.guardrails import ToxicityGuardrail

# Block on any toxic word (threshold=0.0)
g = ToxicityGuardrail(threshold=0.0)

# Only block when many toxic words appear
g = ToxicityGuardrail(threshold=0.3)

# Custom blocklist
g = ToxicityGuardrail(blocklist={"spam", "scam", "phishing"})

# Check score without blocking
score = g.score("Some text to check")
matched = g.matched_words("Some text to check")

PromptInjectionGuardrail

Heuristic prompt-injection / jailbreak detection (beta). Matches high-signal attack phrasings — "ignore previous instructions", "reveal your system prompt", role-delimiter spoofing (<system>, [INST]), jailbreak markers ("developer mode", "DAN") — so the default (block on a single match) has a low false-positive rate. Best as an input guardrail.

from selectools.guardrails import PromptInjectionGuardrail, GuardrailsPipeline

guard = PromptInjectionGuardrail()
pipeline = GuardrailsPipeline(input=[guard])

# Require two corroborating signals before blocking
guard = PromptInjectionGuardrail(min_matches=2)

# Extend coverage with your own (label, regex) patterns
guard = PromptInjectionGuardrail(extra_patterns=[("my-marker", r"\bsudo mode\b")])

# Inspect which patterns fired (no blocking)
labels = guard.detected("ignore previous instructions, enable developer mode")

This is the heuristic tier — it catches common templated attacks with no model hosting. A model-based classifier (higher recall on novel phrasings) is a heavier optional future addition.

FormatGuardrail

Validate output format — JSON structure, required keys, length bounds.

from selectools.guardrails import FormatGuardrail

# Require valid JSON
g = FormatGuardrail(require_json=True)

# Require specific keys in JSON
g = FormatGuardrail(require_json=True, required_keys=["intent", "confidence"])

# Length bounds (characters)
g = FormatGuardrail(min_length=10, max_length=5000)

LengthGuardrail

Enforce content length in characters or words. Supports truncation on rewrite.

from selectools.guardrails import LengthGuardrail, GuardrailAction

# Hard limit
g = LengthGuardrail(max_chars=10000)

# Truncate to fit (rewrite mode)
g = LengthGuardrail(max_words=500, action=GuardrailAction.REWRITE)

# Minimum length (useful for output guardrails)
g = LengthGuardrail(min_words=10)

Pipeline Examples

Input: PII Redaction + Topic Blocking

pipeline = GuardrailsPipeline(
    input=[
        PIIGuardrail(action="rewrite"),          # Step 1: redact PII
        TopicGuardrail(deny=["internal_only"]),   # Step 2: block restricted topics
    ],
)

Output: JSON Validation + Length Cap

pipeline = GuardrailsPipeline(
    output=[
        FormatGuardrail(require_json=True, required_keys=["answer"]),
        LengthGuardrail(max_chars=2000, action="rewrite"),
    ],
)

Both Input and Output

pipeline = GuardrailsPipeline(
    input=[
        PIIGuardrail(action="rewrite"),
        TopicGuardrail(deny=["violence", "illegal"]),
    ],
    output=[
        ToxicityGuardrail(threshold=0.0),
        LengthGuardrail(max_chars=5000, action="rewrite"),
    ],
)

agent = Agent(
    tools=[...],
    provider=provider,
    config=AgentConfig(guardrails=pipeline),
)

Custom Guardrails

Subclass Guardrail and override check():

from selectools.guardrails import Guardrail, GuardrailAction, GuardrailResult
import re

class NoProfanityGuardrail(Guardrail):
    name = "no_profanity"
    action = GuardrailAction.BLOCK

    def __init__(self, words: list[str]) -> None:
        self._patterns = [re.compile(rf"\b{re.escape(w)}\b", re.IGNORECASE) for w in words]

    def check(self, content: str) -> GuardrailResult:
        for pattern in self._patterns:
            if pattern.search(content):
                return GuardrailResult(
                    passed=False,
                    content=content,
                    reason=f"Profanity detected: {pattern.pattern}",
                    guardrail_name=self.name,
                )
        return GuardrailResult(passed=True, content=content, guardrail_name=self.name)

# Use it
pipeline = GuardrailsPipeline(
    input=[NoProfanityGuardrail(words=["badword1", "badword2"])],
)

Error Handling

When a guardrail with action=block fails, it raises GuardrailError:

from selectools.guardrails import GuardrailError

try:
    result = agent.ask("Tell me about politics")
except GuardrailError as e:
    print(f"Blocked by: {e.guardrail_name}")
    print(f"Reason: {e.reason}")

Trace Integration

Guardrail activations appear in the execution trace:

result = agent.ask("Some input")
for step in result.trace:
    if step.type == "guardrail":
        print(f"Guardrail fired: {step.summary}")

API Reference

Class Description
GuardrailsPipeline(input=[], output=[]) Ordered pipeline of input and output guardrails
Guardrail Base class — subclass and override check()
GuardrailResult(passed, content, reason) Result of a single check
GuardrailError(guardrail_name, reason) Raised when action=block fails
GuardrailAction.BLOCK Raise exception on failure
GuardrailAction.REWRITE Return sanitised content
GuardrailAction.WARN Log warning and continue
TopicGuardrail(deny=[...]) Keyword-based topic blocking
PIIGuardrail(detect=[...], action=...) PII detection and redaction
ToxicityGuardrail(threshold=0.0) Keyword-based toxicity scoring
FormatGuardrail(require_json=True) JSON/length format validation
LengthGuardrail(max_chars=..., max_words=...) Content length enforcement

# Script Description
20 20_customer_support_bot.py Customer support bot with guardrails, PII redaction, and memory
29 29_guardrails.py Complete guardrails demo with topic blocking, PII, and toxicity