Skip to content
tnsaiDocs
GitHub

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:

  1. 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.
  2. Passes the output to the policy, which returns a JudgeVerdict.
  3. Stops on APPROVE or REJECT. On RETRY it 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 methodDefaultMeaning
policy(policy)requiredJudges each output
executor((agentId, feedback) -> output)requiredProduces the output
maxRetries(n)3Attempts in total, including the first; must be at least 1
timeout(duration)noneTime 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

AccessorMeaning
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:

ComponentMeaning
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 is RETRY with score 0.5, or REJECT on the last attempt.
  • A missing SCORE becomes 0.5.
  • A RETRY on the last attempt becomes REJECT.
  • If the LLM call fails, the verdict is RETRY, or REJECT on 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();