@nolag/agents 0.4.1 → 1.1.1

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
@@ -1,5 +1,3 @@
1
- import { NoLag } from '@nolag/js-sdk';
2
-
3
1
  /**
4
2
  * Tiny typed event emitter — framework-agnostic base for NoLagAgents and AgentRoom.
5
3
  *
@@ -36,7 +34,19 @@ class EventEmitter {
36
34
  return;
37
35
  for (const handler of handlers) {
38
36
  try {
39
- 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
+ }
40
50
  }
41
51
  catch (e) {
42
52
  console.error(`Error in ${String(event)} handler:`, e);
@@ -66,23 +76,31 @@ const TOPIC_TOOLS = "tools";
66
76
  const TOPIC_APPROVAL = "approval";
67
77
  /** Default room for agent coordination */
68
78
  const DEFAULT_ROOM = "default-workflow";
79
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
80
+ const LOBBY_REFRESH_DELAY_MS = 2000;
69
81
  /** Agents-protocol version: 2 = directed replies (filter-routed results),
70
82
  * NO_HANDLER NACKs, presence protocol advertisement. Absent/1 = legacy
71
83
  * broadcast replies (pre-0.2.0 SDKs). */
72
84
  const AGENTS_PROTOCOL_VERSION = 2;
73
85
 
74
86
  /**
75
- * AgentRoom — wraps a RoomContext from @nolag/js-sdk.
87
+ * AgentRoom — a single agent-coordination room (scoped unit).
88
+ *
89
+ * Wraps a RoomContext from @nolag/js-sdk with typed pub/sub for agent
90
+ * coordination topics, presence-based service discovery, and capability
91
+ * routing.
76
92
  *
77
- * Provides typed pub/sub for agent coordination topics,
78
- * presence-based service discovery, and capability routing.
93
+ * Created via `NoLagAgents.room(name)`. Do not instantiate directly. Presence
94
+ * events are routed in by the parent NoLagAgents (which owns the shared
95
+ * client's connection-level presence handlers); the room only wires its own
96
+ * topic handlers on its RoomContext, and cleanup removes exactly those.
79
97
  *
80
98
  * @example
81
99
  * ```typescript
82
100
  * const room = agents.room('default-workflow');
83
101
  *
84
102
  * // Service discovery - see who's connected
85
- * const agents = room.getConnectedAgents();
103
+ * const connected = room.getConnectedAgents();
86
104
  * const summarizers = room.findAgents('summarize');
87
105
  *
88
106
  * // Capability-filtered task handler
@@ -90,28 +108,32 @@ const AGENTS_PROTOCOL_VERSION = 2;
90
108
  * ```
91
109
  */
92
110
  class AgentRoom extends EventEmitter {
93
- constructor(name, roomContext, client, log, agentId, presence) {
111
+ /** @internal */
112
+ constructor(name, roomContext, log, agentId, appName, isConnected, presence) {
94
113
  super();
95
114
  /** Registry of connected agents discovered via presence */
96
115
  this._agents = new Map();
116
+ // Stored topic handler refs — cleanup removes exactly these, never all
117
+ // handlers for a topic (the client may be shared with other consumers).
118
+ this._topicHandlers = [];
97
119
  this.name = name;
98
120
  this.agentId = agentId;
99
121
  this._roomContext = roomContext;
100
- this._client = client;
101
122
  this._log = log;
123
+ this._appName = appName;
124
+ this._isConnected = isConnected;
102
125
  this._presence = presence;
103
126
  this._wireTopicListeners();
104
- this._wirePresenceListeners();
105
127
  // Set presence if provided (with the SDK's protocol version advertised
106
- // so counterparts can detect incompatible reply semantics)
128
+ // so counterparts can detect incompatible reply semantics, and a __scope
129
+ // tag so co-attached wrappers on other apps filter our presence out).
107
130
  if (presence) {
108
- const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
109
- this._presence = withProtocol;
110
- this._log(`setting presence in room ${name}:`, withProtocol);
111
- this._roomContext.setPresence(withProtocol);
131
+ this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
132
+ this._log(`setting presence in room ${name}:`, this._presence);
133
+ this._roomContext.setPresence({ ...this._presence, __scope: appName });
112
134
  }
113
135
  // Fetch initial presence snapshot
114
- this._fetchInitialPresence();
136
+ void this._fetchInitialPresence();
115
137
  }
116
138
  // ============================================================
117
139
  // SERVICE DISCOVERY
@@ -143,10 +165,9 @@ class AgentRoom extends EventEmitter {
143
165
  // ============================================================
144
166
  /** Update this agent's presence data (protocol version auto-injected) */
145
167
  setPresence(data) {
146
- const withProtocol = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
147
- this._presence = withProtocol;
168
+ this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
148
169
  this._log(`updating presence in room ${this.name}`);
149
- this._roomContext.setPresence(withProtocol);
170
+ this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
150
171
  }
151
172
  /** Fetch current presence snapshot for this room */
152
173
  async fetchPresence() {
@@ -158,24 +179,13 @@ class AgentRoom extends EventEmitter {
158
179
  return [];
159
180
  }
160
181
  }
161
- /**
162
- * @internal Emit a presence event (used by NoLagAgents for lobby forwarding)
163
- */
164
- _emitPresence(event, actorId, data) {
165
- if (event === 'presenceLeave') {
166
- this.emit('presenceLeave', actorId);
167
- }
168
- else {
169
- this.emit(event, actorId, data || {});
170
- }
171
- }
172
- // ============================================================
173
- // PUBLISH (with automatic agentId injection)
174
- // ============================================================
175
182
  /** Get the underlying RoomContext for advanced usage */
176
183
  get context() {
177
184
  return this._roomContext;
178
185
  }
186
+ // ============================================================
187
+ // PUBLISH (with automatic agentId injection)
188
+ // ============================================================
179
189
  /** Publish to the tasks topic */
180
190
  publishTask(envelope) {
181
191
  // Auto-set createdBy if not set
@@ -237,8 +247,82 @@ class AgentRoom extends EventEmitter {
237
247
  this._publish(TOPIC_APPROVAL, data, { retain: true });
238
248
  }
239
249
  // ============================================================
250
+ // INTERNAL (called by NoLagAgents)
251
+ // ============================================================
252
+ /** @internal Re-apply local presence after a reconnect (core does not restore it) */
253
+ _updateLocalPresence() {
254
+ if (this._presence) {
255
+ this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
256
+ }
257
+ }
258
+ /** @internal Route a presence:join event in from the parent */
259
+ _handlePresenceJoin(actorId, data) {
260
+ const d = data || {};
261
+ const agent = {
262
+ actorId,
263
+ name: d.name || actorId,
264
+ role: d.role || "agent",
265
+ capabilities: d.capabilities || [],
266
+ metadata: d.metadata,
267
+ connectedAt: Date.now(),
268
+ protocol: typeof d.protocol === "number" ? d.protocol : 1,
269
+ };
270
+ this._agents.set(actorId, agent);
271
+ this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
272
+ this.emit("presenceJoin", actorId, d);
273
+ }
274
+ /** @internal Route a presence:leave event in from the parent */
275
+ _handlePresenceLeave(actorId) {
276
+ const agent = this._agents.get(actorId);
277
+ this._agents.delete(actorId);
278
+ this._log(`agent left room ${this.name}:`, agent?.name || actorId);
279
+ this.emit("presenceLeave", actorId);
280
+ }
281
+ /** @internal Route a presence:update event in from the parent */
282
+ _handlePresenceUpdate(actorId, data) {
283
+ const d = data || {};
284
+ const existing = this._agents.get(actorId);
285
+ const agent = {
286
+ actorId,
287
+ name: d.name || existing?.name || actorId,
288
+ role: d.role || existing?.role || "agent",
289
+ capabilities: d.capabilities || existing?.capabilities || [],
290
+ metadata: d.metadata || existing?.metadata,
291
+ connectedAt: existing?.connectedAt || Date.now(),
292
+ protocol: typeof d.protocol === "number" ? d.protocol : (existing?.protocol ?? 1),
293
+ };
294
+ this._agents.set(actorId, agent);
295
+ this.emit("presenceUpdate", actorId, d);
296
+ }
297
+ /**
298
+ * @internal Unsubscribe topics (when connected) and remove exactly this
299
+ * room's handler refs. Handler-specific removal only: the client may be
300
+ * shared, and a bare off(topic) would strip other consumers' handlers too.
301
+ */
302
+ _cleanup() {
303
+ this._log(`room cleanup: ${this.name}`);
304
+ // Server unsubscribes need a live socket; skip when disconnected
305
+ // (best-effort — the core would no-op with an error callback anyway).
306
+ if (this._isConnected()) {
307
+ const topics = new Set(this._topicHandlers.map((t) => t.topic));
308
+ for (const topic of topics) {
309
+ this._roomContext.unsubscribe(topic);
310
+ }
311
+ }
312
+ for (const { topic, handler } of this._topicHandlers) {
313
+ this._roomContext.off(topic, handler);
314
+ }
315
+ this._topicHandlers = [];
316
+ this._agents.clear();
317
+ this.removeAllListeners();
318
+ }
319
+ // ============================================================
240
320
  // INTERNALS
241
321
  // ============================================================
322
+ _on(topic, handler) {
323
+ this._topicHandlers.push({ topic, handler });
324
+ this._roomContext.on(topic, handler);
325
+ }
242
326
  _publish(topic, data, options) {
243
327
  this._log(`publish to ${topic} in room ${this.name}`);
244
328
  if (options) {
@@ -249,15 +333,15 @@ class AgentRoom extends EventEmitter {
249
333
  }
250
334
  }
251
335
  _toConnectedAgent(actor) {
252
- const presence = actor.presence || actor.data || {};
336
+ const presence = (actor.presence || actor.data || {});
253
337
  return {
254
- actorId: actor.actorTokenId || actor.actorId || '',
255
- name: presence.name || actor.actorTokenId || '',
256
- role: presence.role || 'agent',
338
+ actorId: actor.actorTokenId || actor.actorId || "",
339
+ name: presence.name || actor.actorTokenId || "",
340
+ role: presence.role || "agent",
257
341
  capabilities: presence.capabilities || [],
258
342
  metadata: presence.metadata,
259
343
  connectedAt: actor.joinedAt || Date.now(),
260
- protocol: typeof presence.protocol === 'number' ? presence.protocol : 1,
344
+ protocol: typeof presence.protocol === "number" ? presence.protocol : 1,
261
345
  status: actor.status,
262
346
  };
263
347
  }
@@ -278,62 +362,6 @@ class AgentRoom extends EventEmitter {
278
362
  // fetchPresence may not be available yet
279
363
  }
280
364
  }
281
- _wirePresenceListeners() {
282
- if (!this._client)
283
- return;
284
- const client = this._client;
285
- client.on?.('presence:join', (evt) => {
286
- if (evt?.roomId === this.name || !evt?.roomId) {
287
- const id = evt?.actorId || evt?.actorTokenId;
288
- const data = evt?.data || evt?.presence || {};
289
- if (id) {
290
- const agent = {
291
- actorId: id,
292
- name: data.name || id,
293
- role: data.role || 'agent',
294
- capabilities: data.capabilities || [],
295
- metadata: data.metadata,
296
- connectedAt: Date.now(),
297
- protocol: typeof data.protocol === 'number' ? data.protocol : 1,
298
- };
299
- this._agents.set(id, agent);
300
- this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
301
- this.emit('presenceJoin', id, data);
302
- }
303
- }
304
- });
305
- client.on?.('presence:leave', (evt) => {
306
- if (evt?.roomId === this.name || !evt?.roomId) {
307
- const id = evt?.actorId || evt?.actorTokenId;
308
- if (id) {
309
- const agent = this._agents.get(id);
310
- this._agents.delete(id);
311
- this._log(`agent left room ${this.name}:`, agent?.name || id);
312
- this.emit('presenceLeave', id);
313
- }
314
- }
315
- });
316
- client.on?.('presence:update', (evt) => {
317
- if (evt?.roomId === this.name || !evt?.roomId) {
318
- const id = evt?.actorId || evt?.actorTokenId;
319
- const data = evt?.data || evt?.presence || {};
320
- if (id) {
321
- const existing = this._agents.get(id);
322
- const agent = {
323
- actorId: id,
324
- name: data.name || existing?.name || id,
325
- role: data.role || existing?.role || 'agent',
326
- capabilities: data.capabilities || existing?.capabilities || [],
327
- metadata: data.metadata || existing?.metadata,
328
- connectedAt: existing?.connectedAt || Date.now(),
329
- protocol: typeof data.protocol === 'number' ? data.protocol : (existing?.protocol ?? 1),
330
- };
331
- this._agents.set(id, agent);
332
- this.emit('presenceUpdate', id, data);
333
- }
334
- }
335
- });
336
- }
337
365
  _wireTopicListeners() {
338
366
  // Work distribution topics honour the connection-level loadBalance
339
367
  // setting, so a pool shares each message one-of-N (no double handling):
@@ -367,14 +395,14 @@ class AgentRoom extends EventEmitter {
367
395
  { topic: TOPIC_INBOX, event: "inbox" },
368
396
  ];
369
397
  for (const { topic, event } of simpleMap) {
370
- this._roomContext.on(topic, (data) => {
398
+ this._on(topic, (data) => {
371
399
  this._log(`received ${topic} in room ${this.name}`);
372
400
  this.emit(event, data);
373
401
  });
374
402
  }
375
403
  // Multiplexed: results topic carries task results AND tool responses,
376
404
  // both filter-directed to this agent.
377
- this._roomContext.on(TOPIC_RESULTS, (data) => {
405
+ this._on(TOPIC_RESULTS, (data) => {
378
406
  this._log(`received ${TOPIC_RESULTS} in room ${this.name}`);
379
407
  if (data?.type === "tool_response") {
380
408
  this.emit("toolResponse", data);
@@ -384,7 +412,7 @@ class AgentRoom extends EventEmitter {
384
412
  }
385
413
  });
386
414
  // Multiplexed: approval topic carries requests + responses
387
- this._roomContext.on(TOPIC_APPROVAL, (data) => {
415
+ this._on(TOPIC_APPROVAL, (data) => {
388
416
  this._log(`received ${TOPIC_APPROVAL} in room ${this.name}`);
389
417
  if (data?.type === "approval_response") {
390
418
  this.emit("approvalResponse", data);
@@ -396,7 +424,7 @@ class AgentRoom extends EventEmitter {
396
424
  // Tools topic carries requests; tool_response is still accepted here for
397
425
  // backward compatibility with responders on older SDK versions (their
398
426
  // responses are only reliable when the requester is not load-balanced).
399
- this._roomContext.on(TOPIC_TOOLS, (data) => {
427
+ this._on(TOPIC_TOOLS, (data) => {
400
428
  this._log(`received ${TOPIC_TOOLS} in room ${this.name}`);
401
429
  if (data?.type === "tool_response") {
402
430
  this.emit("toolResponse", data);
@@ -436,6 +464,29 @@ function createLogger(prefix, enabled) {
436
464
  function createTimestamp() {
437
465
  return Date.now();
438
466
  }
467
+ // ============ Wrapper registry ============
468
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
469
+ // one connection would collide on topics, presence and the lobby.
470
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
471
+ const wrapperRegistry = new WeakMap();
472
+ /** Register a wrapper against a client + appName; warns on collision. */
473
+ function registerWrapper(client, appName, wrapperName) {
474
+ let apps = wrapperRegistry.get(client);
475
+ if (!apps) {
476
+ apps = new Map();
477
+ wrapperRegistry.set(client, apps);
478
+ }
479
+ const existing = apps.get(appName);
480
+ if (existing) {
481
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
482
+ `Use one wrapper per (client, app) — detach the other instance first.`);
483
+ }
484
+ apps.set(appName, wrapperName);
485
+ }
486
+ /** Release a wrapper's (client, appName) registration on detach. */
487
+ function releaseWrapper(client, appName) {
488
+ wrapperRegistry.get(client)?.delete(appName);
489
+ }
439
490
 
440
491
  /**
441
492
  * NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
@@ -443,193 +494,438 @@ function createTimestamp() {
443
494
  * Provides typed rooms for multi-agent patterns: Handoff, Blackboard,
444
495
  * Inbox, Tools, Approval, and Observe.
445
496
  *
497
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
498
+ * client (shared by any number of wrappers on distinct apps) and the
499
+ * wrapper attaches to it at construction and releases it via `detach()`.
500
+ *
446
501
  * @example
447
502
  * ```typescript
503
+ * import { NoLag } from '@nolag/js-sdk';
448
504
  * import { NoLagAgents } from '@nolag/agents';
449
505
  *
450
- * const agents = new NoLagAgents(token, {
506
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
507
+ * const agents = new NoLagAgents({
508
+ * client,
451
509
  * appName: 'my-workflow',
452
510
  * agentId: 'worker-1',
453
511
  * presence: { name: 'worker-1', role: 'agent', capabilities: ['summarize'] },
454
512
  * });
455
- * await agents.connect();
513
+ *
514
+ * await client.connect(); // the app owns the connection
515
+ * await agents.ready(); // wrapper setup done (identity, rooms, lobby)
456
516
  *
457
517
  * const room = agents.room('default-workflow');
458
- * room.handoff.onTask(['summarize'], async (task, respond) => {
459
- * const result = await summarize(task.payload);
460
- * respond('success', { result });
461
- * });
518
+ * room.on('task', (task) => console.log('New task:', task));
519
+ *
520
+ * agents.detach(); // wrapper releases its handlers and topics
521
+ * client.disconnect(); // the app closes the socket
462
522
  * ```
463
523
  */
464
524
  class NoLagAgents extends EventEmitter {
465
- constructor(token, options = {}) {
525
+ constructor(options) {
466
526
  super();
467
- this._client = null;
468
- this._appContext = null;
469
527
  this._rooms = new Map();
470
- this._connected = false;
471
- this._token = token;
528
+ this._lobby = null;
529
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
530
+ this._epoch = 0;
531
+ this._detached = false;
532
+ this._isReady = false;
533
+ this._lobbyRefreshTimer = null;
534
+ // Stored client handler refs. INVARIANT: every client.on() below has a
535
+ // matching client.off() in detach() — never bare off(event), never inline
536
+ // closures on the client.
537
+ this._onConnectRef = () => this._onConnect();
538
+ this._onDisconnectRef = (reason) => {
539
+ this._log("disconnected:", reason);
540
+ this.emit("disconnected", reason);
541
+ };
542
+ this._onReconnectRef = () => {
543
+ this._log("reconnecting...");
544
+ this.emit("reconnecting");
545
+ };
546
+ this._onErrorRef = (error) => {
547
+ this._log("error:", error?.message ?? error);
548
+ this.emit("error", error);
549
+ };
550
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
551
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
552
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
553
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
554
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
555
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
556
+ if (!options?.client) {
557
+ throw new TypeError("NoLagAgents requires an injected NoLag client: new NoLagAgents({ client, ... })");
558
+ }
559
+ this._client = options.client;
472
560
  this._options = {
473
561
  appName: options.appName ?? DEFAULT_APP_NAME,
474
562
  agentId: options.agentId ?? generateId(),
563
+ name: options.name,
564
+ role: options.role,
475
565
  debug: options.debug ?? false,
476
566
  rooms: options.rooms ?? [DEFAULT_ROOM],
477
567
  lobby: options.lobby,
478
- presence: options.presence,
479
- clientOptions: options.clientOptions,
568
+ presence: options.presence ?? this._presenceFromIdentity(options),
480
569
  };
481
570
  this._log = createLogger("NoLagAgents", this._options.debug);
571
+ this._readyPromise = new Promise((resolve, reject) => {
572
+ this._readyResolve = resolve;
573
+ this._readyReject = reject;
574
+ });
575
+ // ready() rejection is only meaningful to callers that await it
576
+ this._readyPromise.catch(() => { });
577
+ registerWrapper(this._client, this._options.appName, "NoLagAgents");
578
+ // Construction = attach: wire everything now, with stored refs.
579
+ this._client.on("connect", this._onConnectRef);
580
+ this._client.on("disconnect", this._onDisconnectRef);
581
+ this._client.on("reconnect", this._onReconnectRef);
582
+ this._client.on("error", this._onErrorRef);
583
+ this._client.on("presence:join", this._onPresenceJoinRef);
584
+ this._client.on("presence:leave", this._onPresenceLeaveRef);
585
+ this._client.on("presence:update", this._onPresenceUpdateRef);
586
+ this._client.on("lobbyPresence:join", this._onLobbyJoinRef);
587
+ this._client.on("lobbyPresence:leave", this._onLobbyLeaveRef);
588
+ this._client.on("lobbyPresence:update", this._onLobbyUpdateRef);
589
+ // Attach-to-connected: if the client is already authenticated, run setup.
590
+ // The microtask lets the caller wire wrapper event handlers synchronously
591
+ // first; a racing real 'connect' event wins via the epoch guard.
592
+ queueMicrotask(() => {
593
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
594
+ this._onConnect();
595
+ }
596
+ });
482
597
  }
598
+ // ============ Public Properties ============
483
599
  /** The agent's unique ID */
484
600
  get agentId() {
485
601
  return this._options.agentId;
486
602
  }
487
- /** Whether the client is connected */
603
+ /** Whether the underlying connection is established (connected ≠ ready) */
488
604
  get connected() {
489
- return this._connected;
605
+ return !this._detached && this._client.connected;
606
+ }
607
+ /** The injected core client (owned by the app, not the wrapper) */
608
+ get client() {
609
+ return this._client;
490
610
  }
491
611
  /** Map of joined rooms */
492
612
  get rooms() {
493
613
  return this._rooms;
494
614
  }
495
- /** Connect to NoLag and join configured rooms */
496
- async connect() {
497
- this._log("connecting...");
498
- this._client = NoLag(this._token, {
499
- ...this._options.clientOptions,
500
- });
501
- this._appContext = this._client.setApp(this._options.appName);
502
- this._client.on("connected", () => {
503
- this._connected = true;
504
- this._log("connected");
615
+ // ============ Lifecycle ============
616
+ /**
617
+ * Resolves once the wrapper's first setup completed (identity, configured
618
+ * rooms and — when configured — the lobby ready; equivalently, once
619
+ * 'connected' has fired). Rejects only if detach() is called before that.
620
+ * Client auth failures surface via the app's own `await client.connect()`.
621
+ */
622
+ ready() {
623
+ return this._readyPromise;
624
+ }
625
+ /**
626
+ * Detach from the client: remove every handler this wrapper added,
627
+ * unsubscribe its topics and lobby (when connected), clear state.
628
+ * Terminal and idempotent; never touches the socket. To use agents again,
629
+ * construct a new instance.
630
+ */
631
+ detach() {
632
+ if (this._detached)
633
+ return;
634
+ this._log("detaching...");
635
+ this._detached = true;
636
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
637
+ if (this._lobbyRefreshTimer) {
638
+ clearTimeout(this._lobbyRefreshTimer);
639
+ this._lobbyRefreshTimer = null;
640
+ }
641
+ // Remove all client handlers by stored ref
642
+ this._client.off("connect", this._onConnectRef);
643
+ this._client.off("disconnect", this._onDisconnectRef);
644
+ this._client.off("reconnect", this._onReconnectRef);
645
+ this._client.off("error", this._onErrorRef);
646
+ this._client.off("presence:join", this._onPresenceJoinRef);
647
+ this._client.off("presence:leave", this._onPresenceLeaveRef);
648
+ this._client.off("presence:update", this._onPresenceUpdateRef);
649
+ this._client.off("lobbyPresence:join", this._onLobbyJoinRef);
650
+ this._client.off("lobbyPresence:leave", this._onLobbyLeaveRef);
651
+ this._client.off("lobbyPresence:update", this._onLobbyUpdateRef);
652
+ // Rooms: handler-specific off + connected-gated server unsubscribe
653
+ for (const name of [...this._rooms.keys()]) {
654
+ this._rooms.get(name)._cleanup();
655
+ this._rooms.delete(name);
656
+ }
657
+ // Lobby: server unsubscribe is best-effort and needs a live socket
658
+ if (this._lobby && this._client.connected) {
659
+ try {
660
+ this._lobby.unsubscribe();
661
+ }
662
+ catch {
663
+ /* best-effort */
664
+ }
665
+ }
666
+ this._lobby = null;
667
+ releaseWrapper(this._client, this._options.appName);
668
+ if (!this._isReady) {
669
+ this._readyReject(new Error("NoLagAgents detached before ready"));
670
+ }
671
+ }
672
+ // ============ Private: Epoch Setup ============
673
+ _onConnect() {
674
+ this._epoch++;
675
+ void this._runSetup(this._epoch);
676
+ }
677
+ /**
678
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
679
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
680
+ * epoch started or the wrapper detached — checked after every await.
681
+ */
682
+ async _runSetup(epoch) {
683
+ const stale = () => epoch !== this._epoch || this._detached;
684
+ this._log(this._isReady ? "restoring after reconnect..." : "setting up...");
685
+ this._log("agentId:", this._options.agentId, "→ actorId:", this._client.actorId);
686
+ if (!this._isReady) {
687
+ // First successful setup: auto-join configured rooms.
688
+ for (const roomName of this._options.rooms) {
689
+ this._joinRoomInternal(roomName);
690
+ }
691
+ }
692
+ else {
693
+ // Reconnect: the core auto-restores topic subscriptions, but not
694
+ // room-scoped presence — re-apply each room's local presence.
695
+ for (const room of this._rooms.values()) {
696
+ room._updateLocalPresence();
697
+ }
698
+ }
699
+ // Lobby is OPTIONAL: only when configured. Subscribe every epoch
700
+ // (idempotent server-side) and diff-hydrate from the returned snapshot —
701
+ // one path for setup and reconnect restore.
702
+ if (this._options.lobby) {
703
+ if (!this._lobby) {
704
+ this._lobby = this._client.setApp(this._options.appName).setLobby(this._options.lobby);
705
+ }
706
+ try {
707
+ const state = await this._lobby.subscribe();
708
+ if (stale())
709
+ return;
710
+ this._diffHydrateLobby(state);
711
+ this._log("lobby subscribed:", this._options.lobby);
712
+ }
713
+ catch (err) {
714
+ if (stale())
715
+ return;
716
+ this._log("lobby subscription failed:", err);
717
+ }
718
+ }
719
+ if (stale())
720
+ return;
721
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
722
+ // epoch aborted by a racing reconnect must not strand ready().
723
+ if (!this._isReady) {
724
+ this._isReady = true;
725
+ this._readyResolve();
505
726
  this.emit("connected");
506
- });
507
- this._client.on("disconnected", (reason) => {
508
- this._connected = false;
509
- this._log("disconnected:", reason);
510
- this.emit("disconnected", reason);
511
- });
512
- this._client.on("reconnected", () => {
513
- this._connected = true;
514
- this._log("reconnected");
727
+ }
728
+ else {
515
729
  this.emit("reconnected");
516
- });
517
- this._client.on("error", (err) => {
518
- this._log("error:", err.message);
519
- this.emit("error", err);
520
- });
521
- await this._client.connect();
522
- // Auto-join configured rooms
523
- for (const roomName of this._options.rooms) {
524
- this.room(roomName);
525
730
  }
526
- // Auto-subscribe to lobby if configured (for cross-room presence observation)
731
+ // Deferred lobby refetch: catches agents who joined during the setup
732
+ // window (only when the lobby is configured).
527
733
  if (this._options.lobby) {
528
- await this.subscribeLobby(this._options.lobby);
734
+ this._scheduleLobbyRefresh(epoch);
529
735
  }
530
736
  }
737
+ _scheduleLobbyRefresh(epoch) {
738
+ if (this._lobbyRefreshTimer)
739
+ clearTimeout(this._lobbyRefreshTimer);
740
+ this._lobbyRefreshTimer = setTimeout(() => {
741
+ this._lobbyRefreshTimer = null;
742
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
743
+ return;
744
+ }
745
+ this._lobby
746
+ .fetchPresence()
747
+ .then((state) => {
748
+ if (epoch !== this._epoch || this._detached)
749
+ return;
750
+ this._diffHydrateLobby(state);
751
+ })
752
+ .catch(() => {
753
+ /* best-effort */
754
+ });
755
+ }, LOBBY_REFRESH_DELAY_MS);
756
+ }
757
+ // ============ Room Management ============
758
+ /**
759
+ * Get or create an AgentRoom wrapper.
760
+ * If the room hasn't been joined yet, it will be joined automatically.
761
+ */
762
+ room(name) {
763
+ this._assertUsable();
764
+ const existing = this._rooms.get(name);
765
+ if (existing)
766
+ return existing;
767
+ return this._joinRoomInternal(name);
768
+ }
769
+ // ============ Lobby (cross-room presence observation) ============
531
770
  /**
532
- * Subscribe to a lobby for cross-room presence observation.
533
- * Lobby presence events are forwarded to all AgentRooms.
771
+ * Subscribe to a lobby for cross-room presence observation. Lobby presence
772
+ * events are forwarded into all AgentRooms. Prefer the `lobby` constructor
773
+ * option — this method is for on-demand subscription after ready.
534
774
  *
535
775
  * Returns the initial presence snapshot.
536
776
  */
537
777
  async subscribeLobby(lobbySlug) {
538
- if (!this._appContext) {
539
- throw new Error("Not connected. Call connect() before subscribing to lobbies.");
540
- }
778
+ this._assertUsable();
541
779
  this._log(`subscribing to lobby: ${lobbySlug}`);
542
- const lobby = this._appContext.setLobby(lobbySlug);
543
- // Listen for lobby presence events on the client
544
- // (lobby.on() uses lobby UUID internally which may not match)
545
- this._client.on('lobbyPresence:join', (evt) => {
546
- const id = evt?.actorId;
547
- const data = evt?.data || {};
548
- if (id) {
549
- this._log(`lobby presence:join — ${data.name || id}`);
550
- for (const room of this._rooms.values()) {
551
- const agents = room._agents;
552
- if (!agents.has(id)) {
553
- agents.set(id, {
554
- actorId: id,
555
- name: data.name || id,
556
- role: data.role || 'agent',
557
- capabilities: data.capabilities || [],
558
- metadata: data.metadata,
559
- connectedAt: Date.now(),
560
- });
561
- }
562
- room._emitPresence('presenceJoin', id, data);
563
- }
564
- }
565
- });
566
- this._client.on('lobbyPresence:leave', (evt) => {
567
- const id = evt?.actorId;
568
- if (id) {
569
- this._log(`lobby presence:leave — ${id}`);
570
- for (const room of this._rooms.values()) {
571
- const agents = room._agents;
572
- agents.delete(id);
573
- room._emitPresence('presenceLeave', id);
574
- }
575
- }
576
- });
577
- this._client.on('lobbyPresence:update', (evt) => {
578
- const id = evt?.actorId;
579
- const data = evt?.data || {};
580
- if (id) {
581
- for (const room of this._rooms.values()) {
582
- const agents = room._agents;
583
- const existing = agents.get(id);
584
- if (existing) {
585
- if (data.name)
586
- existing.name = data.name;
587
- if (data.role)
588
- existing.role = data.role;
589
- if (data.capabilities)
590
- existing.capabilities = data.capabilities;
591
- if (data.metadata)
592
- existing.metadata = data.metadata;
593
- }
594
- room._emitPresence('presenceUpdate', id, data);
595
- }
596
- }
597
- });
780
+ this._options.lobby = lobbySlug;
781
+ if (!this._lobby) {
782
+ this._lobby = this._client.setApp(this._options.appName).setLobby(lobbySlug);
783
+ }
598
784
  try {
599
- const initialState = await lobby.subscribe();
600
- this._log(`lobby subscribed, initial state:`, Object.keys(initialState || {}));
601
- return initialState || {};
785
+ const state = await this._lobby.subscribe();
786
+ if (!this._detached)
787
+ this._diffHydrateLobby(state);
788
+ return state || {};
602
789
  }
603
790
  catch (err) {
604
- this._log(`lobby subscription failed:`, err);
791
+ this._log("lobby subscription failed:", err);
605
792
  return {};
606
793
  }
607
794
  }
608
- /** Disconnect from NoLag */
609
- disconnect() {
610
- this._log("disconnecting...");
611
- this._client?.disconnect();
612
- this._rooms.clear();
613
- this._client = null;
614
- this._appContext = null;
615
- this._connected = false;
795
+ // ============ Private: Guards ============
796
+ _assertUsable() {
797
+ if (this._detached) {
798
+ throw new Error("NoLagAgents has been detached — construct a new instance");
799
+ }
800
+ if (!this._isReady) {
801
+ throw new Error('NoLagAgents not ready — await ready() or the "connected" event');
802
+ }
803
+ }
804
+ // ============ Private: Room Setup ============
805
+ _joinRoomInternal(name) {
806
+ this._log(`joining room: ${name}`);
807
+ const roomContext = this._client.setApp(this._options.appName).setRoom(name);
808
+ const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence);
809
+ this._rooms.set(name, room);
810
+ return room;
616
811
  }
812
+ // ============ Private: Scope Filtering ============
617
813
  /**
618
- * Get or create an AgentRoom wrapper.
619
- * If the room hasn't been joined yet, it will be joined automatically.
814
+ * On a shared client, presence events from other apps' wrappers arrive on
815
+ * the same connection-level events. Wrappers stamp their presence with a
816
+ * `__scope` (their appName); a mismatched tag means another app's data.
817
+ * Untagged presence is accepted (older peers in this same app).
620
818
  */
621
- room(name) {
622
- let agentRoom = this._rooms.get(name);
623
- if (agentRoom)
624
- return agentRoom;
625
- if (!this._appContext) {
626
- throw new Error("Not connected. Call connect() before accessing rooms.");
819
+ _foreignScope(data) {
820
+ const scope = data?.__scope;
821
+ return typeof scope === "string" && scope !== this._options.appName;
822
+ }
823
+ // ============ Private: Room Presence → Rooms ============
824
+ _handleRoomPresenceJoin(data) {
825
+ if (data.actorTokenId === this._client.actorId)
826
+ return;
827
+ const presence = (data.presence || {});
828
+ if (this._foreignScope(presence))
829
+ return;
830
+ const roomId = data.roomId;
831
+ for (const room of this._targetRooms(roomId)) {
832
+ room._handlePresenceJoin(data.actorTokenId, presence);
833
+ }
834
+ }
835
+ _handleRoomPresenceLeave(data) {
836
+ if (data.actorTokenId === this._client.actorId)
837
+ return;
838
+ const roomId = data.roomId;
839
+ for (const room of this._targetRooms(roomId)) {
840
+ room._handlePresenceLeave(data.actorTokenId);
841
+ }
842
+ }
843
+ _handleRoomPresenceUpdate(data) {
844
+ if (data.actorTokenId === this._client.actorId)
845
+ return;
846
+ const presence = (data.presence || {});
847
+ if (this._foreignScope(presence))
848
+ return;
849
+ const roomId = data.roomId;
850
+ for (const room of this._targetRooms(roomId)) {
851
+ room._handlePresenceUpdate(data.actorTokenId, presence);
852
+ }
853
+ }
854
+ /** Rooms a presence event targets: the named room, or all when unscoped. */
855
+ _targetRooms(roomId) {
856
+ if (roomId && this._rooms.has(roomId))
857
+ return [this._rooms.get(roomId)];
858
+ if (roomId)
859
+ return [];
860
+ return [...this._rooms.values()];
861
+ }
862
+ // ============ Private: Lobby → Rooms ============
863
+ _handleLobbyJoin(event) {
864
+ const { actorId, data } = event;
865
+ if (actorId === this._client.actorId)
866
+ return;
867
+ const presence = (data || {});
868
+ if (this._foreignScope(presence))
869
+ return;
870
+ this._log(`lobby presence:join — ${presence.name || actorId}`);
871
+ for (const room of this._rooms.values()) {
872
+ room._handlePresenceJoin(actorId, presence);
627
873
  }
628
- this._log(`joining room: ${name}`);
629
- const roomContext = this._appContext.setRoom(name);
630
- agentRoom = new AgentRoom(name, roomContext, this._client, this._log, this._options.agentId, this._options.presence);
631
- this._rooms.set(name, agentRoom);
632
- return agentRoom;
874
+ }
875
+ _handleLobbyLeave(event) {
876
+ const { actorId, data } = event;
877
+ if (actorId === this._client.actorId)
878
+ return;
879
+ const presence = (data || {});
880
+ if (this._foreignScope(presence))
881
+ return;
882
+ this._log(`lobby presence:leave — ${actorId}`);
883
+ for (const room of this._rooms.values()) {
884
+ room._handlePresenceLeave(actorId);
885
+ }
886
+ }
887
+ _handleLobbyUpdate(event) {
888
+ const { actorId, data } = event;
889
+ if (actorId === this._client.actorId)
890
+ return;
891
+ const presence = (data || {});
892
+ if (this._foreignScope(presence))
893
+ return;
894
+ for (const room of this._rooms.values()) {
895
+ room._handlePresenceUpdate(actorId, presence);
896
+ }
897
+ }
898
+ /**
899
+ * Reconcile the rooms' agent registries against a fresh lobby snapshot,
900
+ * routing each present actor in as a join. One path for initial hydration,
901
+ * reconnect restore, and the deferred refetch.
902
+ */
903
+ _diffHydrateLobby(state) {
904
+ for (const roomId of Object.keys(state)) {
905
+ const roomPresence = state[roomId];
906
+ for (const actorId of Object.keys(roomPresence)) {
907
+ if (actorId === this._client.actorId)
908
+ continue;
909
+ const raw = roomPresence[actorId];
910
+ // Server returns full actor records with presence nested under .presence
911
+ const presence = (raw?.presence ?? raw);
912
+ if (this._foreignScope(presence))
913
+ continue;
914
+ for (const room of this._rooms.values()) {
915
+ room._handlePresenceJoin(actorId, presence);
916
+ }
917
+ }
918
+ }
919
+ }
920
+ // ============ Private: Helpers ============
921
+ /** Derive presence identity from name/role options when no presence given. */
922
+ _presenceFromIdentity(options) {
923
+ if (!options.name && !options.role)
924
+ return undefined;
925
+ return {
926
+ name: options.name ?? (options.agentId ?? "agent"),
927
+ role: options.role ?? "agent",
928
+ };
633
929
  }
634
930
  }
635
931
 
@@ -1139,10 +1435,20 @@ class Tools {
1139
1435
  this._agentId = agentId;
1140
1436
  // Wire response correlation
1141
1437
  this._room.on("toolResponse", (envelope) => {
1438
+ if (!envelope || typeof envelope.correlationId !== "string")
1439
+ return;
1142
1440
  this._correlations.resolve(envelope.correlationId, envelope);
1143
1441
  });
1144
1442
  // Wire request handling
1145
1443
  this._room.on("toolRequest", async (envelope) => {
1444
+ // The tools topic is an open room surface: anything published there
1445
+ // that is not a tool_response is classified as a request, including
1446
+ // foreign or malformed payloads. A request with no toolName cannot be
1447
+ // routed OR NACKed meaningfully — and it must never be able to crash
1448
+ // the host (this listener is async, so an uncaught throw here becomes
1449
+ // an unhandled rejection that terminates the process).
1450
+ if (!envelope || typeof envelope.toolName !== "string")
1451
+ return;
1146
1452
  const handler = this._handlers.get(envelope.toolName);
1147
1453
  // Direct the response back to the requester's filter sub-topic
1148
1454
  const replyTo = envelope.replyTo ?? envelope.requestedBy;
@@ -1191,6 +1497,9 @@ class Tools {
1191
1497
  /** True when this agent hosts handlers in the tool's namespace (prefix
1192
1498
  * before the first '.'); unprefixed tools match any unprefixed handler. */
1193
1499
  _ownsNamespace(toolName) {
1500
+ // Belt and braces: a nameless tool belongs to nobody.
1501
+ if (typeof toolName !== "string")
1502
+ return false;
1194
1503
  if (this._handlers.size === 0)
1195
1504
  return false;
1196
1505
  const ns = toolName.includes(".") ? toolName.slice(0, toolName.indexOf(".")) : null;