@nolag/agents 0.1.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -66,6 +66,10 @@ const TOPIC_TOOLS = "tools";
66
66
  const TOPIC_APPROVAL = "approval";
67
67
  /** Default room for agent coordination */
68
68
  const DEFAULT_ROOM = "default-workflow";
69
+ /** Agents-protocol version: 2 = directed replies (filter-routed results),
70
+ * NO_HANDLER NACKs, presence protocol advertisement. Absent/1 = legacy
71
+ * broadcast replies (pre-0.2.0 SDKs). */
72
+ const AGENTS_PROTOCOL_VERSION = 2;
69
73
 
70
74
  /**
71
75
  * AgentRoom — wraps a RoomContext from @nolag/js-sdk.
@@ -98,10 +102,13 @@ class AgentRoom extends EventEmitter {
98
102
  this._presence = presence;
99
103
  this._wireTopicListeners();
100
104
  this._wirePresenceListeners();
101
- // Set presence if provided
105
+ // Set presence if provided (with the SDK's protocol version advertised
106
+ // so counterparts can detect incompatible reply semantics)
102
107
  if (presence) {
103
- this._log(`setting presence in room ${name}:`, presence);
104
- this._roomContext.setPresence(presence);
108
+ const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
109
+ this._presence = withProtocol;
110
+ this._log(`setting presence in room ${name}:`, withProtocol);
111
+ this._roomContext.setPresence(withProtocol);
105
112
  }
106
113
  // Fetch initial presence snapshot
107
114
  this._fetchInitialPresence();
@@ -134,11 +141,12 @@ class AgentRoom extends EventEmitter {
134
141
  // ============================================================
135
142
  // PRESENCE
136
143
  // ============================================================
137
- /** Update this agent's presence data */
144
+ /** Update this agent's presence data (protocol version auto-injected) */
138
145
  setPresence(data) {
139
- this._presence = data;
146
+ const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
147
+ this._presence = withProtocol;
140
148
  this._log(`updating presence in room ${this.name}`);
141
- this._roomContext.setPresence(data);
149
+ this._roomContext.setPresence(withProtocol);
142
150
  }
143
151
  /** Fetch current presence snapshot for this room */
144
152
  async fetchPresence() {
@@ -176,13 +184,20 @@ class AgentRoom extends EventEmitter {
176
184
  }
177
185
  this._publish(TOPIC_TASKS, envelope);
178
186
  }
179
- /** Publish to the results topic */
187
+ /** Publish to the results topic — directed to the dispatcher via filter when replyTo is set */
180
188
  publishResult(envelope) {
181
189
  // Auto-set completedBy if not set
182
190
  if (!envelope.completedBy) {
183
191
  envelope.completedBy = this.agentId;
184
192
  }
185
- this._publish(TOPIC_RESULTS, envelope);
193
+ if (envelope.replyTo) {
194
+ this._publish(TOPIC_RESULTS, envelope, { filter: envelope.replyTo });
195
+ }
196
+ else {
197
+ // Legacy: no reply address — unfiltered publish (only reaches
198
+ // wildcard subscribers, i.e. pre-0.2.0 SDKs)
199
+ this._publish(TOPIC_RESULTS, envelope);
200
+ }
186
201
  }
187
202
  /** Publish to the state topic (retained) */
188
203
  publishState(data) {
@@ -204,8 +219,17 @@ class AgentRoom extends EventEmitter {
204
219
  publishInbox(data) {
205
220
  this._publish(TOPIC_INBOX, data);
206
221
  }
207
- /** Publish to the tools topic */
222
+ /**
223
+ * Publish a tool message.
224
+ * Requests go to the tools topic (load-balanced one-of-N across server
225
+ * replicas). Responses are directed to the requester on the results topic
226
+ * via filter — never load-balanced, never broadcast.
227
+ */
208
228
  publishTools(data) {
229
+ if (data?.type === "tool_response" && typeof data.replyTo === "string" && data.replyTo) {
230
+ this._publish(TOPIC_RESULTS, data, { filter: data.replyTo });
231
+ return;
232
+ }
209
233
  this._publish(TOPIC_TOOLS, data);
210
234
  }
211
235
  /** Publish to the approval topic (retained) */
@@ -233,6 +257,8 @@ class AgentRoom extends EventEmitter {
233
257
  capabilities: presence.capabilities || [],
234
258
  metadata: presence.metadata,
235
259
  connectedAt: actor.joinedAt || Date.now(),
260
+ protocol: typeof presence.protocol === 'number' ? presence.protocol : 1,
261
+ status: actor.status,
236
262
  };
237
263
  }
238
264
  async _fetchInitialPresence() {
@@ -268,6 +294,7 @@ class AgentRoom extends EventEmitter {
268
294
  capabilities: data.capabilities || [],
269
295
  metadata: data.metadata,
270
296
  connectedAt: Date.now(),
297
+ protocol: typeof data.protocol === 'number' ? data.protocol : 1,
271
298
  };
272
299
  this._agents.set(id, agent);
273
300
  this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
@@ -299,6 +326,7 @@ class AgentRoom extends EventEmitter {
299
326
  capabilities: data.capabilities || existing?.capabilities || [],
300
327
  metadata: data.metadata || existing?.metadata,
301
328
  connectedAt: existing?.connectedAt || Date.now(),
329
+ protocol: typeof data.protocol === 'number' ? data.protocol : (existing?.protocol ?? 1),
302
330
  };
303
331
  this._agents.set(id, agent);
304
332
  this.emit('presenceUpdate', id, data);
@@ -307,23 +335,33 @@ class AgentRoom extends EventEmitter {
307
335
  });
308
336
  }
309
337
  _wireTopicListeners() {
310
- // All topics that need broker subscriptions
311
- const allTopics = [
312
- TOPIC_TASKS,
313
- TOPIC_RESULTS,
314
- TOPIC_STATE,
315
- TOPIC_EVENTS,
316
- TOPIC_INBOX,
317
- TOPIC_TOOLS,
318
- TOPIC_APPROVAL,
319
- ];
320
- for (const topic of allTopics) {
321
- this._roomContext.subscribe(topic);
338
+ // Work distribution topics honour the connection-level loadBalance
339
+ // setting, so a pool shares each message one-of-N (no double handling):
340
+ // - tasks: each task goes to exactly one worker in the group
341
+ // - tools: each tool REQUEST goes to exactly one tool-server replica
342
+ this._roomContext.subscribe(TOPIC_TASKS);
343
+ this._roomContext.subscribe(TOPIC_TOOLS);
344
+ // Replies are DIRECTED, not broadcast: the results topic carries task
345
+ // results and tool responses published with `filter: <recipient agentId>`,
346
+ // and each agent subscribes only to its own filter sub-topic. The broker
347
+ // routes each reply straight to the requester — no fan-out waste, and
348
+ // immune to load-balance groups (a broadcast or LB'd reply could land on
349
+ // a group member that doesn't hold the pending correlation, timing out
350
+ // the requester even though the responder did the work).
351
+ this._roomContext.subscribe(TOPIC_RESULTS, {
352
+ loadBalance: false,
353
+ filters: [this.agentId],
354
+ });
355
+ // Broadcast topics must always fan out, even when the connection enables
356
+ // loadBalance for work distribution: state/events are broadcasts by
357
+ // nature; inbox and approval messages are claimed client-side.
358
+ const broadcastTopics = [TOPIC_STATE, TOPIC_EVENTS, TOPIC_INBOX, TOPIC_APPROVAL];
359
+ for (const topic of broadcastTopics) {
360
+ this._roomContext.subscribe(topic, { loadBalance: false });
322
361
  }
323
362
  // Simple 1:1 mappings
324
363
  const simpleMap = [
325
364
  { topic: TOPIC_TASKS, event: "task" },
326
- { topic: TOPIC_RESULTS, event: "result" },
327
365
  { topic: TOPIC_STATE, event: "stateChange" },
328
366
  { topic: TOPIC_EVENTS, event: "event" },
329
367
  { topic: TOPIC_INBOX, event: "inbox" },
@@ -334,6 +372,17 @@ class AgentRoom extends EventEmitter {
334
372
  this.emit(event, data);
335
373
  });
336
374
  }
375
+ // Multiplexed: results topic carries task results AND tool responses,
376
+ // both filter-directed to this agent.
377
+ this._roomContext.on(TOPIC_RESULTS, (data) => {
378
+ this._log(`received ${TOPIC_RESULTS} in room ${this.name}`);
379
+ if (data?.type === "tool_response") {
380
+ this.emit("toolResponse", data);
381
+ }
382
+ else {
383
+ this.emit("result", data);
384
+ }
385
+ });
337
386
  // Multiplexed: approval topic carries requests + responses
338
387
  this._roomContext.on(TOPIC_APPROVAL, (data) => {
339
388
  this._log(`received ${TOPIC_APPROVAL} in room ${this.name}`);
@@ -344,7 +393,9 @@ class AgentRoom extends EventEmitter {
344
393
  this.emit("approvalRequest", data);
345
394
  }
346
395
  });
347
- // Multiplexed: tools topic carries requests + responses
396
+ // Tools topic carries requests; tool_response is still accepted here for
397
+ // backward compatibility with responders on older SDK versions (their
398
+ // responses are only reliable when the requester is not load-balanced).
348
399
  this._roomContext.on(TOPIC_TOOLS, (data) => {
349
400
  this._log(`received ${TOPIC_TOOLS} in room ${this.name}`);
350
401
  if (data?.type === "tool_response") {
@@ -582,6 +633,24 @@ class NoLagAgents extends EventEmitter {
582
633
  }
583
634
  }
584
635
 
636
+ /**
637
+ * IncompatibleProtocolError — thrown when an operation would deterministically
638
+ * fail because every relevant counterpart runs an older agents-protocol
639
+ * (pre-directed-replies). Failing fast beats burning the correlation timeout.
640
+ */
641
+ class IncompatibleProtocolError extends Error {
642
+ constructor(operation, agents) {
643
+ const list = agents.map((a) => `${a.name} (protocol ${a.protocol})`).join(", ");
644
+ super(`${operation} cannot succeed: every relevant agent advertises agents-protocol < 2 ` +
645
+ `[${list}]. Protocol >= 2 responders direct replies to the requester; older ones ` +
646
+ `broadcast and their replies never reach this SDK's filtered subscription. ` +
647
+ `Upgrade the responders to @nolag/agents >= 0.2.0 / nolag-agents >= 0.3.0. ` +
648
+ `NOTE: 0.2.x/0.3.0 responders DO have directed replies but do not yet advertise ` +
649
+ `protocol — if your responders run those versions, pass { allowLegacyResponders: true }.`);
650
+ this.name = "IncompatibleProtocolError";
651
+ }
652
+ }
653
+
585
654
  /**
586
655
  * CorrelationManager — maps correlationIds to pending promises with timeout.
587
656
  * Used by Handoff and Tools patterns for request/response correlation.
@@ -594,13 +663,16 @@ class CorrelationManager {
594
663
  * Register a pending correlation. Returns a promise that resolves
595
664
  * when `resolve()` is called with the matching correlationId.
596
665
  */
597
- register(correlationId, timeoutMs) {
666
+ register(correlationId, timeoutMs, context) {
598
667
  return new Promise((resolve, reject) => {
599
668
  let timer = null;
600
669
  if (timeoutMs && timeoutMs > 0) {
601
670
  timer = setTimeout(() => {
602
671
  this._pending.delete(correlationId);
603
- reject(new Error(`Correlation ${correlationId} timed out after ${timeoutMs}ms`));
672
+ // Context turns an opaque correlation id into an actionable error —
673
+ // callers supply what they were waiting for and the likely causes.
674
+ const what = context ?? `Correlation ${correlationId}`;
675
+ reject(new Error(`${what} timed out after ${timeoutMs}ms`));
604
676
  }, timeoutMs);
605
677
  }
606
678
  this._pending.set(correlationId, { resolve, reject, timer });
@@ -658,6 +730,7 @@ class CorrelationManager {
658
730
  function createTaskEnvelope(capability, payload, options) {
659
731
  return {
660
732
  type: "task",
733
+ protocol: AGENTS_PROTOCOL_VERSION,
661
734
  taskId: generateId(),
662
735
  correlationId: generateId(),
663
736
  replyTo: options?.replyTo,
@@ -671,9 +744,10 @@ function createTaskEnvelope(capability, payload, options) {
671
744
  timeout: options?.timeout,
672
745
  };
673
746
  }
674
- function createResultEnvelope(taskId, correlationId, status, payload, error, completedBy) {
747
+ function createResultEnvelope(taskId, correlationId, status, payload, error, completedBy, replyTo) {
675
748
  return {
676
749
  type: "result",
750
+ protocol: AGENTS_PROTOCOL_VERSION,
677
751
  correlationId,
678
752
  taskId,
679
753
  status,
@@ -681,11 +755,13 @@ function createResultEnvelope(taskId, correlationId, status, payload, error, com
681
755
  error,
682
756
  completedAt: createTimestamp(),
683
757
  completedBy,
758
+ replyTo,
684
759
  };
685
760
  }
686
761
  function createStateEnvelope(key, value, version, updatedBy) {
687
762
  return {
688
763
  type: "state",
764
+ protocol: AGENTS_PROTOCOL_VERSION,
689
765
  key,
690
766
  value,
691
767
  version,
@@ -696,6 +772,7 @@ function createStateEnvelope(key, value, version, updatedBy) {
696
772
  function createEventEnvelope(category, emittedBy, payload, severity = "info") {
697
773
  return {
698
774
  type: "event",
775
+ protocol: AGENTS_PROTOCOL_VERSION,
699
776
  eventId: generateId(),
700
777
  severity,
701
778
  category,
@@ -707,6 +784,7 @@ function createEventEnvelope(category, emittedBy, payload, severity = "info") {
707
784
  function createApprovalRequest(action, context, requestedBy, options) {
708
785
  return {
709
786
  type: "approval_request",
787
+ protocol: AGENTS_PROTOCOL_VERSION,
710
788
  requestId: generateId(),
711
789
  correlationId: generateId(),
712
790
  action,
@@ -720,6 +798,7 @@ function createApprovalRequest(action, context, requestedBy, options) {
720
798
  function createApprovalResponse(requestId, correlationId, decision, respondedBy, reason) {
721
799
  return {
722
800
  type: "approval_response",
801
+ protocol: AGENTS_PROTOCOL_VERSION,
723
802
  requestId,
724
803
  correlationId,
725
804
  decision,
@@ -731,6 +810,7 @@ function createApprovalResponse(requestId, correlationId, decision, respondedBy,
731
810
  function createToolRequest(toolName, args, requestedBy, options) {
732
811
  return {
733
812
  type: "tool_request",
813
+ protocol: AGENTS_PROTOCOL_VERSION,
734
814
  requestId: generateId(),
735
815
  correlationId: generateId(),
736
816
  replyTo: options?.replyTo,
@@ -740,9 +820,10 @@ function createToolRequest(toolName, args, requestedBy, options) {
740
820
  requestedAt: createTimestamp(),
741
821
  };
742
822
  }
743
- function createToolResponse(requestId, correlationId, status, result, error, respondedBy) {
823
+ function createToolResponse(requestId, correlationId, status, result, error, respondedBy, replyTo) {
744
824
  return {
745
825
  type: "tool_response",
826
+ protocol: AGENTS_PROTOCOL_VERSION,
746
827
  requestId,
747
828
  correlationId,
748
829
  status,
@@ -750,6 +831,7 @@ function createToolResponse(requestId, correlationId, status, result, error, res
750
831
  error,
751
832
  respondedBy,
752
833
  respondedAt: createTimestamp(),
834
+ replyTo,
753
835
  };
754
836
  }
755
837
 
@@ -780,6 +862,7 @@ function createToolResponse(requestId, correlationId, status, result, error, res
780
862
  class Handoff {
781
863
  constructor(room) {
782
864
  this._correlations = new CorrelationManager();
865
+ this._warnedMixed = false;
783
866
  this._room = room;
784
867
  // Wire result correlation
785
868
  this._room.on("result", (envelope) => {
@@ -794,11 +877,16 @@ class Handoff {
794
877
  * agent is connected (unless `allowNoWorkers` is set).
795
878
  */
796
879
  async dispatch(capability, payload, options) {
797
- // Service discovery: check if any agent can handle this capability
880
+ // Service discovery: check if any agent can handle this capability.
881
+ // Persistent Presence: findAgents includes offline persistent agents, which
882
+ // the broker wakes on publish — so they satisfy the gate unless requireOnline.
798
883
  if (!options?.allowNoWorkers) {
799
884
  const capable = this._room.findAgents(capability);
800
- if (capable.length === 0) {
801
- throw new Error(`No agent with capability "${capability}" is connected. ` +
885
+ const usable = options?.requireOnline
886
+ ? capable.filter((a) => a.status === undefined || a.status === "online")
887
+ : capable;
888
+ if (usable.length === 0) {
889
+ throw new Error(`No ${options?.requireOnline ? "online " : ""}agent with capability "${capability}" is available. ` +
802
890
  `Available capabilities: [${this._room.getAvailableCapabilities().join(', ')}]. ` +
803
891
  `Connected agents: ${this._room.getConnectedAgents().length}. ` +
804
892
  `Use { allowNoWorkers: true } to dispatch anyway.`);
@@ -806,32 +894,48 @@ class Handoff {
806
894
  }
807
895
  const envelope = createTaskEnvelope(capability, payload, {
808
896
  ...options,
809
- createdBy: this._room.agentId,
897
+ createdBy: options?.createdBy ?? this._room.agentId,
898
+ // Reply address: workers publish the result filter-directed to this
899
+ // room's results subscription
900
+ replyTo: options?.replyTo ?? this._room.agentId,
810
901
  });
811
902
  this._room.publishTask(envelope);
812
903
  if (options?.waitForResult) {
813
- return this._correlations.register(envelope.correlationId, options.timeout);
904
+ // Fail fast when the outcome is deterministic: if capable workers are
905
+ // visible and ALL advertise agents-protocol < 2, their results cannot
906
+ // reach this dispatcher's filtered subscription. Mixed pools proceed
907
+ // with a warning (presence is eventually consistent).
908
+ const capable = this._room.findAgents(capability);
909
+ if (!options?.allowLegacyResponders && capable.length > 0) {
910
+ const modern = capable.filter((a) => a.protocol >= 2);
911
+ if (modern.length === 0) {
912
+ throw new IncompatibleProtocolError(`Task '${capability}' dispatch with waitForResult`, capable.map((a) => ({ name: a.name, protocol: a.protocol })));
913
+ }
914
+ if (modern.length < capable.length && !this._warnedMixed) {
915
+ this._warnedMixed = true;
916
+ console.warn(`[nolag-agents] Capability '${capability}' has workers on agents-protocol < 2: ` +
917
+ capable.filter((a) => a.protocol < 2).map((a) => a.name).join(", ") +
918
+ ". Their results may not be delivered — upgrade them.");
919
+ }
920
+ }
921
+ return this._correlations.register(envelope.correlationId, options.timeout, `Task '${capability}' dispatch (${capable.length} capable worker${capable.length === 1 ? "" : "s"} visible). ` +
922
+ `Likely causes: worker crashed mid-task, worker on agents-protocol < 2 ` +
923
+ `(results not directed), or the room is not deliverable`);
814
924
  }
815
925
  }
816
- /**
817
- * Register a handler for incoming tasks, filtered by capabilities.
818
- *
819
- * Only tasks whose `capability` field matches one of the provided
820
- * capabilities will be delivered to the handler. Non-matching tasks
821
- * are silently ignored.
822
- *
823
- * @param capabilities - Array of capabilities this worker handles.
824
- * Pass `'*'` to receive all tasks.
825
- * @param handler - Async handler called with the task and a respond function.
826
- */
827
- onTask(capabilities, handler) {
926
+ onTask(capabilitiesOrHandler, maybeHandler) {
927
+ // Single-arg form: onTask(handler) receives all tasks
928
+ const capabilities = typeof capabilitiesOrHandler === "function" ? '*' : capabilitiesOrHandler;
929
+ const handler = typeof capabilitiesOrHandler === "function" ? capabilitiesOrHandler : maybeHandler;
828
930
  this._room.on("task", (task) => {
829
931
  // Filter by capability unless wildcard
830
932
  if (capabilities !== '*' && !capabilities.includes(task.capability)) {
831
933
  return;
832
934
  }
833
935
  const respond = (status, payload, error) => {
834
- const result = createResultEnvelope(task.taskId, task.correlationId, status, payload, error, this._room.agentId);
936
+ const result = createResultEnvelope(task.taskId, task.correlationId, status, payload, error, this._room.agentId,
937
+ // Direct the result to the dispatcher's filter sub-topic
938
+ task.replyTo ?? task.createdBy);
835
939
  this._room.publishResult(result);
836
940
  };
837
941
  handler(task, respond);
@@ -1030,6 +1134,7 @@ class Tools {
1030
1134
  constructor(room, agentId) {
1031
1135
  this._correlations = new CorrelationManager();
1032
1136
  this._handlers = new Map();
1137
+ this._warnedMixed = false;
1033
1138
  this._room = room;
1034
1139
  this._agentId = agentId;
1035
1140
  // Wire response correlation
@@ -1039,18 +1144,40 @@ class Tools {
1039
1144
  // Wire request handling
1040
1145
  this._room.on("toolRequest", async (envelope) => {
1041
1146
  const handler = this._handlers.get(envelope.toolName);
1042
- if (!handler)
1147
+ // Direct the response back to the requester's filter sub-topic
1148
+ const replyTo = envelope.replyTo ?? envelope.requestedBy;
1149
+ if (!handler) {
1150
+ // Tool requests are load-balanced to EVERY group in the room, so
1151
+ // agents legitimately receive requests meant for other tool servers.
1152
+ // Stay silent unless this agent plausibly owns the tool:
1153
+ // - pure requesters (zero handlers) never answer
1154
+ // - servers answer only within their own namespace (the prefix
1155
+ // before the first '.', e.g. 'backend.*', 'chemistry.*') — a
1156
+ // 'backend.*' server NACKing 'chemistry.analyze' would race and
1157
+ // beat the real chemistry server's response
1158
+ if (!this._ownsNamespace(envelope.toolName))
1159
+ return;
1160
+ // A tool SERVER missing a handler in ITS OWN namespace NACKs instead
1161
+ // of silently ignoring — silence means the requester burns its full
1162
+ // timeout. (Requires homogeneous tool sets within a loadBalanceGroup
1163
+ // — see AGENTS-PROTOCOL.md.)
1164
+ const nack = createToolResponse(envelope.requestId, envelope.correlationId, "error", null, {
1165
+ code: "NO_HANDLER",
1166
+ message: `Agent '${this._agentId}' has no handler for tool '${envelope.toolName}'`,
1167
+ }, this._agentId, replyTo);
1168
+ this._room.publishTools(nack);
1043
1169
  return;
1170
+ }
1044
1171
  try {
1045
1172
  const result = await handler(envelope.arguments);
1046
- const response = createToolResponse(envelope.requestId, envelope.correlationId, "success", result, undefined, this._agentId);
1173
+ const response = createToolResponse(envelope.requestId, envelope.correlationId, "success", result, undefined, this._agentId, replyTo);
1047
1174
  this._room.publishTools(response);
1048
1175
  }
1049
1176
  catch (err) {
1050
1177
  const response = createToolResponse(envelope.requestId, envelope.correlationId, "error", null, {
1051
1178
  code: "TOOL_ERROR",
1052
1179
  message: err instanceof Error ? err.message : String(err),
1053
- }, this._agentId);
1180
+ }, this._agentId, replyTo);
1054
1181
  this._room.publishTools(response);
1055
1182
  }
1056
1183
  });
@@ -1061,13 +1188,57 @@ class Tools {
1061
1188
  register(toolName, handler) {
1062
1189
  this._handlers.set(toolName, handler);
1063
1190
  }
1191
+ /** True when this agent hosts handlers in the tool's namespace (prefix
1192
+ * before the first '.'); unprefixed tools match any unprefixed handler. */
1193
+ _ownsNamespace(toolName) {
1194
+ if (this._handlers.size === 0)
1195
+ return false;
1196
+ const ns = toolName.includes(".") ? toolName.slice(0, toolName.indexOf(".")) : null;
1197
+ for (const name of this._handlers.keys()) {
1198
+ const handlerNs = name.includes(".") ? name.slice(0, name.indexOf(".")) : null;
1199
+ if (handlerNs === ns)
1200
+ return true;
1201
+ }
1202
+ return false;
1203
+ }
1064
1204
  /**
1065
1205
  * Invoke a remote tool and wait for the response.
1066
1206
  */
1067
1207
  async invoke(toolName, args, options) {
1068
- const envelope = createToolRequest(toolName, args, this._agentId);
1208
+ // Fail fast when the outcome is deterministic: tool servers are visible
1209
+ // in presence; if some exist and ALL advertise protocol < 2, their
1210
+ // replies cannot reach this requester. Mixed pools proceed with a
1211
+ // warning (presence is eventually consistent — hard-failing on one
1212
+ // stale entry would flake).
1213
+ const servers = this._room
1214
+ .getConnectedAgents()
1215
+ .filter((a) => a.role === "tool-server");
1216
+ if (!options?.allowLegacyResponders && servers.length > 0) {
1217
+ const modern = servers.filter((a) => a.protocol >= 2);
1218
+ if (modern.length === 0) {
1219
+ throw new IncompatibleProtocolError(`Tool '${toolName}' invocation`, servers.map((a) => ({ name: a.name, protocol: a.protocol })));
1220
+ }
1221
+ if (modern.length < servers.length && !this._warnedMixed) {
1222
+ this._warnedMixed = true;
1223
+ console.warn(`[nolag-agents] Room '${this._room.name}' has tool servers on agents-protocol < 2: ` +
1224
+ servers.filter((a) => a.protocol < 2).map((a) => a.name).join(", ") +
1225
+ ". Their replies may not be delivered — upgrade them.");
1226
+ }
1227
+ }
1228
+ // replyTo is the room's agentId — the filter sub-topic this room's
1229
+ // results subscription listens on. (this._agentId may differ when a
1230
+ // caller attributes requests to a logical agent; delivery must use the
1231
+ // address that is actually subscribed.)
1232
+ const envelope = createToolRequest(toolName, args, this._agentId, {
1233
+ replyTo: this._room.agentId,
1234
+ });
1069
1235
  this._room.publishTools(envelope);
1070
- return this._correlations.register(envelope.correlationId, options?.timeout);
1236
+ const serverCount = servers.length;
1237
+ return this._correlations.register(envelope.correlationId, options?.timeout, `Tool '${toolName}' invocation in room '${this._room.name}' ` +
1238
+ `(${serverCount} tool-server${serverCount === 1 ? "" : "s"} visible). ` +
1239
+ `Likely causes: no agent has this tool registered (pre-0.3.0 responders ` +
1240
+ `don't NACK), the responder is offline, or the room is not deliverable ` +
1241
+ `(watch the room 'error' events)`);
1071
1242
  }
1072
1243
  /** Cancel all pending correlations */
1073
1244
  dispose() {
@@ -1095,5 +1266,5 @@ function tag(prefix, value) {
1095
1266
  return `${prefix}:${value}`;
1096
1267
  }
1097
1268
 
1098
- export { AgentRoom, Approve, Blackboard, CorrelationManager, EventEmitter, Handoff, Inbox, NoLagAgents, Observe, TAG_FLAGS, TAG_PREFIX, Tools, createApprovalRequest, createApprovalResponse, createEventEnvelope, createResultEnvelope, createStateEnvelope, createTaskEnvelope, createToolRequest, createToolResponse, tag };
1269
+ export { AGENTS_PROTOCOL_VERSION, AgentRoom, Approve, Blackboard, CorrelationManager, EventEmitter, Handoff, Inbox, IncompatibleProtocolError, NoLagAgents, Observe, TAG_FLAGS, TAG_PREFIX, Tools, createApprovalRequest, createApprovalResponse, createEventEnvelope, createResultEnvelope, createStateEnvelope, createTaskEnvelope, createToolRequest, createToolResponse, tag };
1099
1270
  //# sourceMappingURL=index.mjs.map