Judge
JudgeCoordinator in com.tnsai.coordination.judge runs a piece of work, has a JudgePolicy check the output, and runs it again with the judge's suggestions until the output is approved, rejected or out of attempts. It judges one agent's output at a time; it does not rank several candidates against each other.
JudgePolicy policy = LLMJudgePolicy.builder()
.llmClient(llmClient)
.criteria("Evaluate for correctness, completeness, and clarity.")
.build();
JudgeCoordinator<String> coordinator = JudgeCoordinator.<String>builder()
.policy(policy)
.maxRetries(3)
.timeout(Duration.ofSeconds(30))
.executor((agentId, feedback) -> produceOutput(agentId, feedback))
.build();
JudgeCoordinator.Result<String> result = coordinator.execute("agent-1");
if (result.isApproved()) {
System.out.println(result.output());
}The judging loop
execute(agentId) runs up to maxRetries attempts. Each attempt:
- Calls the executor with the agent id and a list of suggestions. The list is empty on the first attempt and holds the previous verdict's suggestions after that.
- Passes the output to the policy, which returns a
JudgeVerdict. - Stops on
APPROVEorREJECT. OnRETRYit moves to the next attempt.
If the executor throws, the attempt counts as a RETRY with the suggestion "Fix execution error: …", or as a REJECT on the last attempt. An exception thrown by the policy itself is not caught and leaves execute.
| Builder method | Default | Meaning |
|---|---|---|
policy(policy) | required | Judges each output |
executor((agentId, feedback) -> output) | required | Produces the output |
maxRetries(n) | 3 | Attempts in total, including the first; must be at least 1 |
timeout(duration) | none | Time limit for the whole loop |
The timeout is checked before each attempt and does not interrupt a running executor. Once it has passed, the loop stops with a REJECT verdict whose reason starts with "Timed out after".
Result
| Accessor | Meaning |
|---|---|
output() | The approved output. Otherwise the output with the highest score so far, or null if no output was judged |
finalVerdict() | The last verdict |
verdictHistory() | One verdict per attempt, plus the timeout verdict if there was one |
iterations() | Attempts made |
duration() | Time the loop took |
isApproved(), score() | Read from the final verdict |
When the attempts run out, the final verdict is the last one returned, so it can still be RETRY if a custom policy returns RETRY on the last attempt. The built-in policies return REJECT there instead.
Verdicts and context
A JudgeVerdict has a decision (APPROVE, RETRY or REJECT), a reason, a score between 0 and 1, and a list of suggestions. A score outside 0–1 throws IllegalArgumentException. Create one with JudgeVerdict.approve(reason, score), retry(reason, score, suggestions) or reject(reason, score, suggestions).
The policy also receives a JudgeContext:
| Component | Meaning |
|---|---|
iteration() | The attempt number, starting at 1 |
maxIterations() | The coordinator's maxRetries |
previousVerdicts() | The verdicts of earlier attempts |
timeBudget() | Always null when the coordinator builds the context |
isFinalIteration() | True on the last attempt |
JudgeContext.first(maxIterations) builds the context for a first attempt, which is useful for calling a policy directly.
Policies
JudgePolicy is a functional interface with one method, evaluate(agentId, output, context), so a lambda works as a custom policy. There are two built-ins.
Threshold policy
new ThresholdJudgePolicy<>(threshold, outputType, scoringFunction) scores the output with your function, clamps the score to 0–1, and approves when it reaches the threshold. Below the threshold it returns RETRY, or REJECT on the last attempt. A null output or an output of the wrong type gets a score of 0. It makes no LLM call, which suits tests and deterministic gates.
JudgePolicy policy = new ThresholdJudgePolicy<>(
0.8,
String.class,
output -> score(output)
);
JudgeVerdict verdict = policy.evaluate(
"agent-1",
"draft answer",
JudgeContext.first(3)
);
if (verdict.isApproved()) {
System.out.println(verdict.score());
}LLM policy
LLMJudgePolicy.builder() takes an llmClient and your criteria; both are required. The prompt gives the model the agent id, the attempt number, the criteria, the reasons from earlier attempts and the output, and asks for four lines: DECISION, SCORE, REASON and SUGGESTIONS. Suggestions are split on commas.
The answer is parsed as plain text:
- Without a recognisable
DECISION, or with an empty answer, the verdict isRETRYwith score 0.5, orREJECTon the last attempt. - A missing
SCOREbecomes 0.5. - A
RETRYon the last attempt becomesREJECT. - If the LLM call fails, the verdict is
RETRY, orREJECTon the last attempt.
Judging a team
The executor can run anything, including a whole group. Here a Team runs the task and the judge checks its TeamResult.
Team team = Team.builder()
.name("Draft team")
.mission(task)
.formation(TeamFormation.PARALLEL)
.member(agent1, TeamRole.MEMBER)
.member(agent2, TeamRole.MEMBER)
.build();
team.start();
JudgeCoordinator<TeamResult> coordinator = JudgeCoordinator.<TeamResult>builder()
.policy(LLMJudgePolicy.builder()
.llmClient(llmClient)
.criteria("Approve only if the aggregated team output is complete and accurate.")
.build())
.maxRetries(3)
.executor((agentId, feedback) -> team.execute(task))
.build();
JudgeCoordinator.Result<TeamResult> judged = coordinator.execute(team.getId());
TeamResult teamResult = judged.output();