Skip to content

Guardrails Guide

The Guardrails Layer (ADR-014) provides declarative input/output validation for LLMAgent subclasses. Guardrails intercept content at two points in the pipeline:

User input
  → InputGuardrailChain   (PII redaction, token limit, content policy)
  → LLMProvider.chat()
  → OutputGuardrailChain  (schema validation, content policy, safety filter)
  → Consumer

The chain runs where your agent calls it. LLMAgent makes no LLM call of its own, so setting a chain, or declaring one with @WithGuardrails, does not apply it: the agent calls applyInputGuardrails before provider.chat(...) and applyOutputGuardrails after. Both are protected. Text that never passes through them reaches the provider unchanged.

@WithGuardrails(input = { PiiRedactionGuardrail.class })
public class FinanceAgent extends LLMAgent {

    private final LLMProvider provider;

    public FinanceAgent(LLMProvider provider) {
        super("finance-agent");
        this.provider = provider;
    }

    public String ask(String userInput) throws GuardrailViolationException {
        GuardrailContext ctx = GuardrailContext.of(getAgentId(), "finance");
        String safeInput = applyInputGuardrails(userInput, ctx);
        LLMRequest request = LLMRequest.builder()
                .addMessage(LLMMessage.user(safeInput))
                .build();
        String rawOutput = provider.chat(request).join().content();
        return applyOutputGuardrails(rawOutput, ctx);
    }
}

Code that is not an LLMAgent, such as an embedding call, uses the chain directly: chain.applyInput(text, ctx) and chain.applyOutput(text, ctx) are public.


Available guardrails

Class Direction Description
PiiRedactionGuardrail Input + Output Detects and redacts PII (email, phone IT, CF, IBAN, credit card)
ContentPolicyGuardrail Input + Output Blocks content matching a YAML blocklist (patterns + topics)
MaxTokensInputGuardrail Input Truncates input that exceeds a token budget (3 strategies)

GuardrailChain builder

Build a chain programmatically when guardrails need constructor parameters:

GuardrailChain chain = GuardrailChain.builder()
    .addInput(new PiiRedactionGuardrail())
    .addInput(new MaxTokensInputGuardrail(2048))
    .addOutput(new ContentPolicyGuardrail("/etc/agenor/policy.yaml"))
    .build();

agent.setGuardrailChain(chain);

Execution semantics:

Result Behaviour
Passed Content unchanged, next guardrail receives same value
Modified(newContent) Content replaced, chain continues with new value
Blocked(reason) Chain short-circuits, GuardrailViolationException thrown

@WithGuardrails annotation

Declare guardrails on the agent class. AgenorRuntime instantiates them via their public no-arg constructor and injects the chain at registration time:

@Agent("finance-agent")
@WithGuardrails(
    input  = { PiiRedactionGuardrail.class, MaxTokensInputGuardrail.class },
    output = { PiiRedactionGuardrail.class, ContentPolicyGuardrail.class }
)
public class FinanceAgent extends LLMAgent { ... }

Note — guardrails listed in @WithGuardrails must expose a public no-arg constructor. For guardrails that need parameters (e.g. a YAML path), use the GuardrailChain builder instead.

Combining both approaches is supported: the annotation-derived chain is prepended to any programmatic chain already set on the agent.


PiiRedactionGuardrail

Detects and replaces PII tokens with [REDACTED].

// All PII types (default)
new PiiRedactionGuardrail()

// Selective
new PiiRedactionGuardrail(EnumSet.of(PiiType.EMAIL, PiiType.IBAN))
PiiType Pattern
EMAIL Standard email addresses
PHONE_IT Italian mobile numbers (+39 prefix optional), compact or in groups (347 123 4567)
CODICE_FISCALE 16-char Italian fiscal code (case-insensitive)
IBAN International Bank Account Number
CREDIT_CARD 13–19 digit card numbers (spaces/dashes optional)

Returns Modified(redactedContent) if at least one match is found, Passed otherwise.

Nothing outside this table is detected: landline numbers, VAT numbers (partita IVA), postal addresses and personal names pass through unchanged. If customer text can carry them and the provider is not local, add your own InputGuardrail for them.


ContentPolicyGuardrail

Loads a YAML blocklist and blocks matching content.

new ContentPolicyGuardrail("/etc/agenor/policy.yaml")
new ContentPolicyGuardrail("classpath:guardrails/policy.yaml")

YAML format:

content-policy:
  blocked-patterns:
    - pattern: "(?i)guaranteed return"
      reason:  "Prohibited financial promise"
  blocked-topics:
    - "violence"
    - "buy*now"          # wildcard supported

blocked-patterns accepts full Java regex (inline flags like (?i) are honoured). blocked-topics are matched as case-insensitive substrings; * is the only wildcard.


MaxTokensInputGuardrail

Truncates inputs that exceed a token budget.

// Default: END strategy, SimpleTokenEstimator
new MaxTokensInputGuardrail(512)

// Custom strategy
new MaxTokensInputGuardrail(1024, TruncationStrategy.MIDDLE)

// Custom estimator
new MaxTokensInputGuardrail(1024, TruncationStrategy.END, myEstimator)
TruncationStrategy Behaviour
END (default) Keep beginning, drop tail
START Keep end, drop head
MIDDLE Keep beginning and end, drop centre

Writing a custom guardrail

Implement InputGuardrail or OutputGuardrail (both are @FunctionalInterface):

// Lambda form
InputGuardrail maxLength = (input, ctx) -> {
    if (input.length() > 4096) {
        return CompletableFuture.completedFuture(
                new GuardrailResult.Blocked("Input too long"));
    }
    return CompletableFuture.completedFuture(new GuardrailResult.Passed());
};

// Class form (required for @WithGuardrails — must have no-arg constructor)
public class MaxLengthGuardrail implements InputGuardrail {
    @Override
    public CompletableFuture<GuardrailResult> apply(String input, GuardrailContext ctx) {
        return CompletableFuture.completedFuture(
            input.length() > 4096
                ? new GuardrailResult.Blocked("Input too long")
                : new GuardrailResult.Passed());
    }
}

GuardrailContext carries agentId, topic, and an open metadata map for passing contextual information from the agent to its guardrails.


Handling GuardrailViolationException

GuardrailViolationException extends AgenorException (unchecked). Catch it explicitly when you need to react to policy violations:

Around the agent's own method, ask in the example above:

try {
    String response = financeAgent.ask(userInput);
} catch (GuardrailViolationException e) {
    log.warn("Guardrail [{}] blocked content: {}", e.blockedBy(), e.reason());
    return "Your request could not be processed due to content policy.";
}

Running the example

mvn exec:java -pl agenor-examples \
    -Dexec.mainClass=dev.agenor.examples.GuardrailsExample

No API key required — the example uses a stub LLM provider.


See also

  • ADR-014 — architectural decision: sealed interface vs enum, hook position in LLMAgent
  • GuardrailChainTest — chain execution semantics (pass, modify, block, concurrency)
  • PiiRedactionGuardrailTest — PII pattern coverage and false-positive rate
  • ContentPolicyGuardrailTest — YAML loading, wildcard matching