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