@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/README.md +2 -0
- package/dist/AgentRoom.d.ts +16 -3
- package/dist/browser.d.ts +2 -0
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/correlation.d.ts +1 -1
- package/dist/envelope.d.ts +2 -2
- package/dist/errors.d.ts +11 -0
- package/dist/index.cjs +223 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.mjs +222 -51
- package/dist/index.mjs.map +1 -1
- package/dist/patterns/handoff.d.ts +20 -4
- package/dist/patterns/tools.d.ts +5 -0
- package/dist/types.d.ts +34 -0
- package/package.json +4 -4
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
|
-
|
|
106
|
-
this.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|