Normandy.Guardrails
(normandy v2.2.0)
View Source
Runs a list of Normandy.Guardrails.Guard modules against a value.
Guardrails sit between schema validation (shape) and resilience (transport) and express content-level constraints — length limits, forbidden phrases, regex allow/deny lists, required output fields, and so on.
Each entry in the guard list is either a bare module atom or a
{module, opts} tuple. Guards run in order and short-circuit on the first
failure: guardrails model blocking policy, not aggregate reporting.
Example
alias Normandy.Guardrails
alias Normandy.Guardrails.Builtins.MaxLength
Guardrails.run([{MaxLength, limit: 5}], "too long")
#=> {:error, [%{guard: MaxLength, path: [], message: _, constraint: :max_length}]}
Guardrails.run([{MaxLength, limit: 100}], "ok")
#=> {:ok, "ok"}Attach a guard list to an agent with guardrails: [input: ..., output: ...] in
Normandy.Agents.BaseAgent.init/1, or with the guardrails/2 macro in
Normandy.DSL.Agent.
Pre-charge admission
Input guardrails run inside a turn by default and signal a rejection by
raising. To use them as a pre-charge filter — block disallowed input
before paying for a turn — call Normandy.Agents.BaseAgent.admit/2,3, which
runs run/2,3 with no turn, memory, or circuit breaker and returns
:ok | {:block, violations} instead of raising.
Context
run/3 threads a caller-supplied context map to any guard implementing the
optional Guard.check/3 callback (guards with only check/2 are unaffected).
Context carries host data a guard needs but the framework must not interpret —
ids, locale, conversation history. See Normandy.Guardrails.Builtins.SemanticScope
for a guard that uses it.
Error handling (:on_error)
Each guard spec may carry an :on_error policy controlling what happens when
its check crashes: raises, exits (a GenServer.call or Task.await timeout
inside the guard) or throws:
:reraise(default) — the crash propagates. A configuration bug stays a crash, not a silent admit.:open— the crash is caught and treated as a pass. Use this for a guard that calls an external service so an outage admits instead of taking down the whole guard chain.:closed— the crash is caught and turned into a:guard_errorviolation whose message names the guard and the exception's module, the exit's tag or "threw", never the exception's message or the exit's terms.
Only the guard's check call is caught; a malformed return value is a
contract violation and always raises regardless of :on_error. So does a
built-in guard's configuration error (an ArgumentError or KeyError, such
as a :field the struct does not have or a missing option). Host code a built-in
guard calls (SemanticScope's :classifier and :fast_path) is not configuration:
its crash reaches :on_error as a Normandy.Guardrails.GuardError. A guard has no
timeout of its own: one that blocks without failing blocks the turn, so give the
service call inside it a timeout. A :field on a plain map cannot be checked: a map
has no fixed keys, so a missing key reads as nil.
What an input guard receives
An input guard receives the validated input: a %BaseAgentInputSchema{} for the default
schema (whether the caller passed "text" or %{chat_message: "text"}), or the custom
schema's struct. A built-in guard with no :field option checks the chat_message text of
a default-schema input; with field: :chat_message it reads that field of the struct.
SemanticScope classifies that text too. RequiredFields checks named fields, so it stays on
the struct.
Structured reason
A violation's :constraint (an atom) is the machine-readable rejection
reason. Hosts map it to localized copy or routing — e.g. a guard returning
constraint: :off_topic lets the caller render scope-specific messaging
without parsing strings. Extra keys on the violation map are allowed.
Output violations (guardrails: [on_violation: ...])
On the non-streaming path (BaseAgent.run/3, Turn.Server), output guards
run on the validated answer before it is saved to memory:
:block(default) —runreturns{:error, %Normandy.Guardrails.ViolationError{}}(run!raises it;Turn.Server.run/2replies the same{:error, exception}) and the answer is not saved.:warn— the violation is logged and the answer is returned and saved. The warning names the guards, the paths and the count (Agent output guardrail violation: ForbiddenSubstrings at chat_message (1 violation)), never the messages, which quote the guard's configuration (a forbidden term, a pattern). Neither the log nor telemetry carries them: only the caller's reply does (theViolationErrorunder:block, the:guardrail_violationstream event).
The option does not apply to streaming, where the content has already reached the caller (see below).
Streaming
Input guardrails run on BaseAgent.stream/3 before the LLM call, as on
BaseAgent.run/3: a violation returns {:error, %Normandy.Guardrails.ViolationError{}}.
Output guardrails run on streaming in one of two modes, configured per agent:
:accumulate(default) — guards run on the concatenated assistant text (text blocks from the streamed response, joined) after the stream completes. A violation is logged and reported through a%Normandy.Components.StreamEvent{type: :guardrail_violation}and telemetry, not blocked: the content has already reached the caller. This differs from the non-streaming path, which guards the validated schema struct: streaming guards the text before it is converted, so field-targeting guards (e.g.{ForbiddenSubstrings, field: :summary}) do not apply in streaming mode. Use bare string guards (no:field) for streaming output.:incremental— guards run everyguardrails[:chunk_size]bytes of accumulated assistant text. On violation, the stream is halted, a:guardrail_violationevent is emitted to the caller callback and telemetry, and any partial tool-use block is dropped; the text streamed so far is converted to the output schema: a:chat_messageschema gets that text; for a non-chat schema, partial text that does not parse into it makesstream/3return{:error, %Normandy.LLMError{type: :invalid_output}}. Follows NVIDIA NeMo Guardrails' chunking approach.Each check runs the guards on the whole text accumulated so far, not on the new chunk, so a pattern that spans two chunks is still seen. The cost grows with the square of the answer's length: an answer of
nbytes is checked aboutn / chunk_sizetimes on texts up tonbytes long (a 100_000-byte answer with the defaultchunk_size: 200runs the guards about 500 times, on 50_000 bytes on average). For long answers or guards that are slow or call a service, raisechunk_sizeor use:accumulate.
RequiredFields is terminal-only and does not apply to either streaming
mode (no schema struct to inspect).
Both modes deliver a :guardrail_violation stream event with data
%{stage: :output, mode: :accumulate | :incremental, violations: violations} and emit
the [:normandy, :agent, :guardrail, :violation] telemetry event with metadata
streaming: true and mode: :accumulate | :incremental.
Telemetry
A [:normandy, :agent, :guardrail, :violation] event is emitted per
violation batch. One event per final-response emission, not per user turn —
with enable_json_retry plus retries, a single turn can emit multiple
output-guardrail events. Its violations are reduced to
%{guard:, path:, constraint:} each: no message, so no forbidden term, pattern or
model text (SemanticScope's reason). The same holds for the :decision event below,
a ViolationError in a run's :exception metadata and a Turn.Server turn state's
persisted error. The full violations reach only the caller: the returned
ViolationError, admit/2,3's {:block, violations} and the stream event.
Normandy.Agents.BaseAgent.admit/2,3 additionally emits
[:normandy, :agent, :guardrail, :decision] with outcome in
:admit | :block | :error, so hosts can monitor admit/block/false-positive
rates on one stream. The in-turn input path emits it too (it delegates to
admit/2); it is suppressed when no guardrails are configured.
Summary
Functions
Runs the guard list against value.
Runs the guard list against value, threading context to guards.
Types
Functions
@spec run([spec()], term()) :: {:ok, term()} | {:error, [Normandy.Guardrails.Guard.violation()]}
Runs the guard list against value.
Returns {:ok, value} if every guard passes, or {:error, [violation]} as
soon as one fails. An empty guard list is always {:ok, value}.
@spec run([spec()], term(), map()) :: {:ok, term()} | {:error, [Normandy.Guardrails.Guard.violation()]}
Runs the guard list against value, threading context to guards.
Behaves exactly like run/2, additionally passing the context map to any
guard that implements the optional Guard.check/3 callback. Guards that
implement only check/2 never see the context and their opts are
unchanged — run/2 is run(guards, value, %{}), so this is purely additive.
Returns {:ok, value} if every guard passes, or {:error, [violation]} as
soon as one fails. An empty guard list is always {:ok, value}.