Protocols
Protocol annotations give an agent a messaging paradigm: direct messages, broadcast, publish/subscribe, request/reply or debate. tnsai-coordination reads the annotations, creates a handler for each one and routes messages through a transport. The same module holds FIPA ACL message types and conversation protocols, which you drive yourself.
For the plain AgentMessage API and group membership, see Messaging.
The protocol manager
An agent gets a protocol manager only when its class carries at least one protocol annotation and tnsai-coordination is on the classpath. The manager is created and started with the agent and stopped when the agent terminates.
agent.getProtocolManager() returns a com.tnsai.protocols.ProtocolManagerHandle from tnsai-core. The handle only has start, stop, isStarted, hasParadigm, getParadigms and getAgent. To send messages, cast it to com.tnsai.protocols.ProtocolManager:
ProtocolManager pm = (ProtocolManager) agent.getProtocolManager();
Set<String> paradigms = pm.getParadigms(); // e.g. [DIRECT, PUBSUB]
boolean canBroadcast = pm.hasParadigm("BROADCAST");The paradigm keys are DIRECT, BROADCAST, PUBSUB, REQUEST_REPLY and DEBATE. Calling a method whose paradigm the agent does not have throws ProtocolHandler.HandlerException, for example "Broadcast not configured for this agent".
Paradigms
| Annotation | Key | Manager methods | Returns |
|---|---|---|---|
@DirectMessage | DIRECT | executeAction, sendMessage | The reply Message |
@Broadcast | BROADCAST | broadcast | Nothing |
@PubSub | PUBSUB | publish | Nothing |
@RequestReply | REQUEST_REPLY | request | The reply Message |
@Debate | DEBATE | startDebate, submitDebatePosition, voteInDebate, counterArgument, getDebate | A DebateSession or nothing |
An agent can carry several annotations and gets one handler per annotation.
Direct messages
@DirectMessage
public class OrderAgent extends Agent { }
// Run an action on another agent
Message response = pm.executeAction("billing-agent", "processOrder", Map.of("orderId", "12345"));
// Run an action of one role
Message charged = pm.executeAction("billing-agent", "billing-role", "charge", params);
// Send text; the receiver's LLM answers it
Message reply = pm.sendMessage("billing-agent", "Please summarize the quarterly report");@DirectMessage has endpoint (empty by default) and transport (AUTO by default).
Broadcast and publish/subscribe
@Broadcast(topic = "system-alerts")
public class MonitorAgent extends Agent { }
pm.broadcast(Map.of("level", "HIGH", "message", "CPU threshold exceeded"));@PubSub(topics = {"alerts", "metrics", "deployments"})
public class DashboardAgent extends Agent { }
pm.publish("alerts", Map.of("severity", "WARN", "source", "db-pool"));Both find their recipients through discovery: every agent whose @Broadcast topic or @PubSub topics match the topic, ignoring case. The sender is skipped, and so is any agent whose annotation sets canSubscribe = false. Neither call returns a delivery result.
@Broadcast has topic, broker, transport, canPublish and canSubscribe; @PubSub has topics, broker, transport, canPublish, canSubscribe and filter. Only canSubscribe is used: canPublish and filter have no effect.
Request/reply
@RequestReply(timeout = 30000) // milliseconds, the default
public class QueryAgent extends Agent { }
Message reply = pm.request("data-agent", "fetchReport", Map.of("quarter", "Q4", "year", 2025));request blocks until the reply arrives. The timeout must be greater than 0; when it passes, the call throws HandlerException ("Request timed out").
Debate
@Debate sets maxRounds (5), consensusThreshold (0.66) and strategy (SUPERMAJORITY). It also has roundTimeoutMs, canInitiate and canVote, which have no effect.
@Debate(maxRounds = 3, strategy = Debate.ConsensusStrategy.WEIGHTED_CONFIDENCE)
public class DecisionAgent extends Agent { }
DebateProtocolHandler.DebateSession session = pm.startDebate(
"Should we deploy to production?", List.of("dev-agent", "qa-agent", "ops-agent"));
pm.submitDebatePosition(session.getId(), "YES", 8, "All tests pass"); // confidence 1-10
pm.voteInDebate(session.getId(), "NO", false);
pm.counterArgument(session.getId(), "ops-agent",
"Staging does not reflect production traffic", "Wait for the load test");A debate session lives only in the handler of the agent that started it, and the manager always submits under its own agent's id. Another agent's manager does not know the session and throws "Debate not found", so participants cannot submit positions through their own managers. For debates between several agents, use Debate and peer review.
How the handler scores a session:
- A round ends when it has at least as many positions as there are participants.
- Consensus means the leading stance's share reaches
consensusThreshold.UNANIMOUSalso needs a share of 1.0.MAJORITYandSUPERMAJORITYboth use the share of positions; only the threshold differs.WEIGHTED_CONFIDENCEsums the confidence values instead of counting positions. - Votes are recorded but not counted. Confidence is clamped to 1–10.
- After
maxRoundswithout consensus the status isDEADLOCK, and the decision is the stance with the most support, or "No clear position". - The statuses are
IN_PROGRESS,CONSENSUS,DEADLOCKandCANCELLED.
Transports
Each handler delivers through a transport, chosen by the annotation's transport attribute.
TransportMode | Behaviour |
|---|---|
INTERNAL | In-memory delivery inside the JVM |
HTTP | Delivery to a remote endpoint over HTTP |
WEBSOCKET | Not implemented; creating the protocol manager throws UnsupportedOperationException |
AUTO | INTERNAL when no endpoint is set, HTTP otherwise (the default) |
A mode other than INTERNAL needs an endpoint: endpoint on @DirectMessage and @RequestReply, and broker on @Broadcast, @PubSub and @Debate.
@DirectMessage(endpoint = "http://remote:8080", transport = TransportMode.HTTP)
public class RemoteAgent extends Agent { }The internal transport throws when it is not started or the receiver is unknown. Broadcast and publish/subscribe messages go to the receiver's @OnMessageReceived callbacks, text messages are answered by the receiver's LLM, and action requests run the action and send its result back. Both built-in transports are synchronous.
FIPA messages
com.tnsai.protocols.fipa models FIPA ACL, the agent communication language of the Foundation for Intelligent Physical Agents. These classes build and track messages; they do not send them.
Performatives
A performative states the intent of a message. FIPAPerformative has 22 values in four categories:
| Category | Performatives |
|---|---|
| Assertive | INFORM, CONFIRM, DISCONFIRM, INFORM_IF, INFORM_REF |
| Directive | REQUEST, REQUEST_WHEN, REQUEST_WHENEVER, QUERY_IF, QUERY_REF, SUBSCRIBE |
| Commissive | PROPOSE, ACCEPT_PROPOSAL, REJECT_PROPOSAL, AGREE, REFUSE, CFP |
| Declarative | CANCEL, FAILURE, NOT_UNDERSTOOD, PROPAGATE, PROXY |
The Category enum also has EXPRESSIVE, which no performative uses.
| Method | True for |
|---|---|
isInitiating() | REQUEST, QUERY_IF, QUERY_REF, CFP, PROPOSE, SUBSCRIBE |
isResponding() | INFORM, AGREE, REFUSE, ACCEPT_PROPOSAL, REJECT_PROPOSAL, FAILURE, NOT_UNDERSTOOD |
isPositive() | INFORM, AGREE, CONFIRM, ACCEPT_PROPOSAL |
isNegative() | REFUSE, REJECT_PROPOSAL, FAILURE, DISCONFIRM, CANCEL |
Building messages
FIPAMessage is a record with the FIPA ACL fields: id, performative, sender, receiver, content, conversationId, replyWith, inReplyTo, protocol, language, encoding, ontology, timestamp, replyBy and userParameters.
FIPAMessage request = FIPAMessage.builder()
.performative(FIPAPerformative.REQUEST)
.sender("agent-1")
.receiver("agent-2")
.content("Please perform analysis")
.protocol("fipa-request")
.ontology("market-analysis")
.replyBy(Instant.now().plus(Duration.ofMinutes(5)))
.build();
FIPAMessage reply = request.createReply()
.performative(FIPAPerformative.AGREE)
.sender("agent-2")
.content("I will perform the analysis")
.build();
FIPAMessage inform = FIPAMessage.inform("sender", "receiver", "Task complete");
FIPAMessage cfp = FIPAMessage.cfp("manager", "worker", "Translate document to French");
FIPAMessage query = FIPAMessage.queryIf("agent-a", "agent-b", "Is the service healthy?");build() throws IllegalStateException without a performative. Unset fields default to a random id and conversation id, language natural, encoding UTF-8 and the current time. An initiating message without replyWith gets reply-<id>.
createReply() returns a builder with inReplyTo set to the original's replyWith (or its id), the receiver set to the original sender, and the conversation id, protocol, language and ontology copied. It does not set the sender or performative.
| Method | Returns true when |
|---|---|
expectsReply() | The performative is initiating |
isReply() | inReplyTo is set |
isReplyOverdue() | replyBy is set and has passed |
The request, queryIf and cfp factories set the protocol to fipa-request, fipa-query and fipa-contract-net.
Conversation protocols
A ConversationProtocol tracks one conversation: its state, its message history and its result. processMessage records a message and moves the state.
| Protocol | Factory | Exchange | Success | Failure |
|---|---|---|---|---|
RequestProtocol | ConversationProtocol.request() | REQUEST, an optional AGREE, then the result | INFORM | REFUSE, NOT_UNDERSTOOD, FAILURE |
QueryProtocol | ConversationProtocol.query() | QUERY_IF or QUERY_REF, then the answer | INFORM, INFORM_REF, CONFIRM, DISCONFIRM | REFUSE, FAILURE, NOT_UNDERSTOOD |
ProposeProtocol | ConversationProtocol.propose() | PROPOSE, then the answer | ACCEPT_PROPOSAL | REJECT_PROPOSAL, NOT_UNDERSTOOD |
ContractNetProtocol | ConversationProtocol.contractNet() | See below | INFORM | FAILURE |
In the first three, any other performative moves the state to ERROR.
ProtocolState has nine values: INITIAL, AWAITING_INITIATION, AWAITING_RESPONSE, NEGOTIATING, COMPLETED_SUCCESS, COMPLETED_FAILURE, CANCELLED, TIMED_OUT and ERROR. The shipped protocols never set AWAITING_INITIATION, CANCELLED or TIMED_OUT. isComplete() is true for COMPLETED_SUCCESS, COMPLETED_FAILURE and ERROR; isSuccessful() only for COMPLETED_SUCCESS.
ContractNetProtocol follows one Contract Net exchange at the FIPA message level; your code picks the proposal. To have TnsAI run the bidding rounds and pick the contractor, use Negotiation.
ContractNetProtocol moves the state like this:
| Performative | Effect |
|---|---|
CFP | AWAITING_RESPONSE |
PROPOSE | Stores the proposal under its sender; NEGOTIATING |
ACCEPT_PROPOSAL | Records the receiver as the selected participant; AWAITING_RESPONSE |
INFORM | COMPLETED_SUCCESS; the content becomes the result |
FAILURE | COMPLETED_FAILURE; the content becomes the result |
REFUSE, NOT_UNDERSTOOD and REJECT_PROPOSAL are recorded without a state change. processMessage does not validate the message; call isValidMessage or isValidResponse first if you need that.
ContractNetProtocol cnp = new ContractNetProtocol();
FIPAMessage cfp = cnp.createInitialMessage("manager", "translator", "Translate 500 pages to French");
cnp.processMessage(cfp);
FIPAMessage bid = cnp.createProposal(cfp, "2 days, 500 EUR"); // sent by "translator"
cnp.processMessage(bid);
cnp.selectProposal("translator"); // true: a proposal from "translator" exists
FIPAMessage accept = cnp.createAcceptance(bid);
cnp.processMessage(accept);
FIPAMessage done = cnp.createResponse(accept, FIPAPerformative.INFORM, "Translation delivered");
cnp.processMessage(done);
cnp.isSuccessful(); // true
cnp.getResult(); // Optional[Translation delivered]Use createInitialMessage(sender, receiver, content) for each contractor. createCFP(sender, task) leaves the receiver empty, so a proposal made from it has no sender and cannot be selected by id.