@nolag/agents 1.1.1 → 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.cjs CHANGED
@@ -60,6 +60,97 @@ class EventEmitter {
60
60
  }
61
61
  }
62
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
+
63
154
  /** Default app name for agent coordination */
64
155
  const DEFAULT_APP_NAME = "agents";
65
156
  /** Topic name for task dispatch (Handoff pattern) */
@@ -85,6 +176,19 @@ const LOBBY_REFRESH_DELAY_MS = 2000;
85
176
  * broadcast replies (pre-0.2.0 SDKs). */
86
177
  const AGENTS_PROTOCOL_VERSION = 2;
87
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);
88
192
  /**
89
193
  * AgentRoom — a single agent-coordination room (scoped unit).
90
194
  *
@@ -111,13 +215,17 @@ const AGENTS_PROTOCOL_VERSION = 2;
111
215
  */
112
216
  class AgentRoom extends EventEmitter {
113
217
  /** @internal */
114
- constructor(name, roomContext, log, agentId, appName, isConnected, presence) {
218
+ constructor(name, roomContext, log, agentId, appName, isConnected, presence, filters) {
115
219
  super();
116
220
  /** Registry of connected agents discovered via presence */
117
221
  this._agents = new Map();
118
222
  // Stored topic handler refs — cleanup removes exactly these, never all
119
223
  // handlers for a topic (the client may be shared with other consumers).
120
224
  this._topicHandlers = [];
225
+ /** Filter values applied per topic. `results` is never included. */
226
+ this._filters = {
227
+ tasks: [], tools: [], state: [], events: [], inbox: [], approval: [],
228
+ };
121
229
  this.name = name;
122
230
  this.agentId = agentId;
123
231
  this._roomContext = roomContext;
@@ -125,6 +233,10 @@ class AgentRoom extends EventEmitter {
125
233
  this._appName = appName;
126
234
  this._isConnected = isConnected;
127
235
  this._presence = presence;
236
+ if (filters && filters.length > 0) {
237
+ for (const topic of ALL_FILTER_TOPICS)
238
+ this._filters[topic] = [...filters];
239
+ }
128
240
  this._wireTopicListeners();
129
241
  // Set presence if provided (with the SDK's protocol version advertised
130
242
  // so counterparts can detect incompatible reply semantics, and a __scope
@@ -189,12 +301,15 @@ class AgentRoom extends EventEmitter {
189
301
  // PUBLISH (with automatic agentId injection)
190
302
  // ============================================================
191
303
  /** Publish to the tasks topic */
192
- publishTask(envelope) {
304
+ publishTask(envelope, opts) {
193
305
  // Auto-set createdBy if not set
194
306
  if (!envelope.createdBy) {
195
307
  envelope.createdBy = this.agentId;
196
308
  }
197
- this._publish(TOPIC_TASKS, envelope);
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));
198
313
  }
199
314
  /** Publish to the results topic — directed to the dispatcher via filter when replyTo is set */
200
315
  publishResult(envelope) {
@@ -212,24 +327,24 @@ class AgentRoom extends EventEmitter {
212
327
  }
213
328
  }
214
329
  /** Publish to the state topic (retained) */
215
- publishState(data) {
330
+ publishState(data, opts) {
216
331
  // Auto-set updatedBy if not set
217
332
  if (!data.updatedBy) {
218
333
  data.updatedBy = this.agentId;
219
334
  }
220
- this._publish(TOPIC_STATE, data, { retain: true });
335
+ this._publish(TOPIC_STATE, data, { retain: true, ...filterEmitOptions(opts) });
221
336
  }
222
337
  /** Publish to the events topic */
223
- publishEvent(data) {
338
+ publishEvent(data, opts) {
224
339
  // Auto-set emittedBy if not set
225
340
  if (!data.emittedBy) {
226
341
  data.emittedBy = this.agentId;
227
342
  }
228
- this._publish(TOPIC_EVENTS, data);
343
+ this._publish(TOPIC_EVENTS, data, filterEmitOptions(opts));
229
344
  }
230
345
  /** Publish to the inbox topic */
231
- publishInbox(data) {
232
- this._publish(TOPIC_INBOX, data);
346
+ publishInbox(data, opts) {
347
+ this._publish(TOPIC_INBOX, data, filterEmitOptions(opts));
233
348
  }
234
349
  /**
235
350
  * Publish a tool message.
@@ -237,16 +352,84 @@ class AgentRoom extends EventEmitter {
237
352
  * replicas). Responses are directed to the requester on the results topic
238
353
  * via filter — never load-balanced, never broadcast.
239
354
  */
240
- publishTools(data) {
355
+ publishTools(data, opts) {
241
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.
242
359
  this._publish(TOPIC_RESULTS, data, { filter: data.replyTo });
243
360
  return;
244
361
  }
245
- this._publish(TOPIC_TOOLS, data);
362
+ this._publish(TOPIC_TOOLS, data, filterEmitOptions(opts));
246
363
  }
247
364
  /** Publish to the approval topic (retained) */
248
- publishApproval(data) {
249
- 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;
250
433
  }
251
434
  // ============================================================
252
435
  // INTERNAL (called by NoLagAgents)
@@ -327,13 +510,24 @@ class AgentRoom extends EventEmitter {
327
510
  }
328
511
  _publish(topic, data, options) {
329
512
  this._log(`publish to ${topic} in room ${this.name}`);
330
- if (options) {
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) {
331
516
  this._roomContext.emit(topic, data, options);
332
517
  }
333
518
  else {
334
519
  this._roomContext.emit(topic, data);
335
520
  }
336
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
+ }
337
531
  _toConnectedAgent(actor) {
338
532
  const presence = (actor.presence || actor.data || {});
339
533
  return {
@@ -369,8 +563,8 @@ class AgentRoom extends EventEmitter {
369
563
  // setting, so a pool shares each message one-of-N (no double handling):
370
564
  // - tasks: each task goes to exactly one worker in the group
371
565
  // - tools: each tool REQUEST goes to exactly one tool-server replica
372
- this._roomContext.subscribe(TOPIC_TASKS);
373
- this._roomContext.subscribe(TOPIC_TOOLS);
566
+ this._subscribeFiltered(TOPIC_TASKS, this._filters.tasks);
567
+ this._subscribeFiltered(TOPIC_TOOLS, this._filters.tools);
374
568
  // Replies are DIRECTED, not broadcast: the results topic carries task
375
569
  // results and tool responses published with `filter: <recipient agentId>`,
376
570
  // and each agent subscribes only to its own filter sub-topic. The broker
@@ -385,9 +579,13 @@ class AgentRoom extends EventEmitter {
385
579
  // Broadcast topics must always fan out, even when the connection enables
386
580
  // loadBalance for work distribution: state/events are broadcasts by
387
581
  // nature; inbox and approval messages are claimed client-side.
388
- const broadcastTopics = [TOPIC_STATE, TOPIC_EVENTS, TOPIC_INBOX, TOPIC_APPROVAL];
389
- for (const topic of broadcastTopics) {
390
- this._roomContext.subscribe(topic, { loadBalance: false });
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
+ });
391
589
  }
392
590
  // Simple 1:1 mappings
393
591
  const simpleMap = [
@@ -438,58 +636,6 @@ class AgentRoom extends EventEmitter {
438
636
  }
439
637
  }
440
638
 
441
- /**
442
- * Generate a unique ID.
443
- * Uses crypto.randomUUID when available, falls back to a simple random string.
444
- */
445
- function generateId() {
446
- if (typeof crypto !== "undefined" &&
447
- typeof crypto.randomUUID === "function") {
448
- return crypto.randomUUID();
449
- }
450
- return "xxxx-xxxx-xxxx-xxxx".replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
451
- }
452
- /**
453
- * Create a debug logger that only logs when enabled.
454
- */
455
- function createLogger(prefix, enabled) {
456
- if (!enabled) {
457
- return (..._args) => { };
458
- }
459
- return (...args) => {
460
- console.log(`[${prefix}]`, ...args);
461
- };
462
- }
463
- /**
464
- * Create a Unix millisecond timestamp.
465
- */
466
- function createTimestamp() {
467
- return Date.now();
468
- }
469
- // ============ Wrapper registry ============
470
- // One wrapper instance per (client, appName): two wrappers sharing an app on
471
- // one connection would collide on topics, presence and the lobby.
472
- // Warn (not throw): HMR and tests legitimately construct before disposing.
473
- const wrapperRegistry = new WeakMap();
474
- /** Register a wrapper against a client + appName; warns on collision. */
475
- function registerWrapper(client, appName, wrapperName) {
476
- let apps = wrapperRegistry.get(client);
477
- if (!apps) {
478
- apps = new Map();
479
- wrapperRegistry.set(client, apps);
480
- }
481
- const existing = apps.get(appName);
482
- if (existing) {
483
- console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
484
- `Use one wrapper per (client, app) — detach the other instance first.`);
485
- }
486
- apps.set(appName, wrapperName);
487
- }
488
- /** Release a wrapper's (client, appName) registration on detach. */
489
- function releaseWrapper(client, appName) {
490
- wrapperRegistry.get(client)?.delete(appName);
491
- }
492
-
493
639
  /**
494
640
  * NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
495
641
  *
@@ -761,12 +907,16 @@ class NoLagAgents extends EventEmitter {
761
907
  * Get or create an AgentRoom wrapper.
762
908
  * If the room hasn't been joined yet, it will be joined automatically.
763
909
  */
764
- room(name) {
910
+ room(name, opts) {
765
911
  this._assertUsable();
766
912
  const existing = this._rooms.get(name);
767
- 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);
768
917
  return existing;
769
- return this._joinRoomInternal(name);
918
+ }
919
+ return this._joinRoomInternal(name, opts?.filters);
770
920
  }
771
921
  // ============ Lobby (cross-room presence observation) ============
772
922
  /**
@@ -804,10 +954,10 @@ class NoLagAgents extends EventEmitter {
804
954
  }
805
955
  }
806
956
  // ============ Private: Room Setup ============
807
- _joinRoomInternal(name) {
957
+ _joinRoomInternal(name, filters) {
808
958
  this._log(`joining room: ${name}`);
809
959
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
810
- 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);
811
961
  this._rooms.set(name, room);
812
962
  return room;
813
963
  }
@@ -1349,6 +1499,11 @@ class Blackboard {
1349
1499
  *
1350
1500
  * Agents emit structured events; observers/dashboards subscribe to the stream.
1351
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`.
1352
1507
  */
1353
1508
  class Observe {
1354
1509
  constructor(room, emittedBy) {
@@ -1357,10 +1512,28 @@ class Observe {
1357
1512
  }
1358
1513
  /**
1359
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.
1360
1520
  */
1361
- emit(category, payload, severity = "info") {
1521
+ emit(category, payload, severity = "info", opts) {
1362
1522
  const envelope = createEventEnvelope(category, this._emittedBy, payload, severity);
1363
- 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" });
1364
1537
  }
1365
1538
  /**
1366
1539
  * Listen for events, optionally filtered by category or severity.