@nolag/agents 1.0.0 → 1.2.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/AgentRoom.d.ts +49 -8
- package/dist/NoLagAgents.d.ts +2 -2
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +277 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +277 -79
- package/dist/index.mjs.map +1 -1
- package/dist/patterns/observe.d.ts +26 -2
- package/dist/react-native.d.ts +13 -0
- package/dist/react-native.js +1752 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/utils.d.ts +49 -0
- package/package.json +11 -5
package/dist/index.cjs
CHANGED
|
@@ -36,7 +36,19 @@ class EventEmitter {
|
|
|
36
36
|
return;
|
|
37
37
|
for (const handler of handlers) {
|
|
38
38
|
try {
|
|
39
|
-
|
|
39
|
+
// Async handlers don't throw — they return rejected promises, which
|
|
40
|
+
// the catch below never sees. Left unattached, one bad envelope from
|
|
41
|
+
// the wire becomes an unhandled rejection and (Node ≥15) TERMINATES
|
|
42
|
+
// THE HOST PROCESS. An event emitter fed by network input must never
|
|
43
|
+
// hand a remote peer that power, so rejections are contained here
|
|
44
|
+
// exactly like sync throws.
|
|
45
|
+
const result = handler(...args);
|
|
46
|
+
if (result &&
|
|
47
|
+
typeof result.then === "function") {
|
|
48
|
+
result.then(undefined, (e) => {
|
|
49
|
+
console.error(`Error in async ${String(event)} handler:`, e);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
40
52
|
}
|
|
41
53
|
catch (e) {
|
|
42
54
|
console.error(`Error in ${String(event)} handler:`, e);
|
|
@@ -48,6 +60,97 @@ class EventEmitter {
|
|
|
48
60
|
}
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Generate a unique ID.
|
|
65
|
+
* Uses crypto.randomUUID when available, falls back to a simple random string.
|
|
66
|
+
*/
|
|
67
|
+
function generateId() {
|
|
68
|
+
if (typeof crypto !== "undefined" &&
|
|
69
|
+
typeof crypto.randomUUID === "function") {
|
|
70
|
+
return crypto.randomUUID();
|
|
71
|
+
}
|
|
72
|
+
return "xxxx-xxxx-xxxx-xxxx".replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Create a debug logger that only logs when enabled.
|
|
76
|
+
*/
|
|
77
|
+
function createLogger(prefix, enabled) {
|
|
78
|
+
if (!enabled) {
|
|
79
|
+
return (..._args) => { };
|
|
80
|
+
}
|
|
81
|
+
return (...args) => {
|
|
82
|
+
console.log(`[${prefix}]`, ...args);
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Create a Unix millisecond timestamp.
|
|
87
|
+
*/
|
|
88
|
+
function createTimestamp() {
|
|
89
|
+
return Date.now();
|
|
90
|
+
}
|
|
91
|
+
// ============ Filters ============
|
|
92
|
+
/**
|
|
93
|
+
* Build the filter fragment of an emit options object.
|
|
94
|
+
*
|
|
95
|
+
* `filter` wins over `filters`: a publish is routed to exactly one topic, so
|
|
96
|
+
* honouring both would silently drop one of them.
|
|
97
|
+
*/
|
|
98
|
+
function filterEmitOptions(opts) {
|
|
99
|
+
if (opts?.filter)
|
|
100
|
+
return { filter: opts.filter };
|
|
101
|
+
if (opts?.filters && opts.filters.length > 0)
|
|
102
|
+
return { filters: opts.filters };
|
|
103
|
+
return {};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Merge OR terms into an existing filter set. AND groups (nested arrays) are
|
|
107
|
+
* preserved as-is — only plain string terms are deduplicated.
|
|
108
|
+
*/
|
|
109
|
+
function mergeFilters(existing, add) {
|
|
110
|
+
const simple = new Set();
|
|
111
|
+
const groups = [];
|
|
112
|
+
for (const f of existing) {
|
|
113
|
+
if (typeof f === 'string')
|
|
114
|
+
simple.add(f);
|
|
115
|
+
else
|
|
116
|
+
groups.push(f);
|
|
117
|
+
}
|
|
118
|
+
for (const v of add)
|
|
119
|
+
simple.add(v);
|
|
120
|
+
return [...simple, ...groups];
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Drop OR terms from a filter set. AND groups are left untouched — remove
|
|
124
|
+
* those by calling `setFilters` with the set you want.
|
|
125
|
+
*/
|
|
126
|
+
function withoutFilters(existing, remove) {
|
|
127
|
+
const drop = new Set(remove);
|
|
128
|
+
return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
|
|
129
|
+
}
|
|
130
|
+
// ============ Wrapper registry ============
|
|
131
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
132
|
+
// one connection would collide on topics, presence and the lobby.
|
|
133
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
134
|
+
const wrapperRegistry = new WeakMap();
|
|
135
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
136
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
137
|
+
let apps = wrapperRegistry.get(client);
|
|
138
|
+
if (!apps) {
|
|
139
|
+
apps = new Map();
|
|
140
|
+
wrapperRegistry.set(client, apps);
|
|
141
|
+
}
|
|
142
|
+
const existing = apps.get(appName);
|
|
143
|
+
if (existing) {
|
|
144
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
145
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
146
|
+
}
|
|
147
|
+
apps.set(appName, wrapperName);
|
|
148
|
+
}
|
|
149
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
150
|
+
function releaseWrapper(client, appName) {
|
|
151
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
152
|
+
}
|
|
153
|
+
|
|
51
154
|
/** Default app name for agent coordination */
|
|
52
155
|
const DEFAULT_APP_NAME = "agents";
|
|
53
156
|
/** Topic name for task dispatch (Handoff pattern) */
|
|
@@ -73,6 +176,19 @@ const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
|
73
176
|
* broadcast replies (pre-0.2.0 SDKs). */
|
|
74
177
|
const AGENTS_PROTOCOL_VERSION = 2;
|
|
75
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Maps the public topic names onto the wire topics. `results` is absent by
|
|
181
|
+
* design — it is reserved for directed replies keyed to this agent's id.
|
|
182
|
+
*/
|
|
183
|
+
const FILTER_TOPICS = {
|
|
184
|
+
tasks: TOPIC_TASKS,
|
|
185
|
+
tools: TOPIC_TOOLS,
|
|
186
|
+
state: TOPIC_STATE,
|
|
187
|
+
events: TOPIC_EVENTS,
|
|
188
|
+
inbox: TOPIC_INBOX,
|
|
189
|
+
approval: TOPIC_APPROVAL,
|
|
190
|
+
};
|
|
191
|
+
const ALL_FILTER_TOPICS = Object.keys(FILTER_TOPICS);
|
|
76
192
|
/**
|
|
77
193
|
* AgentRoom — a single agent-coordination room (scoped unit).
|
|
78
194
|
*
|
|
@@ -99,13 +215,17 @@ const AGENTS_PROTOCOL_VERSION = 2;
|
|
|
99
215
|
*/
|
|
100
216
|
class AgentRoom extends EventEmitter {
|
|
101
217
|
/** @internal */
|
|
102
|
-
constructor(name, roomContext, log, agentId, appName, isConnected, presence) {
|
|
218
|
+
constructor(name, roomContext, log, agentId, appName, isConnected, presence, filters) {
|
|
103
219
|
super();
|
|
104
220
|
/** Registry of connected agents discovered via presence */
|
|
105
221
|
this._agents = new Map();
|
|
106
222
|
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
107
223
|
// handlers for a topic (the client may be shared with other consumers).
|
|
108
224
|
this._topicHandlers = [];
|
|
225
|
+
/** Filter values applied per topic. `results` is never included. */
|
|
226
|
+
this._filters = {
|
|
227
|
+
tasks: [], tools: [], state: [], events: [], inbox: [], approval: [],
|
|
228
|
+
};
|
|
109
229
|
this.name = name;
|
|
110
230
|
this.agentId = agentId;
|
|
111
231
|
this._roomContext = roomContext;
|
|
@@ -113,6 +233,10 @@ class AgentRoom extends EventEmitter {
|
|
|
113
233
|
this._appName = appName;
|
|
114
234
|
this._isConnected = isConnected;
|
|
115
235
|
this._presence = presence;
|
|
236
|
+
if (filters && filters.length > 0) {
|
|
237
|
+
for (const topic of ALL_FILTER_TOPICS)
|
|
238
|
+
this._filters[topic] = [...filters];
|
|
239
|
+
}
|
|
116
240
|
this._wireTopicListeners();
|
|
117
241
|
// Set presence if provided (with the SDK's protocol version advertised
|
|
118
242
|
// so counterparts can detect incompatible reply semantics, and a __scope
|
|
@@ -177,12 +301,15 @@ class AgentRoom extends EventEmitter {
|
|
|
177
301
|
// PUBLISH (with automatic agentId injection)
|
|
178
302
|
// ============================================================
|
|
179
303
|
/** Publish to the tasks topic */
|
|
180
|
-
publishTask(envelope) {
|
|
304
|
+
publishTask(envelope, opts) {
|
|
181
305
|
// Auto-set createdBy if not set
|
|
182
306
|
if (!envelope.createdBy) {
|
|
183
307
|
envelope.createdBy = this.agentId;
|
|
184
308
|
}
|
|
185
|
-
|
|
309
|
+
// Routing by capability (`{ filter: envelope.capability }`) is opt-in:
|
|
310
|
+
// it only reaches workers that filter on it, and mixing filtered and
|
|
311
|
+
// wildcard workers in one load-balance pool double-delivers.
|
|
312
|
+
this._publish(TOPIC_TASKS, envelope, filterEmitOptions(opts));
|
|
186
313
|
}
|
|
187
314
|
/** Publish to the results topic — directed to the dispatcher via filter when replyTo is set */
|
|
188
315
|
publishResult(envelope) {
|
|
@@ -200,24 +327,24 @@ class AgentRoom extends EventEmitter {
|
|
|
200
327
|
}
|
|
201
328
|
}
|
|
202
329
|
/** Publish to the state topic (retained) */
|
|
203
|
-
publishState(data) {
|
|
330
|
+
publishState(data, opts) {
|
|
204
331
|
// Auto-set updatedBy if not set
|
|
205
332
|
if (!data.updatedBy) {
|
|
206
333
|
data.updatedBy = this.agentId;
|
|
207
334
|
}
|
|
208
|
-
this._publish(TOPIC_STATE, data, { retain: true });
|
|
335
|
+
this._publish(TOPIC_STATE, data, { retain: true, ...filterEmitOptions(opts) });
|
|
209
336
|
}
|
|
210
337
|
/** Publish to the events topic */
|
|
211
|
-
publishEvent(data) {
|
|
338
|
+
publishEvent(data, opts) {
|
|
212
339
|
// Auto-set emittedBy if not set
|
|
213
340
|
if (!data.emittedBy) {
|
|
214
341
|
data.emittedBy = this.agentId;
|
|
215
342
|
}
|
|
216
|
-
this._publish(TOPIC_EVENTS, data);
|
|
343
|
+
this._publish(TOPIC_EVENTS, data, filterEmitOptions(opts));
|
|
217
344
|
}
|
|
218
345
|
/** Publish to the inbox topic */
|
|
219
|
-
publishInbox(data) {
|
|
220
|
-
this._publish(TOPIC_INBOX, data);
|
|
346
|
+
publishInbox(data, opts) {
|
|
347
|
+
this._publish(TOPIC_INBOX, data, filterEmitOptions(opts));
|
|
221
348
|
}
|
|
222
349
|
/**
|
|
223
350
|
* Publish a tool message.
|
|
@@ -225,16 +352,84 @@ class AgentRoom extends EventEmitter {
|
|
|
225
352
|
* replicas). Responses are directed to the requester on the results topic
|
|
226
353
|
* via filter — never load-balanced, never broadcast.
|
|
227
354
|
*/
|
|
228
|
-
publishTools(data) {
|
|
355
|
+
publishTools(data, opts) {
|
|
229
356
|
if (data?.type === "tool_response" && typeof data.replyTo === "string" && data.replyTo) {
|
|
357
|
+
// Responses stay keyed to the requester; a caller filter must not
|
|
358
|
+
// redirect them away from the agent waiting on the correlation.
|
|
230
359
|
this._publish(TOPIC_RESULTS, data, { filter: data.replyTo });
|
|
231
360
|
return;
|
|
232
361
|
}
|
|
233
|
-
this._publish(TOPIC_TOOLS, data);
|
|
362
|
+
this._publish(TOPIC_TOOLS, data, filterEmitOptions(opts));
|
|
234
363
|
}
|
|
235
364
|
/** Publish to the approval topic (retained) */
|
|
236
|
-
publishApproval(data) {
|
|
237
|
-
this._publish(TOPIC_APPROVAL, data, { retain: true });
|
|
365
|
+
publishApproval(data, opts) {
|
|
366
|
+
this._publish(TOPIC_APPROVAL, data, { retain: true, ...filterEmitOptions(opts) });
|
|
367
|
+
}
|
|
368
|
+
// ============================================================
|
|
369
|
+
// FILTERS
|
|
370
|
+
// ============================================================
|
|
371
|
+
/** The filter values currently applied to this room, by topic. */
|
|
372
|
+
get filters() {
|
|
373
|
+
return {
|
|
374
|
+
tasks: [...this._filters.tasks],
|
|
375
|
+
tools: [...this._filters.tools],
|
|
376
|
+
state: [...this._filters.state],
|
|
377
|
+
events: [...this._filters.events],
|
|
378
|
+
inbox: [...this._filters.inbox],
|
|
379
|
+
approval: [...this._filters.approval],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Replace this room's filters — only messages published with one of these
|
|
384
|
+
* values are delivered. Applies to every filterable topic unless scoped with
|
|
385
|
+
* `{ topic }`.
|
|
386
|
+
*
|
|
387
|
+
* The usual case is a worker declaring its capabilities on `tasks`, so the
|
|
388
|
+
* broker routes only work it can do instead of every worker receiving every
|
|
389
|
+
* task and discarding the rest. Pair it with
|
|
390
|
+
* `publishTask(envelope, { filter: capability })` on the dispatcher.
|
|
391
|
+
*
|
|
392
|
+
* `results` is never filtered here: it carries directed replies keyed to
|
|
393
|
+
* this agent's id, and repointing it would strand pending results.
|
|
394
|
+
*
|
|
395
|
+
* With load balancing on, keep a worker pool uniform — the broker treats a
|
|
396
|
+
* wildcard and a filtered subscription as separate share groups, so a mixed
|
|
397
|
+
* pool delivers each task twice.
|
|
398
|
+
*
|
|
399
|
+
* Passing an empty array clears filtering and restores the wildcard
|
|
400
|
+
* subscription, which receives everything.
|
|
401
|
+
*
|
|
402
|
+
* @example
|
|
403
|
+
* ```ts
|
|
404
|
+
* room.setFilters(['ocr', 'translate'], { topic: 'tasks' });
|
|
405
|
+
* room.setFilters([]); // everything
|
|
406
|
+
* ```
|
|
407
|
+
*/
|
|
408
|
+
setFilters(values, opts) {
|
|
409
|
+
for (const topic of this._targetTopics(opts)) {
|
|
410
|
+
this._filters[topic] = [...values];
|
|
411
|
+
// The core types filters as `string[]`, but both its implementation and
|
|
412
|
+
// the wire protocol accept AND groups (nested arrays).
|
|
413
|
+
this._roomContext.setFilters(FILTER_TOPICS[topic], values);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/** Add filter values to the existing set. Existing AND groups are kept. */
|
|
417
|
+
addFilters(values, opts) {
|
|
418
|
+
for (const topic of this._targetTopics(opts)) {
|
|
419
|
+
this.setFilters(mergeFilters(this._filters[topic], values), { topic });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Remove filter values from the existing set. Removing the last value
|
|
424
|
+
* restores the wildcard subscription.
|
|
425
|
+
*/
|
|
426
|
+
removeFilters(values, opts) {
|
|
427
|
+
for (const topic of this._targetTopics(opts)) {
|
|
428
|
+
this.setFilters(withoutFilters(this._filters[topic], values), { topic });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
_targetTopics(opts) {
|
|
432
|
+
return opts?.topic ? [opts.topic] : ALL_FILTER_TOPICS;
|
|
238
433
|
}
|
|
239
434
|
// ============================================================
|
|
240
435
|
// INTERNAL (called by NoLagAgents)
|
|
@@ -315,13 +510,24 @@ class AgentRoom extends EventEmitter {
|
|
|
315
510
|
}
|
|
316
511
|
_publish(topic, data, options) {
|
|
317
512
|
this._log(`publish to ${topic} in room ${this.name}`);
|
|
318
|
-
|
|
513
|
+
// An empty options object is dropped rather than forwarded: publishing
|
|
514
|
+
// without filters should look exactly as it did before filters existed.
|
|
515
|
+
if (options && Object.keys(options).length > 0) {
|
|
319
516
|
this._roomContext.emit(topic, data, options);
|
|
320
517
|
}
|
|
321
518
|
else {
|
|
322
519
|
this._roomContext.emit(topic, data);
|
|
323
520
|
}
|
|
324
521
|
}
|
|
522
|
+
/** @internal Subscribe honouring the topic's filter set. */
|
|
523
|
+
_subscribeFiltered(topic, values) {
|
|
524
|
+
if (values.length > 0) {
|
|
525
|
+
this._roomContext.subscribe(topic, { filters: values });
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
this._roomContext.subscribe(topic);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
325
531
|
_toConnectedAgent(actor) {
|
|
326
532
|
const presence = (actor.presence || actor.data || {});
|
|
327
533
|
return {
|
|
@@ -357,8 +563,8 @@ class AgentRoom extends EventEmitter {
|
|
|
357
563
|
// setting, so a pool shares each message one-of-N (no double handling):
|
|
358
564
|
// - tasks: each task goes to exactly one worker in the group
|
|
359
565
|
// - tools: each tool REQUEST goes to exactly one tool-server replica
|
|
360
|
-
this.
|
|
361
|
-
this.
|
|
566
|
+
this._subscribeFiltered(TOPIC_TASKS, this._filters.tasks);
|
|
567
|
+
this._subscribeFiltered(TOPIC_TOOLS, this._filters.tools);
|
|
362
568
|
// Replies are DIRECTED, not broadcast: the results topic carries task
|
|
363
569
|
// results and tool responses published with `filter: <recipient agentId>`,
|
|
364
570
|
// and each agent subscribes only to its own filter sub-topic. The broker
|
|
@@ -373,9 +579,13 @@ class AgentRoom extends EventEmitter {
|
|
|
373
579
|
// Broadcast topics must always fan out, even when the connection enables
|
|
374
580
|
// loadBalance for work distribution: state/events are broadcasts by
|
|
375
581
|
// nature; inbox and approval messages are claimed client-side.
|
|
376
|
-
const broadcastTopics = [
|
|
377
|
-
for (const
|
|
378
|
-
|
|
582
|
+
const broadcastTopics = ["state", "events", "inbox", "approval"];
|
|
583
|
+
for (const key of broadcastTopics) {
|
|
584
|
+
const values = this._filters[key];
|
|
585
|
+
this._roomContext.subscribe(FILTER_TOPICS[key], {
|
|
586
|
+
loadBalance: false,
|
|
587
|
+
...(values.length > 0 ? { filters: values } : {}),
|
|
588
|
+
});
|
|
379
589
|
}
|
|
380
590
|
// Simple 1:1 mappings
|
|
381
591
|
const simpleMap = [
|
|
@@ -426,58 +636,6 @@ class AgentRoom extends EventEmitter {
|
|
|
426
636
|
}
|
|
427
637
|
}
|
|
428
638
|
|
|
429
|
-
/**
|
|
430
|
-
* Generate a unique ID.
|
|
431
|
-
* Uses crypto.randomUUID when available, falls back to a simple random string.
|
|
432
|
-
*/
|
|
433
|
-
function generateId() {
|
|
434
|
-
if (typeof crypto !== "undefined" &&
|
|
435
|
-
typeof crypto.randomUUID === "function") {
|
|
436
|
-
return crypto.randomUUID();
|
|
437
|
-
}
|
|
438
|
-
return "xxxx-xxxx-xxxx-xxxx".replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Create a debug logger that only logs when enabled.
|
|
442
|
-
*/
|
|
443
|
-
function createLogger(prefix, enabled) {
|
|
444
|
-
if (!enabled) {
|
|
445
|
-
return (..._args) => { };
|
|
446
|
-
}
|
|
447
|
-
return (...args) => {
|
|
448
|
-
console.log(`[${prefix}]`, ...args);
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Create a Unix millisecond timestamp.
|
|
453
|
-
*/
|
|
454
|
-
function createTimestamp() {
|
|
455
|
-
return Date.now();
|
|
456
|
-
}
|
|
457
|
-
// ============ Wrapper registry ============
|
|
458
|
-
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
459
|
-
// one connection would collide on topics, presence and the lobby.
|
|
460
|
-
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
461
|
-
const wrapperRegistry = new WeakMap();
|
|
462
|
-
/** Register a wrapper against a client + appName; warns on collision. */
|
|
463
|
-
function registerWrapper(client, appName, wrapperName) {
|
|
464
|
-
let apps = wrapperRegistry.get(client);
|
|
465
|
-
if (!apps) {
|
|
466
|
-
apps = new Map();
|
|
467
|
-
wrapperRegistry.set(client, apps);
|
|
468
|
-
}
|
|
469
|
-
const existing = apps.get(appName);
|
|
470
|
-
if (existing) {
|
|
471
|
-
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
472
|
-
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
473
|
-
}
|
|
474
|
-
apps.set(appName, wrapperName);
|
|
475
|
-
}
|
|
476
|
-
/** Release a wrapper's (client, appName) registration on detach. */
|
|
477
|
-
function releaseWrapper(client, appName) {
|
|
478
|
-
wrapperRegistry.get(client)?.delete(appName);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
639
|
/**
|
|
482
640
|
* NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
|
|
483
641
|
*
|
|
@@ -749,12 +907,16 @@ class NoLagAgents extends EventEmitter {
|
|
|
749
907
|
* Get or create an AgentRoom wrapper.
|
|
750
908
|
* If the room hasn't been joined yet, it will be joined automatically.
|
|
751
909
|
*/
|
|
752
|
-
room(name) {
|
|
910
|
+
room(name, opts) {
|
|
753
911
|
this._assertUsable();
|
|
754
912
|
const existing = this._rooms.get(name);
|
|
755
|
-
if (existing)
|
|
913
|
+
if (existing) {
|
|
914
|
+
// Already joined — re-point its filters rather than ignoring them.
|
|
915
|
+
if (opts?.filters)
|
|
916
|
+
existing.setFilters(opts.filters);
|
|
756
917
|
return existing;
|
|
757
|
-
|
|
918
|
+
}
|
|
919
|
+
return this._joinRoomInternal(name, opts?.filters);
|
|
758
920
|
}
|
|
759
921
|
// ============ Lobby (cross-room presence observation) ============
|
|
760
922
|
/**
|
|
@@ -792,10 +954,10 @@ class NoLagAgents extends EventEmitter {
|
|
|
792
954
|
}
|
|
793
955
|
}
|
|
794
956
|
// ============ Private: Room Setup ============
|
|
795
|
-
_joinRoomInternal(name) {
|
|
957
|
+
_joinRoomInternal(name, filters) {
|
|
796
958
|
this._log(`joining room: ${name}`);
|
|
797
959
|
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
798
|
-
const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence);
|
|
960
|
+
const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence, filters);
|
|
799
961
|
this._rooms.set(name, room);
|
|
800
962
|
return room;
|
|
801
963
|
}
|
|
@@ -1337,6 +1499,11 @@ class Blackboard {
|
|
|
1337
1499
|
*
|
|
1338
1500
|
* Agents emit structured events; observers/dashboards subscribe to the stream.
|
|
1339
1501
|
* Events have severity, category, and emittedBy for filtering.
|
|
1502
|
+
*
|
|
1503
|
+
* `on(handler, filter)` discards non-matching events after they arrive, which
|
|
1504
|
+
* is fine for a quiet room and wasteful for a loud one. `setFilters` moves the
|
|
1505
|
+
* same selection to the broker, so an observer is only sent the categories it
|
|
1506
|
+
* asked for. Emit with a matching `filter` for that to work — see `emit`.
|
|
1340
1507
|
*/
|
|
1341
1508
|
class Observe {
|
|
1342
1509
|
constructor(room, emittedBy) {
|
|
@@ -1345,10 +1512,28 @@ class Observe {
|
|
|
1345
1512
|
}
|
|
1346
1513
|
/**
|
|
1347
1514
|
* Emit an observability event.
|
|
1515
|
+
*
|
|
1516
|
+
* Pass `{ filter: category }` to route it server-side, so observers that
|
|
1517
|
+
* called `setFilters` receive only the categories they subscribed to.
|
|
1518
|
+
* Observers with no filters still receive it either way, so tagging is safe
|
|
1519
|
+
* to adopt without coordinating with them.
|
|
1348
1520
|
*/
|
|
1349
|
-
emit(category, payload, severity = "info") {
|
|
1521
|
+
emit(category, payload, severity = "info", opts) {
|
|
1350
1522
|
const envelope = createEventEnvelope(category, this._emittedBy, payload, severity);
|
|
1351
|
-
this._room.publishEvent(envelope);
|
|
1523
|
+
this._room.publishEvent(envelope, opts);
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* Replace the observer's server-side event filters.
|
|
1527
|
+
*
|
|
1528
|
+
* Scoped to the events topic, so it never disturbs the room's other
|
|
1529
|
+
* subscriptions — notably `inbox`, whose messages are published unfiltered
|
|
1530
|
+
* and would stop arriving if this were applied room-wide.
|
|
1531
|
+
*
|
|
1532
|
+
* An empty array restores the wildcard subscription, which receives every
|
|
1533
|
+
* event on the room.
|
|
1534
|
+
*/
|
|
1535
|
+
setFilters(values) {
|
|
1536
|
+
this._room.setFilters(values, { topic: "events" });
|
|
1352
1537
|
}
|
|
1353
1538
|
/**
|
|
1354
1539
|
* Listen for events, optionally filtered by category or severity.
|
|
@@ -1425,10 +1610,20 @@ class Tools {
|
|
|
1425
1610
|
this._agentId = agentId;
|
|
1426
1611
|
// Wire response correlation
|
|
1427
1612
|
this._room.on("toolResponse", (envelope) => {
|
|
1613
|
+
if (!envelope || typeof envelope.correlationId !== "string")
|
|
1614
|
+
return;
|
|
1428
1615
|
this._correlations.resolve(envelope.correlationId, envelope);
|
|
1429
1616
|
});
|
|
1430
1617
|
// Wire request handling
|
|
1431
1618
|
this._room.on("toolRequest", async (envelope) => {
|
|
1619
|
+
// The tools topic is an open room surface: anything published there
|
|
1620
|
+
// that is not a tool_response is classified as a request, including
|
|
1621
|
+
// foreign or malformed payloads. A request with no toolName cannot be
|
|
1622
|
+
// routed OR NACKed meaningfully — and it must never be able to crash
|
|
1623
|
+
// the host (this listener is async, so an uncaught throw here becomes
|
|
1624
|
+
// an unhandled rejection that terminates the process).
|
|
1625
|
+
if (!envelope || typeof envelope.toolName !== "string")
|
|
1626
|
+
return;
|
|
1432
1627
|
const handler = this._handlers.get(envelope.toolName);
|
|
1433
1628
|
// Direct the response back to the requester's filter sub-topic
|
|
1434
1629
|
const replyTo = envelope.replyTo ?? envelope.requestedBy;
|
|
@@ -1477,6 +1672,9 @@ class Tools {
|
|
|
1477
1672
|
/** True when this agent hosts handlers in the tool's namespace (prefix
|
|
1478
1673
|
* before the first '.'); unprefixed tools match any unprefixed handler. */
|
|
1479
1674
|
_ownsNamespace(toolName) {
|
|
1675
|
+
// Belt and braces: a nameless tool belongs to nobody.
|
|
1676
|
+
if (typeof toolName !== "string")
|
|
1677
|
+
return false;
|
|
1480
1678
|
if (this._handlers.size === 0)
|
|
1481
1679
|
return false;
|
|
1482
1680
|
const ns = toolName.includes(".") ? toolName.slice(0, toolName.indexOf(".")) : null;
|