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