Judges Are Like Unit Tests for Agents
The core analogy: just as JUnit gives youassertEquals and AssertJ gives you assertThat, Agent Judge gives you FileExistsJudge, BuildSuccessJudge, and CorrectnessJudge.
You wouldn’t ship application code without tests or assertions.
Agents need the same discipline — automated, repeatable evaluation that runs after every execution and catches regressions before they reach users.
This framing drives several design decisions:
- Judges should be cheap to write (one functional interface, one method)
- Judges should be cheap to run (deterministic judges cost nothing)
- Judges should compose (juries aggregate judges like test suites aggregate tests)
- Results should be actionable (reasoning and checks, not just pass/fail)
Zero-Dependency Core
agent-judge-core has no external dependencies. Not Spring, not Spring AI, not Jackson — nothing.
This means you can evaluate agent output in:
- A plain Java application
- A JUnit test
- A CLI tool
- A Spring Boot service
- A serverless function
Functional Interface Discipline
Judge is a @FunctionalInterface with a single method and no default methods:
- Lambdas work:
ctx -> Judgment.pass("ok") - Method references work:
this::evaluateBuild - Composition uses the
Judgesutility class, not interface default methods
NamedJudge wraps any judge) and the JudgeWithMetadata marker interface, not through method defaults on Judge itself.
This avoids the combinatorial explosion of default method interactions and keeps the core contract minimal.
Sealed Score Hierarchy
Score is a sealed interface with three implementations:
switch expression over Score will warn you if you miss a case.
This matters when aggregating heterogeneous scores in a jury.
The three types cover the evaluation spectrum:
The
Scores utility handles cross-type normalization so a jury with mixed score types can still aggregate cleanly.
Cascaded Cost Model
Not all evaluation is equal cost. A typical cascade orders checks from cheapest and most decisive to most expensive:
The
CascadedJury codifies this: fail fast on cheap checks, escalate only when necessary.
Frameworks Are Vertical, Evaluation Is Horizontal
Agent runtimes are vertical stacks — Spring AI, LangChain4j, Koog, and CLI-delegated agents (via AgentClient) each provide their own execution model, memory, tool calling, and observability. Evaluation cuts across all of them.FaithfulnessJudge doesn’t care whether the answer came from a Spring AI ChatClient, a LangChain4j AiService, a Koog agent, or Claude Code via AgentClient.
It evaluates the (question, context, answer) triple the same way.
This is the core architectural bet: evaluation is framework-neutral, and the bridge layer is thin.
Adapter Module Architecture
Each framework bridge module follows the same pattern:- Provided-scope dependency on the framework — the bridge doesn’t pull the framework into your classpath; you already have it.
- JudgmentContextBuilder — a static utility that converts framework-specific output (
ChatResponse,Result<T>,AIAgent,AgentClientResponse) into aJudgmentContext. - Evaluator — a static convenience class with Judge/Jury overloads, including variants that accept extra metadata, combining execution + context building + evaluation into a one-liner.
- Metadata key conventions — public constants where available (
SpringAiMetadataKeys,AgentClientMetadataKeys), and documented metadata keys for values such as model name, finish reason, token usage, sources, or agent ID.
JudgmentContext.metadata() where judges can optionally inspect it.
This keeps the core zero-dependency, keeps bridges thin, and means new framework support is a single module addition — not a core change.
Composition Over Inheritance for AI Judges
The originalLLMJudge uses the template method pattern — you subclass it, override buildPrompt() and parseResponse(), and the base class handles the LLM call via Spring AI’s ChatClient. This works, but it tangles three concerns into one class hierarchy:
- Prompt rendering — how context becomes a prompt string
- Model invocation — which AI backend to call
- Response classification — how to turn the model’s text into a
Judgment
ChatClient.Builder or mocking Spring AI internals.
The agent-judge-ai-core module separates these into composable parts:
ModelBackedJudge wires the three parts together via a builder. Each part is independently testable and replaceable:
JudgePromptTemplateloads templates from classpath, file, or string. Renders{{variable}}placeholders fromJudgmentContext. Validates required variables at build time.JudgeModelis a@FunctionalInterface— any(JudgeModelRequest → JudgeModelResponse)lambda works.SpringAiJudgeModel(inagent-judge-llm) delegates to Spring AI’sChatClient.AgentClientJudgeModel(inagent-judge-agent-client) invokes a CLI agent that can use tools and inspect files — enabling agentic judges.JudgmentClassifiermaps text to aJudgment.LabelJudgmentClassifier.passFail()handles the common binary case. Custom classifiers handle structured or multi-label responses.
agent-judge-core, the agent-judge-ai-core module has zero external dependencies. The actual AI backend arrives through a JudgeModel implementation from a bridge module. This preserves the zero-dep principle while giving AI judges first-class infrastructure.
When to use which:
Best-of-Breed Evaluation Patterns
Agent Judge borrows from patterns that have emerged across modern evaluation systems, including Python-first eval frameworks, SaaS evaluation platforms, and JVM projects such as Dokimos. The goal is not to clone any one framework. It is to bring the strongest ideas into a JVM-native, framework-neutral library:
Agent Judge is intentionally JVM-native rather than a thin wrapper around a Python eval stack: it understands workspaces, Maven builds, Java source structure, typed records, sealed scores, and Java framework integration points.
Judge vs Journal
Agent Judge draws a sharp boundary between inputs to judges and narrative trace. Judges legitimately reason about:- Token usage, tool executions, retrieved sources — these are structured outputs that affect verdict logic
- Finish reason, execution status, timing — these determine whether evaluation is even meaningful
- Intermediate responses, full conversation history, private reasoning traces, or step-by-step narrative logs — this is cognitive observability, not evaluation input
JudgmentContext contract framework-specific — exactly what the horizontal layer avoids.
Agent-Agnostic by Design
JudgmentContext doesn’t import any agent framework.
It describes what happened (goal, workspace, status, timing) without coupling to how it happened.
The workspace-centric pattern:
- An agent modifies a directory
- A judge inspects the directory
- The judge doesn’t know or care which agent made the changes
Immutable Records
Most evaluation data types are Java records:Judgment, Verdict, Check, JudgmentContext, and JudgeMetadata. Score is a sealed interface with record implementations (BooleanScore, NumericalScore, CategoricalScore).
Records are:
- Immutable — no accidental mutation between judges
- Value-based — equality by content, not identity
- Pattern-matchable —
if (score instanceof NumericalScore(var v, var min, var max)) - Easy to serialize — record components map cleanly to JSON/logging formats without requiring serialization dependencies in core