Guardrails are validation functions that run before (input) and after (output) agent processing. They help ensure:
Safety - Block harmful or inappropriate content
Quality - Enforce response standards
Compliance - Meet business requirements
Security - Detect prompt injection and PII
Guardrails belong to the agent, not to a run: give them to the builder in a GuardrailMiddleware.
1
2
3
4
5
6
7
8
9
10
11
12
importorg.llm4s.agent.Agentimportorg.llm4s.agent.graph.middleware.GuardrailMiddlewarevalagent=Agent.builder("assistant",client).withTools(tools).withMiddleware(GuardrailMiddleware(input=Seq(...),// Validate each query before the LLM calloutput=Seq(...)// Validate each final answer)).build()
A guardrail that refuses does not make run return a Left: the turn ends with
AgentStatus.Blocked(guardrail, reason) - the first failing guardrail’s name, and every failure’s
error - and the thread stays usable, so the next run or continueConversation on it works. The
blocked turn is never kept: an input block stores nothing for that turn, and an output block removes
the whole turn - the query, any tool calls and results, and the answer - so result.messages is the
conversation as it was before the turn and the model never sees the blocked answer again. usage
still counts the blocked turn’s model calls. A guardrail that transforms (PIIMasker) changes the
stored query or answer instead.
Underneath, a block is the graph runtime’s Block: the run ends as a finished failure
(RunResult.Failed with a GuardrailBlocked error, the thread’s checkpoint Failed), which
recover has nothing to continue and start accepts like a completed thread. A Left from another
middleware’s beforeAgent or afterAgent blocks the same way, but is returned as that Left.
A list of guardrails runs completely: each guardrail, in order, on the value the previous one
returned, even after one has failed. That is why the block carries every failure’s error, and why a
guardrail that costs a call - an LLM judge - runs on every turn that reaches its list. To stop at the first
failure, use CompositeGuardrail.sequential (adapted with InputGuardrail.from,
see Using a composite in an agent); the block then names the composite as the
guardrail. Running the whole list is the settled default (#1521): it
reports every reason a value was refused in one block, and an early stop is one composite away, while the opposite
default would hide the other failures with no way to ask for them.
A block sends one durable agent.guardrail_blocked event, naming the first failing guardrail and its phase
(Input or Output), and no event comes from a guardrail that passed or that ran after the first failure.
The run then ends with the kernel’s RunFailed; RunCompleted is never sent for a blocked turn. An input
block comes before any model call. An output block comes after the turn’s ModelCallCompleted events, since
the answer it refuses has been produced. See Streaming for the event stream.
Guardrails on the root agent guard its whole handoff family: they apply to every turn’s query and
every final answer, whichever agent is active after a handoff. See
Guardrails and Middleware Across Handoffs.
Built-in Guardrails
Simple Validators
These guardrails run locally without LLM calls:
Guardrail
Purpose
Example
LengthCheck
Enforce min/max length
new LengthCheck(1, 10000)
ProfanityFilter
Block profane content
new ProfanityFilter()
JSONValidator
Ensure valid JSON output
new JSONValidator()
RegexValidator
Pattern matching
new RegexValidator("\\d{3}-\\d{4}")
ToneValidator
Simple tone detection
new ToneValidator(Tone.Professional)
PIIDetector
Detect PII (email, SSN, etc.)
new PIIDetector()
PIIMasker
Mask detected PII
new PIIMasker()
PromptInjectionDetector
Detect injection attempts
new PromptInjectionDetector()
LLM-as-Judge Guardrails
These use an LLM to evaluate subjective qualities:
Guardrail
Purpose
Example
LLMSafetyGuardrail
Content safety check
new LLMSafetyGuardrail(client)
LLMFactualityGuardrail
Verify factual accuracy
new LLMFactualityGuardrail(client)
LLMQualityGuardrail
Assess response quality
new LLMQualityGuardrail(client)
LLMToneGuardrail
Validate tone compliance
new LLMToneGuardrail(client, "professional")
Each judge scores the content and passes it when the score reaches its threshold. The threshold must be between 0.0 and 1.0: for any other value, or NaN, validate returns a ValidationError on the field threshold without calling the judge.
importorg.llm4s.agent.{Agent,AgentStatus}importorg.llm4s.agent.graph.middleware.GuardrailMiddlewareimportorg.llm4s.agent.guardrails.builtin._valresult=for{agent<-Agent.builder("assistant",client).withTools(tools).withMiddleware(GuardrailMiddleware(input=Seq(newLengthCheck(min=1,max=10000),newProfanityFilter(),newPromptInjectionDetector()),output=Nil)).build()result<-agent.run(userInput)}yieldresultresultmatch{caseRight(r)=>r.statusmatch{caseAgentStatus.Blocked(guardrail,reason)=>println(s"Input rejected by $guardrail: $reason")caseAgentStatus.Completed(answer)=>println(answer)caseother=>println(s"Run ended: $other")}caseLeft(error)=>println(s"Run failed: ${error.message}")}
Output Validation
1
2
3
4
5
6
valagent=Agent.builder("assistant",client).withTools(tools).withMiddleware(GuardrailMiddleware(input=Nil,output=Seq(newJSONValidator(),newPIIMasker()))).build()valresult=agent.flatMap(_.run("Generate a JSON response with user data"))
importorg.llm4s.agent.guardrails.builtin.LLMSafetyGuardrailvalsafetyGuardrail=newLLMSafetyGuardrail(client)valagent=Agent.builder("storyteller",client).withMiddleware(GuardrailMiddleware(input=Nil,output=Seq(safetyGuardrail))).build()agent.flatMap(_.run("Write a story"))
Factuality Check
Verify responses are grounded in source documents:
1
2
3
4
5
6
7
8
9
10
11
12
importorg.llm4s.agent.guardrails.builtin.LLMFactualityGuardrailvalfactualityGuardrail=LLMFactualityGuardrail.strict(client=client,referenceContext="The capital of France is Paris. Paris has a population of 2.1 million.")valagent=Agent.builder("geography",client).withMiddleware(GuardrailMiddleware(input=Nil,output=Seq(factualityGuardrail))).build()agent.flatMap(_.run("What is the capital of France?"))
Tone Validation
1
2
3
4
5
6
7
8
9
10
11
12
importorg.llm4s.agent.guardrails.builtin.LLMToneGuardrailvaltoneGuardrail=newLLMToneGuardrail(llmClient=client,allowedTones=Set("professional","helpful"))valagent=Agent.builder("support",client).withMiddleware(GuardrailMiddleware(input=Nil,output=Seq(toneGuardrail))).build()agent.flatMap(_.run("Help with customer complaint"))
Composite Guardrails
Combine multiple guardrails with different strategies:
All Must Pass (AND)
1
2
3
4
5
6
7
8
9
importorg.llm4s.agent.guardrails.CompositeGuardrailvalstrictValidation=CompositeGuardrail.all(Seq(newLengthCheck(1,5000),newProfanityFilter(),newPIIDetector()))// All guardrails must pass for input to be accepted
Any Must Pass (OR)
1
2
3
4
5
6
valflexibleValidation=CompositeGuardrail.any(Seq(newRegexValidator("^[A-Z].*"),// Starts with capitalnewRegexValidator("^\\d.*")// Starts with digit))// At least one guardrail must pass
Sequential (Short-Circuit)
1
2
3
4
5
6
7
valsequentialValidation=CompositeGuardrail.sequential(Seq(newLengthCheck(1,10000),// Check length firstnewProfanityFilter(),// Then profanitynewPIIDetector()// Then PII))// Stops at first failure, more efficient
Using a composite in an agent
A composite is a Guardrail[String], while GuardrailMiddleware takes InputGuardrails and
OutputGuardrails, so adapt it with InputGuardrail.from or OutputGuardrail.from. A cast does not work: it
throws a ClassCastException.
from keeps the guardrail’s name and description and passes the hook’s context on, so an LLM judge inside the
composite has its model calls counted in the thread’s usage (a hand-written wrapper that overrides only
validate(value) loses that). An InputGuardrail passed to InputGuardrail.from comes back unchanged.
all runs every guardrail and reports every failure; any stops at the first guardrail that passes, so
those after it do not run; sequential stops at the first failure.
importorg.llm4s.agent.guardrails.InputGuardrailimportorg.llm4s.error.ValidationErrorimportorg.llm4s.types.ResultclassKeywordRequirementGuardrail(requiredKeywords:Set[String])extendsInputGuardrail{valname:String="keyword-requirement"defvalidate(value:String):Result[String]={valfound=requiredKeywords.filter(kw=>value.toLowerCase.contains(kw.toLowerCase))if(found.nonEmpty){Right(value)}else{Left(ValidationError.invalid("input",s"Input must contain at least one of: ${requiredKeywords.mkString(",")}"))}}}// Usagevalguardrail=newKeywordRequirementGuardrail(Set("scala","java","kotlin"))
Custom Output Guardrail
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
importorg.llm4s.agent.guardrails.OutputGuardrailimportorg.llm4s.error.ValidationErrorimportorg.llm4s.types.ResultclassMaxSentenceCountGuardrail(maxSentences:Int)extendsOutputGuardrail{valname:String="max-sentence-count"defvalidate(value:String):Result[String]={valsentenceCount=value.split("[.!?]+").lengthif(sentenceCount<=maxSentences){Right(value)}else{Left(ValidationError.invalid("output",s"Response has $sentenceCount sentences, max allowed is $maxSentences"))}}}
Custom LLM-Based Guardrail
1
2
3
4
5
6
7
8
9
importorg.llm4s.agent.guardrails.LLMGuardrailimportorg.llm4s.llmconnect.LLMClientclassCustomLLMGuardrail(client:LLMClient)extendsLLMGuardrail{valname:String="custom-llm-check"valllmClient:LLMClient=clientvalevaluationPrompt:String="Rate if this content is appropriate for a children's website (0-1)"overridevalthreshold=0.8}
// Verify answer is grounded in retrieved contextvalgrounding=newGroundingGuardrail(client=client,retrievedContext=retrievedDocuments)// Check retrieved context is relevant to queryvalrelevance=newContextRelevanceGuardrail(client=client,query=userQuery)// Ensure sources are properly citedvalattribution=newSourceAttributionGuardrail(client=client,sourceDocuments=sources)// Prevent off-topic responsesvaltopicBoundary=newTopicBoundaryGuardrail(client=client,allowedTopics=Set("programming","software engineering"))
importorg.llm4s.agent.guardrails.builtin.PIIMaskervalpiiMasker=newPIIMasker()// Replaces PII with [REDACTED_EMAIL], [REDACTED_SSN], etc. - the stored answer is the masked onevalagent=Agent.builder("assistant",client).withTools(tools).withMiddleware(GuardrailMiddleware(input=Nil,output=Seq(piiMasker))).build()agent.flatMap(_.run("Get user details"))
Supported PII Types
Type
Pattern
Masked As
Email
user@domain.com
[REDACTED_EMAIL]
SSN
123-45-6789
[REDACTED_SSN]
Credit Card
4111-1111-1111-1111, 3782 822463 10005 (15-digit American Express)
[REDACTED_CARD]
Phone
(555) 123-4567, +44 20 7946 0958
[REDACTED_PHONE]
IP Address
192.168.1.1
[REDACTED_IP]
A phone number is a US number (with an optional +1 or 1 prefix) or an international number that starts with +
and has 8 to 15 digits, with spaces, dashes, dots or parentheses between them. Digits without a + are not treated
as an international number. A card number is recognised by its shape (a Visa, MasterCard, Amex or Discover prefix and
the right number of digits); the check digit is not validated. When two types match overlapping text (a plain card
number is also account-shaped, and a phone number can run into the email address after it), the overlapping text is
masked once, under the type whose match starts first. Detection does not merge them: detect returns one match per
type, so summarize counts that text once for each type (123456789 gives SSN -> 1, Passport -> 1 with
PIIType.all), and PIIDetector’s block message lists both types. A finding is one match of one type, so the sum of
the counts is how often the patterns fired, not how many stretches of PII the text holds.
Prompt Injection Protection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
importorg.llm4s.agent.guardrails.builtin.PromptInjectionDetectorvalinjectionDetector=newPromptInjectionDetector()valagent=Agent.builder("assistant",client).withTools(tools).withMiddleware(GuardrailMiddleware(input=Seq(injectionDetector),output=Nil)).build()agent.flatMap(_.run(userInput))// Detects patterns like:// - "Ignore previous instructions..."// - "System: You are now..."// - "---\nNew instructions:"// - Base64 encoded payloads
Error Handling
Blocked Turns
A block is an outcome, not an error: run returns Right with AgentStatus.Blocked, the blocked
turn absent from result.messages, and the thread can take another turn.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
agent.run(query)match{caseRight(result)=>result.statusmatch{caseAgentStatus.Blocked(guardrail,reason)=>println(s"Guardrail '$guardrail' blocked the turn: $reason")// Take appropriate action (rephrase, notify user, log); agent.continueConversation(result, ...) still workscaseAgentStatus.Completed(answer)=>println(answer)caseother=>println(s"Run ended: $other")}caseLeft(error)=>// a provider error, a tool's failure, a blank query, or another middleware's beforeAgent/afterAgent Leftprintln(s"Run failed: ${error.message}")}
Validation Mode
Control how guardrail results are handled:
1
2
3
4
5
6
7
8
9
10
importorg.llm4s.agent.guardrails.ValidationMode// Block on failure (default)valblocking=ValidationMode.Block// Warn only, continue processingvalwarn=ValidationMode.Warn// Log and continuevallog=ValidationMode.Log
Best Practices
1. Layer Your Guardrails
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Fast, local checks firstvalinput=Seq(newLengthCheck(1,10000),// Cheapest firstnewProfanityFilter(),// Still fastnewPromptInjectionDetector(),// Pattern matchingnewPIIDetector()// More complex but local)// LLM checks for output only (expensive)valoutput=Seq(newJSONValidator(),// Fast, localnewLLMSafetyGuardrail(client)// Expensive, last)valagent=Agent.builder("assistant",client).withMiddleware(GuardrailMiddleware(input,output)).build()