Council and voting
Two ways to let several participants reach one answer. CouncilExecutor asks several models the same question, has them rank each other's answers, and lets a chairman model write the final answer. ConsensusBuilder and GroupDecisionFramework ask agents to vote on a fixed list of options.
Council
CouncilExecutor in com.tnsai.coordination.council works with LLMClient instances, not agents. deliberate(question) runs three stages:
- Answers. Every member gets the question and answers it on its own virtual thread. A member that throws, or has not answered when the timeout ends, has no answer.
- Reviews. Every member, including one that failed stage 1, gets all the successful answers and is asked to evaluate them and end with a line such as
FINAL RANKING: Model C, Model A, Model B. Reviewers also see and rank their own answer. - Synthesis. The chairman gets the answers with their labels and real names, the reviews under the reviewers' real names, and the aggregate ranking, and writes the final answer.
If no member answers in stage 1, stages 2 and 3 are skipped and the final answer is Council deliberation failed: no responses collected.
Building a council
| Builder method | Default | Meaning |
|---|---|---|
members(list), addMember(client) | required | At least 2 members, otherwise build() throws IllegalArgumentException. members replaces the list. |
chairman(client) | required | Writes the final answer. It can also be a member. |
anonymize(boolean) | true | Reviewers see Model A, Model B and so on instead of model names |
timeout(duration) | 5 minutes | Applies to stage 1 and to stage 2 separately. Stage 3 has no timeout. |
Members are identified by getModel(). Give each member a different model name: two members with the same name share one label, and one of their answers is lost from the reviews.
package com.example.tnsai.docs;
import com.tnsai.coordination.council.CouncilExecutor;
import com.tnsai.coordination.council.CouncilResponse;
import com.tnsai.coordination.council.CouncilResult;
import com.tnsai.coordination.council.CouncilReview;
import com.tnsai.llm.LLMClient;
import java.time.Duration;
import java.util.List;
/** Compilation contract for multi-agent/council.md council. */
public final class CouncilExample {
private CouncilExample() {
}
static String deliberate(List<LLMClient> members, LLMClient chairman) {
CouncilExecutor council = CouncilExecutor.builder()
.members(members) // at least 2, each with a different model name
.chairman(chairman)
.anonymize(true) // reviewers see "Model A", "Model B", ...
.timeout(Duration.ofMinutes(3)) // applies to stage 1 and to stage 2
.build();
CouncilResult result = council.deliberate("How should we architect the auth system?");
for (CouncilResponse response : result.stage1Responses()) {
System.out.println(response.llmName() + (response.isSuccess() ? " answered" : " failed")
+ " in " + response.durationMs() + " ms");
}
for (CouncilReview review : result.stage2Reviews()) {
if (review.hasRanking()) {
System.out.println(review.reviewerName() + " ranked " + review.parsedRanking());
}
}
result.topRanked().ifPresent(top ->
System.out.println("Top: " + top.llmName() + " (average rank " + top.averageRank() + ")"));
System.out.println(result.successfulResponses() + " of " + result.memberCount() + " members answered");
return result.finalAnswer();
}
}Rankings
Each review's ranking is read from the text after FINAL RANKING:, up to the next blank line, as a comma-separated list or as numbered lines. A review without that marker has an empty parsedRanking.
A member's average rank is the mean of its 1-based positions, taken only over the reviews that list it. Lower is better, so 1.0 means every review that ranked it put it first. aggregateRanking is sorted by average rank, and topRanked() returns its first entry. A label the reviewer invented is kept as it was written.
A review that times out has the reviewer name unknown and the evaluation Review unavailable: timeout. When the chairman fails, the final answer starts with Synthesis failed:.
The result
CouncilResult | Contents |
|---|---|
stage1Responses() | One CouncilResponse(llmName, response, durationMs) per member. isSuccess() is false when response is null. A member that timed out appears as unknown. |
stage2Reviews() | CouncilReview(reviewerName, evaluation, parsedRanking), with hasRanking() |
stage3Synthesis() | CouncilSynthesis(chairmanName, synthesis) |
aggregateRanking() | RankedMember(llmName, averageRank, rankingsCount), best first |
memberCount(), duration() | Number of members and total time |
finalAnswer() | The chairman's answer |
topRanked() | The first entry of aggregateRanking, if any |
successfulResponses() | Number of members that answered in stage 1 |
Ensemble calls
For one prompt sent to several models without peer review, ParallelLLM has static methods. Each one sends the prompt to every model in parallel. agent.getEnsembleExecutor() offers the same five calls; it does not add the agent's system prompt or history.
| Method | Returns |
|---|---|
fanOut(prompt, llms) | Every result, successful or failed. The timeout is 120 seconds, or pass your own. |
race(prompt, llms, timeout, unit) | The first successful response |
consensus(prompt, llms, scorer) | The successful response with the highest score |
majorityVote(prompt, llms) | The response that resembles the most other responses. Two responses resemble each other when more than half of their distinct words are shared. |
ensemble(prompt, llms, aggregator) | One answer that the aggregator model writes from all the responses. If that call fails, the first response. |
package com.example.tnsai.docs;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.ParallelLLM;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public final class EnsembleExample {
private EnsembleExample() {}
static List<String> all(List<LLMClient> models) {
ParallelLLM.ParallelResults results = ParallelLLM.fanOut("Explain quantum computing", models);
results.failed().forEach(r -> System.err.println(r.llmName() + ": " + r.error()));
return results.responses();
}
static Optional<String> fastest(List<LLMClient> models) {
return ParallelLLM.race("Quick question", models, 10, TimeUnit.SECONDS)
.map(ParallelLLM.LLMResult::response);
}
static Optional<String> longest(List<LLMClient> models) {
return ParallelLLM.consensus("Review this code", models, response -> (double) response.length())
.map(ParallelLLM.LLMResult::response);
}
static Optional<String> synthesized(List<LLMClient> models, LLMClient aggregator) {
return ParallelLLM.ensemble("Compare the two designs", models, aggregator);
}
}Voting
com.tnsai.coordination.voting has five VotingMethod values:
| Method | Winner |
|---|---|
MAJORITY | The option with more than half of the votes |
SUPERMAJORITY | The option whose share of the votes is above the threshold, 0.667 by default |
UNANIMOUS | The only option chosen, when every vote is the same |
RANKED_CHOICE | Instant runoff: the option with the fewest votes is dropped until one has more than half |
WEIGHTED | The option with more than half of the total vote weight |
There is no winner when fewer votes arrive than the quorum, or when no option reaches the method's bar.
Voting with ConsensusBuilder
ConsensusBuilder.create() asks each agent once and tallies the answers. vote() throws IllegalStateException if the topic, options or voters are missing.
| Builder method | Default | Meaning |
|---|---|---|
topic(text) | required | The question |
options(...) | required | The choices, as strings or a list |
among(agents) | required | The voters |
method(method) | MAJORITY | How votes are tallied |
timeout(duration) | 30 seconds | How long to wait for votes. Later votes are dropped. |
quorum(n) | 1 | Minimum number of votes for a winner |
supermajorityThreshold(share) | 0.667 | Used by SUPERMAJORITY. A value outside 0–1 falls back to 0.667. |
listener(listener) | none | Can be called more than once to add several listeners |
Each agent gets Vote on: <topic>, the options and Respond with your chosen option. through chat, without its history and without tools. Up to 10 agents are asked at a time. An agent that throws casts no vote.
The answer is matched against the options in the order you listed them: the first option whose text appears in the answer, ignoring case, is the vote. An empty answer, or one that names no option, counts as a vote for the first option. Avoid options that contain each other, such as Ship and Do not ship, and put a neutral option first if a non-answer should not favour a real choice.
Every vote is a single choice with weight 1. With RANKED_CHOICE the runoff therefore only drops options; with WEIGHTED the result is the same as MAJORITY. To tally ranked or weighted ballots, build Vote.ofRanked or Vote.ofWeighted yourself and call RankedChoiceVoting.tally or MajorityVoting.tally.
A VotingListener has three default methods:
onVoteReceived(vote)runs on the voting thread as each vote arrives.onQuorumReached(current, required)runs once collection has finished, if the quorum was met. It is not a live signal.onComplete(result)runs after the tally.
VotingResult has winner (null without a winner), voteCounts, quorumMet, unanimous, method and totalVotes, plus hasWinner() and winnerVotes().
Group decisions
GroupDecisionFramework runs a vote inside an AgentGroup (Group topologies) and records the result. DefaultGroupDecisionFramework takes a DecisionTraceStore; InMemoryDecisionTraceStore keeps results in memory.
propose(topic, options, method)creates aDecisionProposalwith a 30-second timeout and a quorum of 1. For other values useDecisionProposal.of(topic, options, method, timeout, quorum). A proposal needs at least two options.vote(group, proposal)asks every member, with the same prompt and matching asConsensusBuilder, including the first-option fallback. The group must be active, otherwise it throwsIllegalStateException.converge(group, votes, proposal)tallies the votes, saves theDecisionResultin the store and broadcasts itssummary()to the group.makeDecision(group, proposal)runsvoteand thenconverge.
converge always tallies by majority rules: SUPERMAJORITY uses 0.667, and RANKED_CHOICE is counted as MAJORITY. When there is no winner and the group has a leader, the leader is asked to choose, and its answer is matched like a vote, so it always yields an option unless the call throws. The result then has leaderOverride and consensusReached set to true.
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.agents.groups.AgentGroup;
import com.tnsai.coordination.decision.DecisionProposal;
import com.tnsai.coordination.decision.DecisionResult;
import com.tnsai.coordination.decision.DecisionTraceStore;
import com.tnsai.coordination.decision.DefaultGroupDecisionFramework;
import com.tnsai.coordination.decision.GroupDecisionFramework;
import com.tnsai.coordination.decision.InMemoryDecisionTraceStore;
import com.tnsai.coordination.voting.ConsensusBuilder;
import com.tnsai.coordination.voting.Vote;
import com.tnsai.coordination.voting.VotingListener;
import com.tnsai.coordination.voting.VotingMethod;
import com.tnsai.coordination.voting.VotingResult;
import java.time.Duration;
import java.util.List;
/** Compilation contract for multi-agent/council.md voting and group decisions. */
public final class VotingExample {
private VotingExample() {
}
static VotingResult chooseDatabase(List<Agent> voters) {
VotingResult result = ConsensusBuilder.create()
.topic("Which database should the new service use?")
.options("PostgreSQL", "MySQL", "MongoDB")
.among(voters)
.method(VotingMethod.SUPERMAJORITY)
.supermajorityThreshold(0.75)
.timeout(Duration.ofSeconds(30))
.quorum(2)
.listener(new VotingListener() {
@Override
public void onVoteReceived(Vote vote) {
System.out.println(vote.voterId() + " voted " + vote.choice());
}
@Override
public void onComplete(VotingResult done) {
System.out.println("Counts: " + done.voteCounts());
}
})
.vote();
if (result.hasWinner()) {
System.out.println(result.winner() + " with " + result.winnerVotes() + " of " + result.totalVotes());
} else if (!result.quorumMet()) {
System.out.println("Too few votes: " + result.totalVotes());
}
return result;
}
static DecisionResult decideRelease(AgentGroup group) {
DecisionTraceStore traces = new InMemoryDecisionTraceStore();
GroupDecisionFramework framework = new DefaultGroupDecisionFramework(traces);
DecisionProposal proposal = DecisionProposal.of(
"Release on Friday?",
List.of("Release", "Postpone"),
VotingMethod.UNANIMOUS,
Duration.ofSeconds(45), // how long vote() waits
3); // quorum
DecisionResult result = framework.makeDecision(group, proposal); // vote, then converge
if (result.leaderOverride()) {
System.out.println("No consensus; the group leader chose " + result.winner());
}
System.out.println(result.summary());
System.out.println(traces.listByTopic("Release on Friday?").size() + " recorded decisions");
return result;
}
}DecisionResult has decisionId, topic, winner, voteCounts, consensusReached, leaderOverride, method, totalVotes and timestamp, plus hasWinner() and summary(). The store offers get(decisionId), list(), listByTopic(topic), size() and clear().