Skip to content
tnsaijava agent framework

Judge Agent Pattern

JudgeCoordinator wraps a coordination cycle with a JudgePolicy. After each attempt the policy returns a JudgeVerdict of APPROVE, RETRY, or REJECT. This is cycle-end quality control for one agent's output, not a tournament that ranks several candidates. Package: com.tnsai.coordination.judge.

Quick Start

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());
}

LLMJudgePolicy has no public constructor. The builder accepts only llmClient and criteria. Scores are always in [0.0, 1.0].

JudgePolicy

JudgePolicy is a functional interface. JudgeCoordinator calls it once per iteration:

ParameterTypeRole
agentIdStringAgent whose output is being judged
outputObjectCycle output (type depends on the executor)
contextJudgeContextIteration number, history, and time budget

The return type is JudgeVerdict, not a ranked list of candidates. There is no CandidateOutput, JudgeResult, or ScoredCandidate type. Built-in policies are constructed in code; they are not loaded from META-INF/services.

ThresholdJudgePolicy

Numeric gate. The caller supplies the output class and a scoring function. The policy approves when the score is at least the threshold; otherwise it retries, or rejects on the final iteration.

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());
}

Best for: deterministic CI gates and tests that must not call an LLM.

LLMJudgePolicy

Sends one output to an LLM with your natural-language criteria. The model must answer in a fixed DECISION / SCORE / REASON / SUGGESTIONS shape. The only builder methods are llmClient(LLMClient) and criteria(String).

ParameterDefaultDescription
llmClientrequiredLLMClient used for evaluation
criteriarequiredNatural-language quality criteria

There is no llm(), scoringScale(), requireReasoning(), or model() setter.

JudgeCoordinator

JudgeCoordinator<T> runs the executor, judges the output, and retries with the previous verdict's suggestions until the policy approves, rejects, or maxRetries is exhausted.

ParameterDefaultDescription
policyrequiredJudgePolicy used after each attempt
executorrequired(agentId, feedback) -> T. Feedback is empty on the first call
maxRetries3Total attempts, including the first
timeoutunsetOptional bound for the whole cycle

execute(String agentId) is the only run method. There is no evaluate(task, candidates) and no runAndJudge(...).

Result

JudgeCoordinator.Result<T> is the value returned by execute:

AccessorMeaning
output()Best output produced, or null if every attempt failed
finalVerdict()Last JudgeVerdict
verdictHistory()Verdict from every iteration
iterations()Attempt count
duration()Wall time for the cycle
isApproved()Whether finalVerdict() is APPROVE
score()finalVerdict().score()

JudgeVerdict

ComponentMeaning
decision()APPROVE, REJECT, or RETRY
reason()Human-readable explanation
score()Quality in [0.0, 1.0]
suggestions()Feedback for the next retry (empty on approve)
isApproved()decision() == APPROVE

Factories: JudgeVerdict.approve(reason, score), retry(...), reject(...).

JudgeContext

ComponentMeaning
iteration()1-based attempt number
maxIterations()Same bound as maxRetries
previousVerdicts()Earlier verdicts (empty on the first attempt)
timeBudget()Optional remaining budget
isFinalIteration()iteration >= maxIterations

JudgeContext.first(maxIterations) builds the first attempt.

Integration with Team

Use JudgeCoordinator around Team.execute. Team.execute returns TeamResult, not String. Team.builder() requires a mission and adds members with member(...), not addMember(...).

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();

For group shapes themselves, see Topologies.