Skip to content
tnsaiDocs
GitHub

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

AnnotationKeyManager methodsReturns
@DirectMessageDIRECTexecuteAction, sendMessageThe reply Message
@BroadcastBROADCASTbroadcastNothing
@PubSubPUBSUBpublishNothing
@RequestReplyREQUEST_REPLYrequestThe reply Message
@DebateDEBATEstartDebate, submitDebatePosition, voteInDebate, counterArgument, getDebateA 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. UNANIMOUS also needs a share of 1.0. MAJORITY and SUPERMAJORITY both use the share of positions; only the threshold differs. WEIGHTED_CONFIDENCE sums the confidence values instead of counting positions.
  • Votes are recorded but not counted. Confidence is clamped to 1–10.
  • After maxRounds without consensus the status is DEADLOCK, and the decision is the stance with the most support, or "No clear position".
  • The statuses are IN_PROGRESS, CONSENSUS, DEADLOCK and CANCELLED.

Transports

Each handler delivers through a transport, chosen by the annotation's transport attribute.

TransportModeBehaviour
INTERNALIn-memory delivery inside the JVM
HTTPDelivery to a remote endpoint over HTTP
WEBSOCKETNot implemented; creating the protocol manager throws UnsupportedOperationException
AUTOINTERNAL 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:

CategoryPerformatives
AssertiveINFORM, CONFIRM, DISCONFIRM, INFORM_IF, INFORM_REF
DirectiveREQUEST, REQUEST_WHEN, REQUEST_WHENEVER, QUERY_IF, QUERY_REF, SUBSCRIBE
CommissivePROPOSE, ACCEPT_PROPOSAL, REJECT_PROPOSAL, AGREE, REFUSE, CFP
DeclarativeCANCEL, FAILURE, NOT_UNDERSTOOD, PROPAGATE, PROXY

The Category enum also has EXPRESSIVE, which no performative uses.

MethodTrue 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.

MethodReturns 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.

ProtocolFactoryExchangeSuccessFailure
RequestProtocolConversationProtocol.request()REQUEST, an optional AGREE, then the resultINFORMREFUSE, NOT_UNDERSTOOD, FAILURE
QueryProtocolConversationProtocol.query()QUERY_IF or QUERY_REF, then the answerINFORM, INFORM_REF, CONFIRM, DISCONFIRMREFUSE, FAILURE, NOT_UNDERSTOOD
ProposeProtocolConversationProtocol.propose()PROPOSE, then the answerACCEPT_PROPOSALREJECT_PROPOSAL, NOT_UNDERSTOOD
ContractNetProtocolConversationProtocol.contractNet()See belowINFORMFAILURE

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:

PerformativeEffect
CFPAWAITING_RESPONSE
PROPOSEStores the proposal under its sender; NEGOTIATING
ACCEPT_PROPOSALRecords the receiver as the selected participant; AWAITING_RESPONSE
INFORMCOMPLETED_SUCCESS; the content becomes the result
FAILURECOMPLETED_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.