LLMGuardrail

org.llm4s.agent.guardrails.LLMGuardrail
See theLLMGuardrail companion object

Base trait for LLM-based guardrails (the LLM-as-Judge pattern).

A judge guardrail asks a language model to rate the content against natural-language criteria and passes the content when the rating reaches a threshold. It suits subjective qualities (tone, factual support, safety in context) that deterministic rules cannot express. For plain word, pattern or length checks prefer the rule-based guardrails (ProfanityFilter, RegexValidator, PIIDetector, LengthCheck): they are free, instant and repeatable.

Side: output only. LLMGuardrail extends OutputGuardrail and not InputGuardrail, so it belongs in the output list of a GuardrailMiddleware, where it judges the agent's final answer.

What one validation does: exactly one synchronous llmClient.complete call, made on the calling thread even when the content is empty. The request holds two messages:

  • a fixed system message that asks for a bare number between 0 and 1;
  • a user message made of evaluationPrompt and the content between triple quotes.

Cost, latency and privacy: every validation adds one llmClient.complete call to the run. With a plain provider client that is one round trip and its token cost; a wrapping client changes the count - CachingLLMClient first embeds the system and user messages (an embedding request that sends the judged content to its embedding provider, hit or miss) and on a cache hit skips only the completion request, and ReliableClient can retry a failed request or reject the call with its circuit breaker open. Either way these guardrails do not belong on latency-sensitive paths. The content being judged is sent to whichever provider llmClient talks to, including any embedding provider a caching wrapper uses. A cheaper or separate model can serve as judge, which also avoids a model grading its own answer.

Scoring: a reply is a score only when the whole reply is one plain decimal number from 0 to 1, optionally labelled Score:, and the content passes when the score reaches threshold (a score equal to the threshold passes). Any other reply is refused as unreadable and fails the guardrail; a number is never clamped into range, because a clamped value would read as 1.0 for the usual mistakes (a 0 to 100 answer, a fraction, a percentage) and approve the content. The rules, in full:

  • Accepted: 0.9, .5, 0, 1, 1.0, with surrounding whitespace or a trailing newline, with markdown emphasis, quotes, brackets or code-fence backticks before or after the number (**0.9**, "0.9", ```0.9```), and with a Score: label in any case before it (Score: 0.9, score:0.9, **Score:** 0.9). The wrapping characters before and after the number are read independently and need not match or balance, so ((0.9 and 0.9 * are accepted too; they carry no meaning either way.
  • Refused, as unreadable: everything else. That includes a number outside 0 to 1 (85, 1.5); a sign, glued or apart (-0.5, - 1, negative 1, +0.5); a percentage, fraction, exponent or any scale, as a sign or in words (85%, 1 %, 1 percent, 1 per ten thousand, 100 bps, 8/10, 0.7 out of 1, 1e-3); a decimal comma (0,9); a trailing full stop (0.85.); any other label or sentence (Rating: 0.9, The score is 0.9, 0.9, because ...); more than one number; and a reply with no number. The grammar lists what is accepted rather than what is refused, so a scale or sign written in a form no one anticipated is refused too.
  • The edges of the grammar: the label is strict, so emphasis may wrap Score: as a whole (**Score:** 0.9) but not split it (**Score**: 0.9 is refused); a code fence is accepted only bare, so a fence with a language tag (text after the opening backticks, on the line before 0.7) is refused, the tag being a word the grammar does not admit; and the number is at most 64 characters, so a reply of thousands of digits is refused before it is parsed.
  • The score is compared at the precision the judge wrote it, and the threshold as the decimal it is written as: 1.0000000000000001 is out of range, and 0.79999999999999999 does not reach a threshold of 0.8, though both would round to the bound as a Double.
  • The judge is not asked to be lenient: the fixed system message asks for only a number between 0 and 1. Before this rule the reply was reduced to its digits and dots and clamped, so 85, 85%, 8/10, 1e-3 and 0,9 all read as 1.0 and passed any threshold up to 1.0, and 0.7 out of 1 read as 0.71.

Failures are always Left; an error never lets content through:

  • A threshold outside 0.0 to 1.0, or NaN: a org.llm4s.error.ValidationError on field threshold, before any judge call: LLM judge threshold must be between 0.0 and 1.0, got <value> for <name>.
  • Below the threshold: a ValidationError on field output whose message names this guardrail, the score and the threshold to two decimals: LLM judge score (0.40) below threshold (0.70) for <name>. It does not say why the judge scored the content low. Because of the rounding, a score of 0.69999 against a threshold of 0.7 reads 0.70 below 0.70.
  • An unreadable reply: a ValidationError on field llm_response that quotes the reply.
  • A failing llmClient.complete (network, rate limit, authentication): the client's own error, returned unchanged.

Inside an agent run a failing guardrail blocks the run: GuardrailMiddleware reports the name of the first failing guardrail together with the failure messages.

Limits: validate is not pure here, unlike the contract of Guardrail: it performs a network call, and the same content can score differently between calls (the default temperature of 0.0 reduces this but does not remove it). The content is inserted into the prompt as is, without escaping, so text inside it that addresses the judge can sway the score: treat the result as a probabilistic filter, not a security boundary. threshold must be between 0.0 and 1.0: any other value, or NaN, makes validate fail before the judge is called (see threshold). A threshold of 0.0 is accepted and passes every reply that parses as a score.

@note The default completionOptions cap the judge's reply at 10 tokens. Override them if your judge model needs more room than a bare number.

@example

class MyCustomLLMGuardrail(client: LLMClient) extends LLMGuardrail {
 val llmClient = client
 val evaluationPrompt = "Rate if this response is helpful (0-1)"
 override val threshold = 0.7
 val name = "HelpfulnessGuardrail"
}

Attributes

Companion
object
Graph
Supertypes
trait Guardrail[String]
class Object
trait Matchable
class Any
Known subtypes

Members list

Value members

Abstract methods

def evaluationPrompt: String

The evaluation criteria in natural language. It is sent after Evaluation criteria: and before the content, which goes between triple quotes. The fixed system message already asks for a bare number between 0 and 1; say in the criteria what 0 and 1 mean, because a reply on any other scale is refused as unreadable and fails the guardrail (see the scoring notes on the trait).

The evaluation criteria in natural language. It is sent after Evaluation criteria: and before the content, which goes between triple quotes. The fixed system message already asks for a bare number between 0 and 1; say in the criteria what 0 and 1 mean, because a reply on any other scale is refused as unreadable and fails the guardrail (see the scoring notes on the trait).

Attributes

Example

"Rate if this response is professional in tone. Return only a number between 0 and 1."

The client that makes the judge call. It can be the agent's own client or a different one; a separate, cheaper model is a common choice. Every validation makes one blocking complete call on it.

The client that makes the judge call. It can be the agent's own client or a different one; a separate, cheaper model is a common choice. Every validation makes one blocking complete call on it.

Attributes

Concrete methods

The options of the judge call. The default is temperature 0.0, which makes the judge as repeatable as the model allows, and a maximum of 10 tokens, enough for a bare number. Override to give a judge model more room or to set other options.

The options of the judge call. The default is temperature 0.0, which makes the judge as repeatable as the model allows, and a maximum of 10 tokens, enough for a bare number. Override to give a judge model more room or to set other options.

Attributes

def threshold: Double

The lowest score that passes (a score equal to it passes). The default is 0.7. It must be between 0.0 and 1.0, both included: validate returns a org.llm4s.error.ValidationError on field threshold, without calling the judge, for a value outside that range (including infinities) or NaN. It is checked when validate runs, not when the guardrail is built, so the constructors and factories keep their signatures and a subclass that overrides it is checked too. A threshold of 0.0 passes every reply that parses as a score. It is not a probability or a confidence: it is a cut-off on whatever number the judge returns.

The lowest score that passes (a score equal to it passes). The default is 0.7. It must be between 0.0 and 1.0, both included: validate returns a org.llm4s.error.ValidationError on field threshold, without calling the judge, for a value outside that range (including infinities) or NaN. It is checked when validate runs, not when the guardrail is built, so the constructors and factories keep their signatures and a subclass that overrides it is checked too. A threshold of 0.0 passes every reply that parses as a score. It is not a probability or a confidence: it is a cut-off on whatever number the judge returns.

Attributes

override def validate(value: String): Result[String]

Judges value with one LLM call and compares the score with threshold.

Judges value with one LLM call and compares the score with threshold.

Value parameters

value

the text to judge, typically the agent's final answer

Attributes

Returns

Right(value) unchanged when the score reaches the threshold; otherwise Left: a org.llm4s.error.ValidationError on field threshold when the threshold is not between 0.0 and 1.0 (no judge call is made), on field output when the score is too low, on field llm_response when the reply has no readable score, or the client's own error when the call fails

Definition Classes
override def validate(value: String, context: ModelContext): Result[String]

As validate(value), the judge called through context.accounted(llmClient), so its call is counted.

As validate(value), the judge called through context.accounted(llmClient), so its call is counted.

Attributes

Definition Classes

Inherited methods

def andThen(other: Guardrail[String]): Guardrail[String]

Compose this guardrail with another sequentially.

Compose this guardrail with another sequentially.

The second guardrail runs only if this one passes.

Value parameters

other

The guardrail to run after this one

Attributes

Returns

A composite guardrail that runs both in sequence

Inherited from:
Guardrail
def description: Option[String]

Optional description of what this guardrail validates.

Optional description of what this guardrail validates.

Attributes

Inherited from:
Guardrail
def transform(output: String): String

Optional: Transform the output after validation. Default is identity (no transformation).

Optional: Transform the output after validation. Default is identity (no transformation).

Value parameters

output

The validated output

Attributes

Returns

The transformed output

Inherited from:
OutputGuardrail

Inherited and Abstract methods

def name: String

Name of this guardrail for logging and error messages.

Name of this guardrail for logging and error messages.

Attributes

Inherited from:
Guardrail