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