Negotiation
tnsai-coordination ships a negotiation executor with four protocols, four concession strategies and a standalone auction. The executor picks the protocol from a NegotiationType and returns one NegotiationOutcome.
The executor
new NegotiationExecutor() registers four protocols: BilateralNegotiation, MonotonicConcessionProtocol, AuctionAdapter (for ENGLISH_AUCTION, DUTCH_AUCTION, SEALED_BID and VICKREY) and ContractNetAdapter. execute(parties, config) throws IllegalArgumentException for NONE or for a type with no registered protocol.
NegotiationExecutor executor = new NegotiationExecutor();
NegotiationOutcome outcome = executor.execute(
List.of(buyerAgent, sellerAgent),
new NegotiationConfig(
NegotiationType.BILATERAL,
10, // maxRounds
Duration.ofMinutes(5), // timeout
50.0, // reservationValue (walk-away point)
100.0, // initialOffer
new LinearConcession()));register(protocol) adds a protocol or replaces the one registered for the same type. Implement NegotiationProtocol to add your own.
Listeners
addListener takes a NegotiationListener and removeListener removes it. Every callback has an empty default, so override only the ones you need:
| Callback | Called |
|---|---|
onNegotiationStarted(NegotiationConfig) | By the executor, before the protocol runs |
onNegotiationCompleted(NegotiationOutcome) | By the executor, after the protocol returns |
onOfferMade(NegotiationOffer) | Never; the shipped protocols do not call it |
onOfferReceived(NegotiationOffer) | Never; the shipped protocols do not call it |
onCounterOffer(NegotiationOffer) | Never; the shipped protocols do not call it |
getProtocol(type) returns the registered protocol for a NegotiationType as an Optional.
executor.addListener(new NegotiationListener() {
@Override
public void onNegotiationCompleted(NegotiationOutcome outcome) {
System.out.println("Agreed: " + outcome.agreed());
}
});Configuration and outcome
NegotiationConfig is a record. type, timeout and concessionStrategy are required, and maxRounds must be at least 1.
| Field | Type | Meaning |
|---|---|---|
type | NegotiationType | Which protocol runs |
maxRounds | int | Round limit |
timeout | Duration | Overall time limit |
reservationValue | double | Walk-away value. Contract net uses it as the acceptance threshold. |
initialOffer | double | Starting offer. Bilateral and monotonic treat a negative value as twice the reservation value; auctions start at 100. |
concessionStrategy | ConcessionStrategy | Computes each next offer |
NegotiationOutcome is a record with agreed, acceptedOffer (null without agreement), allOffers, roundsUsed, durationMs, finalState and failureReason. finalState is a NegotiationState: INITIAL, PROPOSING, COUNTER_OFFERING, EVALUATING, AGREED, FAILED or TIMED_OUT. A finished negotiation ends in one of the last three.
Protocols
Bilateral
BILATERAL uses the first two parties. The proposer starts at initialOffer and the responder starts at reservationValue. Each round, the proposer offers the strategy's next value; if it is at or below the responder's position, they agree. Otherwise the responder counters, and they agree if the counter reaches the proposer's position.
Both sides use the same strategy and the same reservation value. The protocol does not call the agents' LLMs; the agents only supply ids for the offers.
Monotonic concession
MONOTONIC_CONCESSION takes two or more parties. Starting positions are spread evenly from initialOffer (first party) down to reservationValue (last party). Each round every party offers the strategy's next value, capped so it never rises above its previous offer. When the highest and lowest positions are within 0.01 of each other, the parties agree on the midpoint. Like bilateral, it does not call the agents' LLMs.
NegotiationConfig config = new NegotiationConfig(
NegotiationType.MONOTONIC_CONCESSION,
15,
Duration.ofMinutes(3),
30.0,
100.0,
new ExponentialConcession());Auctions through the executor
The four auction types run through AuctionAdapter. Its default bid strategy bids currentPrice + 10 - round for every party without asking the agent, and it does not apply the config timeout. To let agents decide their bids, use the auction directly.
Contract net
CONTRACT_NET runs MultiRoundContractNet. The first party is the manager and the rest are contractors. The default ContractNetAdapter has no way to reach contractors, so a CONTRACT_NET call on a plain new NegotiationExecutor() always fails. Register an adapter with a MultiRoundContractNet.ProposalRequester that returns each contractor's Proposal:
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.annotations.Coordination.NegotiationType;
import com.tnsai.coordination.MultiRoundContractNet;
import com.tnsai.coordination.Proposal;
import com.tnsai.coordination.negotiation.NegotiationConfig;
import com.tnsai.coordination.negotiation.NegotiationExecutor;
import com.tnsai.coordination.negotiation.NegotiationOutcome;
import com.tnsai.coordination.negotiation.adapter.ContractNetAdapter;
import com.tnsai.coordination.negotiation.strategy.LinearConcession;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Compilation contract for multi-agent/negotiation.md contract net. */
public final class ContractNetNegotiationExample {
private ContractNetNegotiationExample() {
}
static NegotiationOutcome award(Agent manager, List<Agent> contractors) {
MultiRoundContractNet.ProposalRequester requester =
(contractor, task, round, targetScore, timeout) -> new Proposal(
UUID.randomUUID().toString(),
contractor.id(),
task.getTaskId(),
40.0, // cost
60_000L, // estimated time in ms
0.8, // confidence
Map.of());
NegotiationExecutor executor = new NegotiationExecutor();
executor.register(new ContractNetAdapter(requester));
List<Agent> parties = new ArrayList<>();
parties.add(manager); // the first party is the manager
parties.addAll(contractors);
return executor.execute(parties, new NegotiationConfig(
NegotiationType.CONTRACT_NET,
5, // maxRounds
Duration.ofMinutes(3), // timeout
0.5, // acceptance threshold (a proposal score)
0.0, // initialOffer is not used by contract net
new LinearConcession()));
}
}The requester gets the contractor, the task, the round, the manager's target score and a timeout. It returns a Proposal, or null to decline. A proposal that names a different task or contractor is dropped. reservationValue becomes the acceptance threshold for proposal scores, and initialOffer is not used.
Concession strategies
ConcessionStrategy is a functional interface:
@FunctionalInterface
public interface ConcessionStrategy {
double nextOffer(int round, int maxRounds, double reservationValue,
double currentValue, List<NegotiationOffer> history);
}| Class | Next offer |
|---|---|
LinearConcession | Concedes (current - reservation) / (maxRounds - round + 1), an equal share of the remaining gap over the rounds left, including the current one |
ExponentialConcession | current - (current - reservation) * (round / maxRounds)²: slow early, fast late |
TitForTatConcession | Concedes as much as the opponent's last concession, never below the reservation value. Falls back to linear with fewer than two offers. |
TimeDependentConcession | reservation + (current - reservation) * (1 - (round / maxRounds)²) |
Linear, exponential and time-dependent return the reservation value in the last round. A lambda works as a custom strategy:
ConcessionStrategy custom = (round, maxRounds, reservation, current, history) -> {
double progress = (double) round / maxRounds;
return current - (current - reservation) * progress * progress;
};Direct auction usage
Auction in com.tnsai.coordination.auction runs an auction with your own BidStrategy, so the agents can decide their bids.
Auction.Type | Mechanism | Winner pays |
|---|---|---|
ENGLISH | Ascending rounds; a bid at or below the current price drops out | Their bid |
DUTCH | The price falls by increment each round; the first bidder to accept wins | The current price |
SEALED_BID | One round of hidden bids; the highest wins | Their bid |
VICKREY | One round of hidden bids; the highest wins | The second-highest bid |
Builder defaults are SEALED_BID, starting price 100, reserve price 0, increment 10 and 10 rounds. A winning bid below the reserve price means no winner.
package com.example.tnsai.docs;
import com.tnsai.agents.Agent;
import com.tnsai.coordination.auction.Auction;
import com.tnsai.coordination.auction.AuctionResult;
import java.util.List;
/** Compilation contract for multi-agent/negotiation.md direct auction usage. */
public final class AuctionExample {
private AuctionExample() {
}
static AuctionResult run(List<Agent> translators) {
AuctionResult result = Auction.builder()
.type(Auction.Type.VICKREY)
.task("Translate document to French")
.bidders(translators)
.bidStrategy((agent, task, currentPrice, round, previousBids) -> {
String reply = agent.chat("Bid a number for: " + task
+ " (current price " + currentPrice + "). Reply with the number only.");
try {
return Double.parseDouble(reply.trim());
} catch (NumberFormatException e) {
return Double.NaN; // drop out
}
})
.startingPrice(100.0)
.reservePrice(20.0)
.maxRounds(10)
.build()
.execute();
if (result.success()) {
System.out.println("Winner: " + result.winner().name());
System.out.println("Bid: " + result.winningBid().value());
System.out.println("Pays: " + result.payPrice()); // second-highest bid for Vickrey
}
return result;
}
}BidStrategy.calculateBid(agent, task, currentPrice, round, previousBids) returns the bid. Double.NaN drops the bidder. An English auction stops when one bidder is left, so with a single bidder it ends with no bids. AuctionResult has success, winner, winningBid, payPrice, allBids, rounds, durationMs and reason. executeAsync() returns a CompletableFuture<AuctionResult>.