@nolag/agents 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AgentRoom.d.ts +49 -8
- package/dist/NoLagAgents.d.ts +2 -2
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +277 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +277 -79
- package/dist/index.mjs.map +1 -1
- package/dist/patterns/observe.d.ts +26 -2
- package/dist/react-native.d.ts +13 -0
- package/dist/react-native.js +1752 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +50 -0
- package/dist/utils.d.ts +49 -0
- package/package.json +11 -5
|
@@ -0,0 +1,1752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny typed event emitter — framework-agnostic base for NoLagAgents and AgentRoom.
|
|
3
|
+
*
|
|
4
|
+
* EventMap is a record of event name -> tuple of handler arguments.
|
|
5
|
+
* e.g. { task: [TaskEnvelope]; result: [ResultEnvelope] }
|
|
6
|
+
*/
|
|
7
|
+
class EventEmitter {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._handlers = new Map();
|
|
10
|
+
}
|
|
11
|
+
on(event, handler) {
|
|
12
|
+
if (!this._handlers.has(event)) {
|
|
13
|
+
this._handlers.set(event, new Set());
|
|
14
|
+
}
|
|
15
|
+
this._handlers.get(event).add(handler);
|
|
16
|
+
return this;
|
|
17
|
+
}
|
|
18
|
+
off(event, handler) {
|
|
19
|
+
if (handler) {
|
|
20
|
+
this._handlers.get(event)?.delete(handler);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
this._handlers.delete(event);
|
|
24
|
+
}
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
removeAllListeners() {
|
|
28
|
+
this._handlers.clear();
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
emit(event, ...args) {
|
|
32
|
+
const handlers = this._handlers.get(event);
|
|
33
|
+
if (!handlers)
|
|
34
|
+
return;
|
|
35
|
+
for (const handler of handlers) {
|
|
36
|
+
try {
|
|
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
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
listenerCount(event) {
|
|
57
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Generate a unique ID.
|
|
63
|
+
* Uses crypto.randomUUID when available, falls back to a simple random string.
|
|
64
|
+
*/
|
|
65
|
+
function generateId() {
|
|
66
|
+
if (typeof crypto !== "undefined" &&
|
|
67
|
+
typeof crypto.randomUUID === "function") {
|
|
68
|
+
return crypto.randomUUID();
|
|
69
|
+
}
|
|
70
|
+
return "xxxx-xxxx-xxxx-xxxx".replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create a debug logger that only logs when enabled.
|
|
74
|
+
*/
|
|
75
|
+
function createLogger(prefix, enabled) {
|
|
76
|
+
if (!enabled) {
|
|
77
|
+
return (..._args) => { };
|
|
78
|
+
}
|
|
79
|
+
return (...args) => {
|
|
80
|
+
console.log(`[${prefix}]`, ...args);
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Create a Unix millisecond timestamp.
|
|
85
|
+
*/
|
|
86
|
+
function createTimestamp() {
|
|
87
|
+
return Date.now();
|
|
88
|
+
}
|
|
89
|
+
// ============ Filters ============
|
|
90
|
+
/**
|
|
91
|
+
* Build the filter fragment of an emit options object.
|
|
92
|
+
*
|
|
93
|
+
* `filter` wins over `filters`: a publish is routed to exactly one topic, so
|
|
94
|
+
* honouring both would silently drop one of them.
|
|
95
|
+
*/
|
|
96
|
+
function filterEmitOptions(opts) {
|
|
97
|
+
if (opts?.filter)
|
|
98
|
+
return { filter: opts.filter };
|
|
99
|
+
if (opts?.filters && opts.filters.length > 0)
|
|
100
|
+
return { filters: opts.filters };
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Merge OR terms into an existing filter set. AND groups (nested arrays) are
|
|
105
|
+
* preserved as-is — only plain string terms are deduplicated.
|
|
106
|
+
*/
|
|
107
|
+
function mergeFilters(existing, add) {
|
|
108
|
+
const simple = new Set();
|
|
109
|
+
const groups = [];
|
|
110
|
+
for (const f of existing) {
|
|
111
|
+
if (typeof f === 'string')
|
|
112
|
+
simple.add(f);
|
|
113
|
+
else
|
|
114
|
+
groups.push(f);
|
|
115
|
+
}
|
|
116
|
+
for (const v of add)
|
|
117
|
+
simple.add(v);
|
|
118
|
+
return [...simple, ...groups];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Drop OR terms from a filter set. AND groups are left untouched — remove
|
|
122
|
+
* those by calling `setFilters` with the set you want.
|
|
123
|
+
*/
|
|
124
|
+
function withoutFilters(existing, remove) {
|
|
125
|
+
const drop = new Set(remove);
|
|
126
|
+
return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
|
|
127
|
+
}
|
|
128
|
+
// ============ Wrapper registry ============
|
|
129
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
130
|
+
// one connection would collide on topics, presence and the lobby.
|
|
131
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
132
|
+
const wrapperRegistry = new WeakMap();
|
|
133
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
134
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
135
|
+
let apps = wrapperRegistry.get(client);
|
|
136
|
+
if (!apps) {
|
|
137
|
+
apps = new Map();
|
|
138
|
+
wrapperRegistry.set(client, apps);
|
|
139
|
+
}
|
|
140
|
+
const existing = apps.get(appName);
|
|
141
|
+
if (existing) {
|
|
142
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
143
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
144
|
+
}
|
|
145
|
+
apps.set(appName, wrapperName);
|
|
146
|
+
}
|
|
147
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
148
|
+
function releaseWrapper(client, appName) {
|
|
149
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Default app name for agent coordination */
|
|
153
|
+
const DEFAULT_APP_NAME = "agents";
|
|
154
|
+
/** Topic name for task dispatch (Handoff pattern) */
|
|
155
|
+
const TOPIC_TASKS = "tasks";
|
|
156
|
+
/** Topic name for task results */
|
|
157
|
+
const TOPIC_RESULTS = "results";
|
|
158
|
+
/** Topic name for shared state (Blackboard pattern) */
|
|
159
|
+
const TOPIC_STATE = "state";
|
|
160
|
+
/** Topic name for observability events (Observe pattern) */
|
|
161
|
+
const TOPIC_EVENTS = "events";
|
|
162
|
+
/** Topic name for per-agent inboxes (Inbox pattern) */
|
|
163
|
+
const TOPIC_INBOX = "inbox";
|
|
164
|
+
/** Topic name for tool invocations (Tools pattern) */
|
|
165
|
+
const TOPIC_TOOLS = "tools";
|
|
166
|
+
/** Topic name for human-in-the-loop approval (Approve pattern) */
|
|
167
|
+
const TOPIC_APPROVAL = "approval";
|
|
168
|
+
/** Default room for agent coordination */
|
|
169
|
+
const DEFAULT_ROOM = "default-workflow";
|
|
170
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
171
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
172
|
+
/** Agents-protocol version: 2 = directed replies (filter-routed results),
|
|
173
|
+
* NO_HANDLER NACKs, presence protocol advertisement. Absent/1 = legacy
|
|
174
|
+
* broadcast replies (pre-0.2.0 SDKs). */
|
|
175
|
+
const AGENTS_PROTOCOL_VERSION = 2;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Maps the public topic names onto the wire topics. `results` is absent by
|
|
179
|
+
* design — it is reserved for directed replies keyed to this agent's id.
|
|
180
|
+
*/
|
|
181
|
+
const FILTER_TOPICS = {
|
|
182
|
+
tasks: TOPIC_TASKS,
|
|
183
|
+
tools: TOPIC_TOOLS,
|
|
184
|
+
state: TOPIC_STATE,
|
|
185
|
+
events: TOPIC_EVENTS,
|
|
186
|
+
inbox: TOPIC_INBOX,
|
|
187
|
+
approval: TOPIC_APPROVAL,
|
|
188
|
+
};
|
|
189
|
+
const ALL_FILTER_TOPICS = Object.keys(FILTER_TOPICS);
|
|
190
|
+
/**
|
|
191
|
+
* AgentRoom — a single agent-coordination room (scoped unit).
|
|
192
|
+
*
|
|
193
|
+
* Wraps a RoomContext from @nolag/js-sdk with typed pub/sub for agent
|
|
194
|
+
* coordination topics, presence-based service discovery, and capability
|
|
195
|
+
* routing.
|
|
196
|
+
*
|
|
197
|
+
* Created via `NoLagAgents.room(name)`. Do not instantiate directly. Presence
|
|
198
|
+
* events are routed in by the parent NoLagAgents (which owns the shared
|
|
199
|
+
* client's connection-level presence handlers); the room only wires its own
|
|
200
|
+
* topic handlers on its RoomContext, and cleanup removes exactly those.
|
|
201
|
+
*
|
|
202
|
+
* @example
|
|
203
|
+
* ```typescript
|
|
204
|
+
* const room = agents.room('default-workflow');
|
|
205
|
+
*
|
|
206
|
+
* // Service discovery - see who's connected
|
|
207
|
+
* const connected = room.getConnectedAgents();
|
|
208
|
+
* const summarizers = room.findAgents('summarize');
|
|
209
|
+
*
|
|
210
|
+
* // Capability-filtered task handler
|
|
211
|
+
* room.on('task', (envelope) => console.log('New task:', envelope));
|
|
212
|
+
* ```
|
|
213
|
+
*/
|
|
214
|
+
class AgentRoom extends EventEmitter {
|
|
215
|
+
/** @internal */
|
|
216
|
+
constructor(name, roomContext, log, agentId, appName, isConnected, presence, filters) {
|
|
217
|
+
super();
|
|
218
|
+
/** Registry of connected agents discovered via presence */
|
|
219
|
+
this._agents = new Map();
|
|
220
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
221
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
222
|
+
this._topicHandlers = [];
|
|
223
|
+
/** Filter values applied per topic. `results` is never included. */
|
|
224
|
+
this._filters = {
|
|
225
|
+
tasks: [], tools: [], state: [], events: [], inbox: [], approval: [],
|
|
226
|
+
};
|
|
227
|
+
this.name = name;
|
|
228
|
+
this.agentId = agentId;
|
|
229
|
+
this._roomContext = roomContext;
|
|
230
|
+
this._log = log;
|
|
231
|
+
this._appName = appName;
|
|
232
|
+
this._isConnected = isConnected;
|
|
233
|
+
this._presence = presence;
|
|
234
|
+
if (filters && filters.length > 0) {
|
|
235
|
+
for (const topic of ALL_FILTER_TOPICS)
|
|
236
|
+
this._filters[topic] = [...filters];
|
|
237
|
+
}
|
|
238
|
+
this._wireTopicListeners();
|
|
239
|
+
// Set presence if provided (with the SDK's protocol version advertised
|
|
240
|
+
// so counterparts can detect incompatible reply semantics, and a __scope
|
|
241
|
+
// tag so co-attached wrappers on other apps filter our presence out).
|
|
242
|
+
if (presence) {
|
|
243
|
+
this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
|
|
244
|
+
this._log(`setting presence in room ${name}:`, this._presence);
|
|
245
|
+
this._roomContext.setPresence({ ...this._presence, __scope: appName });
|
|
246
|
+
}
|
|
247
|
+
// Fetch initial presence snapshot
|
|
248
|
+
void this._fetchInitialPresence();
|
|
249
|
+
}
|
|
250
|
+
// ============================================================
|
|
251
|
+
// SERVICE DISCOVERY
|
|
252
|
+
// ============================================================
|
|
253
|
+
/** Get all currently connected agents */
|
|
254
|
+
getConnectedAgents() {
|
|
255
|
+
return Array.from(this._agents.values());
|
|
256
|
+
}
|
|
257
|
+
/** Find agents that have a specific capability */
|
|
258
|
+
findAgents(capability) {
|
|
259
|
+
return Array.from(this._agents.values()).filter((a) => a.capabilities.includes(capability));
|
|
260
|
+
}
|
|
261
|
+
/** Check if any connected agent can handle a capability */
|
|
262
|
+
hasCapability(capability) {
|
|
263
|
+
return this.findAgents(capability).length > 0;
|
|
264
|
+
}
|
|
265
|
+
/** Get all capabilities available across connected agents */
|
|
266
|
+
getAvailableCapabilities() {
|
|
267
|
+
const caps = new Set();
|
|
268
|
+
for (const agent of this._agents.values()) {
|
|
269
|
+
for (const cap of agent.capabilities) {
|
|
270
|
+
caps.add(cap);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return Array.from(caps);
|
|
274
|
+
}
|
|
275
|
+
// ============================================================
|
|
276
|
+
// PRESENCE
|
|
277
|
+
// ============================================================
|
|
278
|
+
/** Update this agent's presence data (protocol version auto-injected) */
|
|
279
|
+
setPresence(data) {
|
|
280
|
+
this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
|
|
281
|
+
this._log(`updating presence in room ${this.name}`);
|
|
282
|
+
this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
|
|
283
|
+
}
|
|
284
|
+
/** Fetch current presence snapshot for this room */
|
|
285
|
+
async fetchPresence() {
|
|
286
|
+
try {
|
|
287
|
+
const actors = await this._roomContext.fetchPresence();
|
|
288
|
+
return (actors || []).map((a) => this._toConnectedAgent(a));
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Get the underlying RoomContext for advanced usage */
|
|
295
|
+
get context() {
|
|
296
|
+
return this._roomContext;
|
|
297
|
+
}
|
|
298
|
+
// ============================================================
|
|
299
|
+
// PUBLISH (with automatic agentId injection)
|
|
300
|
+
// ============================================================
|
|
301
|
+
/** Publish to the tasks topic */
|
|
302
|
+
publishTask(envelope, opts) {
|
|
303
|
+
// Auto-set createdBy if not set
|
|
304
|
+
if (!envelope.createdBy) {
|
|
305
|
+
envelope.createdBy = this.agentId;
|
|
306
|
+
}
|
|
307
|
+
// Routing by capability (`{ filter: envelope.capability }`) is opt-in:
|
|
308
|
+
// it only reaches workers that filter on it, and mixing filtered and
|
|
309
|
+
// wildcard workers in one load-balance pool double-delivers.
|
|
310
|
+
this._publish(TOPIC_TASKS, envelope, filterEmitOptions(opts));
|
|
311
|
+
}
|
|
312
|
+
/** Publish to the results topic — directed to the dispatcher via filter when replyTo is set */
|
|
313
|
+
publishResult(envelope) {
|
|
314
|
+
// Auto-set completedBy if not set
|
|
315
|
+
if (!envelope.completedBy) {
|
|
316
|
+
envelope.completedBy = this.agentId;
|
|
317
|
+
}
|
|
318
|
+
if (envelope.replyTo) {
|
|
319
|
+
this._publish(TOPIC_RESULTS, envelope, { filter: envelope.replyTo });
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Legacy: no reply address — unfiltered publish (only reaches
|
|
323
|
+
// wildcard subscribers, i.e. pre-0.2.0 SDKs)
|
|
324
|
+
this._publish(TOPIC_RESULTS, envelope);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** Publish to the state topic (retained) */
|
|
328
|
+
publishState(data, opts) {
|
|
329
|
+
// Auto-set updatedBy if not set
|
|
330
|
+
if (!data.updatedBy) {
|
|
331
|
+
data.updatedBy = this.agentId;
|
|
332
|
+
}
|
|
333
|
+
this._publish(TOPIC_STATE, data, { retain: true, ...filterEmitOptions(opts) });
|
|
334
|
+
}
|
|
335
|
+
/** Publish to the events topic */
|
|
336
|
+
publishEvent(data, opts) {
|
|
337
|
+
// Auto-set emittedBy if not set
|
|
338
|
+
if (!data.emittedBy) {
|
|
339
|
+
data.emittedBy = this.agentId;
|
|
340
|
+
}
|
|
341
|
+
this._publish(TOPIC_EVENTS, data, filterEmitOptions(opts));
|
|
342
|
+
}
|
|
343
|
+
/** Publish to the inbox topic */
|
|
344
|
+
publishInbox(data, opts) {
|
|
345
|
+
this._publish(TOPIC_INBOX, data, filterEmitOptions(opts));
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Publish a tool message.
|
|
349
|
+
* Requests go to the tools topic (load-balanced one-of-N across server
|
|
350
|
+
* replicas). Responses are directed to the requester on the results topic
|
|
351
|
+
* via filter — never load-balanced, never broadcast.
|
|
352
|
+
*/
|
|
353
|
+
publishTools(data, opts) {
|
|
354
|
+
if (data?.type === "tool_response" && typeof data.replyTo === "string" && data.replyTo) {
|
|
355
|
+
// Responses stay keyed to the requester; a caller filter must not
|
|
356
|
+
// redirect them away from the agent waiting on the correlation.
|
|
357
|
+
this._publish(TOPIC_RESULTS, data, { filter: data.replyTo });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
this._publish(TOPIC_TOOLS, data, filterEmitOptions(opts));
|
|
361
|
+
}
|
|
362
|
+
/** Publish to the approval topic (retained) */
|
|
363
|
+
publishApproval(data, opts) {
|
|
364
|
+
this._publish(TOPIC_APPROVAL, data, { retain: true, ...filterEmitOptions(opts) });
|
|
365
|
+
}
|
|
366
|
+
// ============================================================
|
|
367
|
+
// FILTERS
|
|
368
|
+
// ============================================================
|
|
369
|
+
/** The filter values currently applied to this room, by topic. */
|
|
370
|
+
get filters() {
|
|
371
|
+
return {
|
|
372
|
+
tasks: [...this._filters.tasks],
|
|
373
|
+
tools: [...this._filters.tools],
|
|
374
|
+
state: [...this._filters.state],
|
|
375
|
+
events: [...this._filters.events],
|
|
376
|
+
inbox: [...this._filters.inbox],
|
|
377
|
+
approval: [...this._filters.approval],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Replace this room's filters — only messages published with one of these
|
|
382
|
+
* values are delivered. Applies to every filterable topic unless scoped with
|
|
383
|
+
* `{ topic }`.
|
|
384
|
+
*
|
|
385
|
+
* The usual case is a worker declaring its capabilities on `tasks`, so the
|
|
386
|
+
* broker routes only work it can do instead of every worker receiving every
|
|
387
|
+
* task and discarding the rest. Pair it with
|
|
388
|
+
* `publishTask(envelope, { filter: capability })` on the dispatcher.
|
|
389
|
+
*
|
|
390
|
+
* `results` is never filtered here: it carries directed replies keyed to
|
|
391
|
+
* this agent's id, and repointing it would strand pending results.
|
|
392
|
+
*
|
|
393
|
+
* With load balancing on, keep a worker pool uniform — the broker treats a
|
|
394
|
+
* wildcard and a filtered subscription as separate share groups, so a mixed
|
|
395
|
+
* pool delivers each task twice.
|
|
396
|
+
*
|
|
397
|
+
* Passing an empty array clears filtering and restores the wildcard
|
|
398
|
+
* subscription, which receives everything.
|
|
399
|
+
*
|
|
400
|
+
* @example
|
|
401
|
+
* ```ts
|
|
402
|
+
* room.setFilters(['ocr', 'translate'], { topic: 'tasks' });
|
|
403
|
+
* room.setFilters([]); // everything
|
|
404
|
+
* ```
|
|
405
|
+
*/
|
|
406
|
+
setFilters(values, opts) {
|
|
407
|
+
for (const topic of this._targetTopics(opts)) {
|
|
408
|
+
this._filters[topic] = [...values];
|
|
409
|
+
// The core types filters as `string[]`, but both its implementation and
|
|
410
|
+
// the wire protocol accept AND groups (nested arrays).
|
|
411
|
+
this._roomContext.setFilters(FILTER_TOPICS[topic], values);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
/** Add filter values to the existing set. Existing AND groups are kept. */
|
|
415
|
+
addFilters(values, opts) {
|
|
416
|
+
for (const topic of this._targetTopics(opts)) {
|
|
417
|
+
this.setFilters(mergeFilters(this._filters[topic], values), { topic });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Remove filter values from the existing set. Removing the last value
|
|
422
|
+
* restores the wildcard subscription.
|
|
423
|
+
*/
|
|
424
|
+
removeFilters(values, opts) {
|
|
425
|
+
for (const topic of this._targetTopics(opts)) {
|
|
426
|
+
this.setFilters(withoutFilters(this._filters[topic], values), { topic });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
_targetTopics(opts) {
|
|
430
|
+
return opts?.topic ? [opts.topic] : ALL_FILTER_TOPICS;
|
|
431
|
+
}
|
|
432
|
+
// ============================================================
|
|
433
|
+
// INTERNAL (called by NoLagAgents)
|
|
434
|
+
// ============================================================
|
|
435
|
+
/** @internal Re-apply local presence after a reconnect (core does not restore it) */
|
|
436
|
+
_updateLocalPresence() {
|
|
437
|
+
if (this._presence) {
|
|
438
|
+
this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/** @internal Route a presence:join event in from the parent */
|
|
442
|
+
_handlePresenceJoin(actorId, data) {
|
|
443
|
+
const d = data || {};
|
|
444
|
+
const agent = {
|
|
445
|
+
actorId,
|
|
446
|
+
name: d.name || actorId,
|
|
447
|
+
role: d.role || "agent",
|
|
448
|
+
capabilities: d.capabilities || [],
|
|
449
|
+
metadata: d.metadata,
|
|
450
|
+
connectedAt: Date.now(),
|
|
451
|
+
protocol: typeof d.protocol === "number" ? d.protocol : 1,
|
|
452
|
+
};
|
|
453
|
+
this._agents.set(actorId, agent);
|
|
454
|
+
this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
|
|
455
|
+
this.emit("presenceJoin", actorId, d);
|
|
456
|
+
}
|
|
457
|
+
/** @internal Route a presence:leave event in from the parent */
|
|
458
|
+
_handlePresenceLeave(actorId) {
|
|
459
|
+
const agent = this._agents.get(actorId);
|
|
460
|
+
this._agents.delete(actorId);
|
|
461
|
+
this._log(`agent left room ${this.name}:`, agent?.name || actorId);
|
|
462
|
+
this.emit("presenceLeave", actorId);
|
|
463
|
+
}
|
|
464
|
+
/** @internal Route a presence:update event in from the parent */
|
|
465
|
+
_handlePresenceUpdate(actorId, data) {
|
|
466
|
+
const d = data || {};
|
|
467
|
+
const existing = this._agents.get(actorId);
|
|
468
|
+
const agent = {
|
|
469
|
+
actorId,
|
|
470
|
+
name: d.name || existing?.name || actorId,
|
|
471
|
+
role: d.role || existing?.role || "agent",
|
|
472
|
+
capabilities: d.capabilities || existing?.capabilities || [],
|
|
473
|
+
metadata: d.metadata || existing?.metadata,
|
|
474
|
+
connectedAt: existing?.connectedAt || Date.now(),
|
|
475
|
+
protocol: typeof d.protocol === "number" ? d.protocol : (existing?.protocol ?? 1),
|
|
476
|
+
};
|
|
477
|
+
this._agents.set(actorId, agent);
|
|
478
|
+
this.emit("presenceUpdate", actorId, d);
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* @internal Unsubscribe topics (when connected) and remove exactly this
|
|
482
|
+
* room's handler refs. Handler-specific removal only: the client may be
|
|
483
|
+
* shared, and a bare off(topic) would strip other consumers' handlers too.
|
|
484
|
+
*/
|
|
485
|
+
_cleanup() {
|
|
486
|
+
this._log(`room cleanup: ${this.name}`);
|
|
487
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
488
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
489
|
+
if (this._isConnected()) {
|
|
490
|
+
const topics = new Set(this._topicHandlers.map((t) => t.topic));
|
|
491
|
+
for (const topic of topics) {
|
|
492
|
+
this._roomContext.unsubscribe(topic);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
for (const { topic, handler } of this._topicHandlers) {
|
|
496
|
+
this._roomContext.off(topic, handler);
|
|
497
|
+
}
|
|
498
|
+
this._topicHandlers = [];
|
|
499
|
+
this._agents.clear();
|
|
500
|
+
this.removeAllListeners();
|
|
501
|
+
}
|
|
502
|
+
// ============================================================
|
|
503
|
+
// INTERNALS
|
|
504
|
+
// ============================================================
|
|
505
|
+
_on(topic, handler) {
|
|
506
|
+
this._topicHandlers.push({ topic, handler });
|
|
507
|
+
this._roomContext.on(topic, handler);
|
|
508
|
+
}
|
|
509
|
+
_publish(topic, data, options) {
|
|
510
|
+
this._log(`publish to ${topic} in room ${this.name}`);
|
|
511
|
+
// An empty options object is dropped rather than forwarded: publishing
|
|
512
|
+
// without filters should look exactly as it did before filters existed.
|
|
513
|
+
if (options && Object.keys(options).length > 0) {
|
|
514
|
+
this._roomContext.emit(topic, data, options);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
this._roomContext.emit(topic, data);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/** @internal Subscribe honouring the topic's filter set. */
|
|
521
|
+
_subscribeFiltered(topic, values) {
|
|
522
|
+
if (values.length > 0) {
|
|
523
|
+
this._roomContext.subscribe(topic, { filters: values });
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
this._roomContext.subscribe(topic);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
_toConnectedAgent(actor) {
|
|
530
|
+
const presence = (actor.presence || actor.data || {});
|
|
531
|
+
return {
|
|
532
|
+
actorId: actor.actorTokenId || actor.actorId || "",
|
|
533
|
+
name: presence.name || actor.actorTokenId || "",
|
|
534
|
+
role: presence.role || "agent",
|
|
535
|
+
capabilities: presence.capabilities || [],
|
|
536
|
+
metadata: presence.metadata,
|
|
537
|
+
connectedAt: actor.joinedAt || Date.now(),
|
|
538
|
+
protocol: typeof presence.protocol === "number" ? presence.protocol : 1,
|
|
539
|
+
status: actor.status,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
async _fetchInitialPresence() {
|
|
543
|
+
try {
|
|
544
|
+
const actors = await this._roomContext.fetchPresence();
|
|
545
|
+
if (Array.isArray(actors)) {
|
|
546
|
+
for (const actor of actors) {
|
|
547
|
+
const connected = this._toConnectedAgent(actor);
|
|
548
|
+
if (connected.actorId) {
|
|
549
|
+
this._agents.set(connected.actorId, connected);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
this._log(`discovered ${this._agents.size} agents in room ${this.name}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
// fetchPresence may not be available yet
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
_wireTopicListeners() {
|
|
560
|
+
// Work distribution topics honour the connection-level loadBalance
|
|
561
|
+
// setting, so a pool shares each message one-of-N (no double handling):
|
|
562
|
+
// - tasks: each task goes to exactly one worker in the group
|
|
563
|
+
// - tools: each tool REQUEST goes to exactly one tool-server replica
|
|
564
|
+
this._subscribeFiltered(TOPIC_TASKS, this._filters.tasks);
|
|
565
|
+
this._subscribeFiltered(TOPIC_TOOLS, this._filters.tools);
|
|
566
|
+
// Replies are DIRECTED, not broadcast: the results topic carries task
|
|
567
|
+
// results and tool responses published with `filter: <recipient agentId>`,
|
|
568
|
+
// and each agent subscribes only to its own filter sub-topic. The broker
|
|
569
|
+
// routes each reply straight to the requester — no fan-out waste, and
|
|
570
|
+
// immune to load-balance groups (a broadcast or LB'd reply could land on
|
|
571
|
+
// a group member that doesn't hold the pending correlation, timing out
|
|
572
|
+
// the requester even though the responder did the work).
|
|
573
|
+
this._roomContext.subscribe(TOPIC_RESULTS, {
|
|
574
|
+
loadBalance: false,
|
|
575
|
+
filters: [this.agentId],
|
|
576
|
+
});
|
|
577
|
+
// Broadcast topics must always fan out, even when the connection enables
|
|
578
|
+
// loadBalance for work distribution: state/events are broadcasts by
|
|
579
|
+
// nature; inbox and approval messages are claimed client-side.
|
|
580
|
+
const broadcastTopics = ["state", "events", "inbox", "approval"];
|
|
581
|
+
for (const key of broadcastTopics) {
|
|
582
|
+
const values = this._filters[key];
|
|
583
|
+
this._roomContext.subscribe(FILTER_TOPICS[key], {
|
|
584
|
+
loadBalance: false,
|
|
585
|
+
...(values.length > 0 ? { filters: values } : {}),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// Simple 1:1 mappings
|
|
589
|
+
const simpleMap = [
|
|
590
|
+
{ topic: TOPIC_TASKS, event: "task" },
|
|
591
|
+
{ topic: TOPIC_STATE, event: "stateChange" },
|
|
592
|
+
{ topic: TOPIC_EVENTS, event: "event" },
|
|
593
|
+
{ topic: TOPIC_INBOX, event: "inbox" },
|
|
594
|
+
];
|
|
595
|
+
for (const { topic, event } of simpleMap) {
|
|
596
|
+
this._on(topic, (data) => {
|
|
597
|
+
this._log(`received ${topic} in room ${this.name}`);
|
|
598
|
+
this.emit(event, data);
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
// Multiplexed: results topic carries task results AND tool responses,
|
|
602
|
+
// both filter-directed to this agent.
|
|
603
|
+
this._on(TOPIC_RESULTS, (data) => {
|
|
604
|
+
this._log(`received ${TOPIC_RESULTS} in room ${this.name}`);
|
|
605
|
+
if (data?.type === "tool_response") {
|
|
606
|
+
this.emit("toolResponse", data);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
this.emit("result", data);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
// Multiplexed: approval topic carries requests + responses
|
|
613
|
+
this._on(TOPIC_APPROVAL, (data) => {
|
|
614
|
+
this._log(`received ${TOPIC_APPROVAL} in room ${this.name}`);
|
|
615
|
+
if (data?.type === "approval_response") {
|
|
616
|
+
this.emit("approvalResponse", data);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
this.emit("approvalRequest", data);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
// Tools topic carries requests; tool_response is still accepted here for
|
|
623
|
+
// backward compatibility with responders on older SDK versions (their
|
|
624
|
+
// responses are only reliable when the requester is not load-balanced).
|
|
625
|
+
this._on(TOPIC_TOOLS, (data) => {
|
|
626
|
+
this._log(`received ${TOPIC_TOOLS} in room ${this.name}`);
|
|
627
|
+
if (data?.type === "tool_response") {
|
|
628
|
+
this.emit("toolResponse", data);
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
this.emit("toolRequest", data);
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
|
|
639
|
+
*
|
|
640
|
+
* Provides typed rooms for multi-agent patterns: Handoff, Blackboard,
|
|
641
|
+
* Inbox, Tools, Approval, and Observe.
|
|
642
|
+
*
|
|
643
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
644
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
645
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
646
|
+
*
|
|
647
|
+
* @example
|
|
648
|
+
* ```typescript
|
|
649
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
650
|
+
* import { NoLagAgents } from '@nolag/agents';
|
|
651
|
+
*
|
|
652
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
653
|
+
* const agents = new NoLagAgents({
|
|
654
|
+
* client,
|
|
655
|
+
* appName: 'my-workflow',
|
|
656
|
+
* agentId: 'worker-1',
|
|
657
|
+
* presence: { name: 'worker-1', role: 'agent', capabilities: ['summarize'] },
|
|
658
|
+
* });
|
|
659
|
+
*
|
|
660
|
+
* await client.connect(); // the app owns the connection
|
|
661
|
+
* await agents.ready(); // wrapper setup done (identity, rooms, lobby)
|
|
662
|
+
*
|
|
663
|
+
* const room = agents.room('default-workflow');
|
|
664
|
+
* room.on('task', (task) => console.log('New task:', task));
|
|
665
|
+
*
|
|
666
|
+
* agents.detach(); // wrapper releases its handlers and topics
|
|
667
|
+
* client.disconnect(); // the app closes the socket
|
|
668
|
+
* ```
|
|
669
|
+
*/
|
|
670
|
+
class NoLagAgents extends EventEmitter {
|
|
671
|
+
constructor(options) {
|
|
672
|
+
super();
|
|
673
|
+
this._rooms = new Map();
|
|
674
|
+
this._lobby = null;
|
|
675
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
676
|
+
this._epoch = 0;
|
|
677
|
+
this._detached = false;
|
|
678
|
+
this._isReady = false;
|
|
679
|
+
this._lobbyRefreshTimer = null;
|
|
680
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
681
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
682
|
+
// closures on the client.
|
|
683
|
+
this._onConnectRef = () => this._onConnect();
|
|
684
|
+
this._onDisconnectRef = (reason) => {
|
|
685
|
+
this._log("disconnected:", reason);
|
|
686
|
+
this.emit("disconnected", reason);
|
|
687
|
+
};
|
|
688
|
+
this._onReconnectRef = () => {
|
|
689
|
+
this._log("reconnecting...");
|
|
690
|
+
this.emit("reconnecting");
|
|
691
|
+
};
|
|
692
|
+
this._onErrorRef = (error) => {
|
|
693
|
+
this._log("error:", error?.message ?? error);
|
|
694
|
+
this.emit("error", error);
|
|
695
|
+
};
|
|
696
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
697
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
698
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
699
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
700
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
701
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
702
|
+
if (!options?.client) {
|
|
703
|
+
throw new TypeError("NoLagAgents requires an injected NoLag client: new NoLagAgents({ client, ... })");
|
|
704
|
+
}
|
|
705
|
+
this._client = options.client;
|
|
706
|
+
this._options = {
|
|
707
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
708
|
+
agentId: options.agentId ?? generateId(),
|
|
709
|
+
name: options.name,
|
|
710
|
+
role: options.role,
|
|
711
|
+
debug: options.debug ?? false,
|
|
712
|
+
rooms: options.rooms ?? [DEFAULT_ROOM],
|
|
713
|
+
lobby: options.lobby,
|
|
714
|
+
presence: options.presence ?? this._presenceFromIdentity(options),
|
|
715
|
+
};
|
|
716
|
+
this._log = createLogger("NoLagAgents", this._options.debug);
|
|
717
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
718
|
+
this._readyResolve = resolve;
|
|
719
|
+
this._readyReject = reject;
|
|
720
|
+
});
|
|
721
|
+
// ready() rejection is only meaningful to callers that await it
|
|
722
|
+
this._readyPromise.catch(() => { });
|
|
723
|
+
registerWrapper(this._client, this._options.appName, "NoLagAgents");
|
|
724
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
725
|
+
this._client.on("connect", this._onConnectRef);
|
|
726
|
+
this._client.on("disconnect", this._onDisconnectRef);
|
|
727
|
+
this._client.on("reconnect", this._onReconnectRef);
|
|
728
|
+
this._client.on("error", this._onErrorRef);
|
|
729
|
+
this._client.on("presence:join", this._onPresenceJoinRef);
|
|
730
|
+
this._client.on("presence:leave", this._onPresenceLeaveRef);
|
|
731
|
+
this._client.on("presence:update", this._onPresenceUpdateRef);
|
|
732
|
+
this._client.on("lobbyPresence:join", this._onLobbyJoinRef);
|
|
733
|
+
this._client.on("lobbyPresence:leave", this._onLobbyLeaveRef);
|
|
734
|
+
this._client.on("lobbyPresence:update", this._onLobbyUpdateRef);
|
|
735
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
736
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
737
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
738
|
+
queueMicrotask(() => {
|
|
739
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
740
|
+
this._onConnect();
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
// ============ Public Properties ============
|
|
745
|
+
/** The agent's unique ID */
|
|
746
|
+
get agentId() {
|
|
747
|
+
return this._options.agentId;
|
|
748
|
+
}
|
|
749
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
750
|
+
get connected() {
|
|
751
|
+
return !this._detached && this._client.connected;
|
|
752
|
+
}
|
|
753
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
754
|
+
get client() {
|
|
755
|
+
return this._client;
|
|
756
|
+
}
|
|
757
|
+
/** Map of joined rooms */
|
|
758
|
+
get rooms() {
|
|
759
|
+
return this._rooms;
|
|
760
|
+
}
|
|
761
|
+
// ============ Lifecycle ============
|
|
762
|
+
/**
|
|
763
|
+
* Resolves once the wrapper's first setup completed (identity, configured
|
|
764
|
+
* rooms and — when configured — the lobby ready; equivalently, once
|
|
765
|
+
* 'connected' has fired). Rejects only if detach() is called before that.
|
|
766
|
+
* Client auth failures surface via the app's own `await client.connect()`.
|
|
767
|
+
*/
|
|
768
|
+
ready() {
|
|
769
|
+
return this._readyPromise;
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
773
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
774
|
+
* Terminal and idempotent; never touches the socket. To use agents again,
|
|
775
|
+
* construct a new instance.
|
|
776
|
+
*/
|
|
777
|
+
detach() {
|
|
778
|
+
if (this._detached)
|
|
779
|
+
return;
|
|
780
|
+
this._log("detaching...");
|
|
781
|
+
this._detached = true;
|
|
782
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
783
|
+
if (this._lobbyRefreshTimer) {
|
|
784
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
785
|
+
this._lobbyRefreshTimer = null;
|
|
786
|
+
}
|
|
787
|
+
// Remove all client handlers by stored ref
|
|
788
|
+
this._client.off("connect", this._onConnectRef);
|
|
789
|
+
this._client.off("disconnect", this._onDisconnectRef);
|
|
790
|
+
this._client.off("reconnect", this._onReconnectRef);
|
|
791
|
+
this._client.off("error", this._onErrorRef);
|
|
792
|
+
this._client.off("presence:join", this._onPresenceJoinRef);
|
|
793
|
+
this._client.off("presence:leave", this._onPresenceLeaveRef);
|
|
794
|
+
this._client.off("presence:update", this._onPresenceUpdateRef);
|
|
795
|
+
this._client.off("lobbyPresence:join", this._onLobbyJoinRef);
|
|
796
|
+
this._client.off("lobbyPresence:leave", this._onLobbyLeaveRef);
|
|
797
|
+
this._client.off("lobbyPresence:update", this._onLobbyUpdateRef);
|
|
798
|
+
// Rooms: handler-specific off + connected-gated server unsubscribe
|
|
799
|
+
for (const name of [...this._rooms.keys()]) {
|
|
800
|
+
this._rooms.get(name)._cleanup();
|
|
801
|
+
this._rooms.delete(name);
|
|
802
|
+
}
|
|
803
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
804
|
+
if (this._lobby && this._client.connected) {
|
|
805
|
+
try {
|
|
806
|
+
this._lobby.unsubscribe();
|
|
807
|
+
}
|
|
808
|
+
catch {
|
|
809
|
+
/* best-effort */
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
this._lobby = null;
|
|
813
|
+
releaseWrapper(this._client, this._options.appName);
|
|
814
|
+
if (!this._isReady) {
|
|
815
|
+
this._readyReject(new Error("NoLagAgents detached before ready"));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
// ============ Private: Epoch Setup ============
|
|
819
|
+
_onConnect() {
|
|
820
|
+
this._epoch++;
|
|
821
|
+
void this._runSetup(this._epoch);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
825
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
826
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
827
|
+
*/
|
|
828
|
+
async _runSetup(epoch) {
|
|
829
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
830
|
+
this._log(this._isReady ? "restoring after reconnect..." : "setting up...");
|
|
831
|
+
this._log("agentId:", this._options.agentId, "→ actorId:", this._client.actorId);
|
|
832
|
+
if (!this._isReady) {
|
|
833
|
+
// First successful setup: auto-join configured rooms.
|
|
834
|
+
for (const roomName of this._options.rooms) {
|
|
835
|
+
this._joinRoomInternal(roomName);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
// Reconnect: the core auto-restores topic subscriptions, but not
|
|
840
|
+
// room-scoped presence — re-apply each room's local presence.
|
|
841
|
+
for (const room of this._rooms.values()) {
|
|
842
|
+
room._updateLocalPresence();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
// Lobby is OPTIONAL: only when configured. Subscribe every epoch
|
|
846
|
+
// (idempotent server-side) and diff-hydrate from the returned snapshot —
|
|
847
|
+
// one path for setup and reconnect restore.
|
|
848
|
+
if (this._options.lobby) {
|
|
849
|
+
if (!this._lobby) {
|
|
850
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(this._options.lobby);
|
|
851
|
+
}
|
|
852
|
+
try {
|
|
853
|
+
const state = await this._lobby.subscribe();
|
|
854
|
+
if (stale())
|
|
855
|
+
return;
|
|
856
|
+
this._diffHydrateLobby(state);
|
|
857
|
+
this._log("lobby subscribed:", this._options.lobby);
|
|
858
|
+
}
|
|
859
|
+
catch (err) {
|
|
860
|
+
if (stale())
|
|
861
|
+
return;
|
|
862
|
+
this._log("lobby subscription failed:", err);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (stale())
|
|
866
|
+
return;
|
|
867
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
868
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
869
|
+
if (!this._isReady) {
|
|
870
|
+
this._isReady = true;
|
|
871
|
+
this._readyResolve();
|
|
872
|
+
this.emit("connected");
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
this.emit("reconnected");
|
|
876
|
+
}
|
|
877
|
+
// Deferred lobby refetch: catches agents who joined during the setup
|
|
878
|
+
// window (only when the lobby is configured).
|
|
879
|
+
if (this._options.lobby) {
|
|
880
|
+
this._scheduleLobbyRefresh(epoch);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
_scheduleLobbyRefresh(epoch) {
|
|
884
|
+
if (this._lobbyRefreshTimer)
|
|
885
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
886
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
887
|
+
this._lobbyRefreshTimer = null;
|
|
888
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
this._lobby
|
|
892
|
+
.fetchPresence()
|
|
893
|
+
.then((state) => {
|
|
894
|
+
if (epoch !== this._epoch || this._detached)
|
|
895
|
+
return;
|
|
896
|
+
this._diffHydrateLobby(state);
|
|
897
|
+
})
|
|
898
|
+
.catch(() => {
|
|
899
|
+
/* best-effort */
|
|
900
|
+
});
|
|
901
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
902
|
+
}
|
|
903
|
+
// ============ Room Management ============
|
|
904
|
+
/**
|
|
905
|
+
* Get or create an AgentRoom wrapper.
|
|
906
|
+
* If the room hasn't been joined yet, it will be joined automatically.
|
|
907
|
+
*/
|
|
908
|
+
room(name, opts) {
|
|
909
|
+
this._assertUsable();
|
|
910
|
+
const existing = this._rooms.get(name);
|
|
911
|
+
if (existing) {
|
|
912
|
+
// Already joined — re-point its filters rather than ignoring them.
|
|
913
|
+
if (opts?.filters)
|
|
914
|
+
existing.setFilters(opts.filters);
|
|
915
|
+
return existing;
|
|
916
|
+
}
|
|
917
|
+
return this._joinRoomInternal(name, opts?.filters);
|
|
918
|
+
}
|
|
919
|
+
// ============ Lobby (cross-room presence observation) ============
|
|
920
|
+
/**
|
|
921
|
+
* Subscribe to a lobby for cross-room presence observation. Lobby presence
|
|
922
|
+
* events are forwarded into all AgentRooms. Prefer the `lobby` constructor
|
|
923
|
+
* option — this method is for on-demand subscription after ready.
|
|
924
|
+
*
|
|
925
|
+
* Returns the initial presence snapshot.
|
|
926
|
+
*/
|
|
927
|
+
async subscribeLobby(lobbySlug) {
|
|
928
|
+
this._assertUsable();
|
|
929
|
+
this._log(`subscribing to lobby: ${lobbySlug}`);
|
|
930
|
+
this._options.lobby = lobbySlug;
|
|
931
|
+
if (!this._lobby) {
|
|
932
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(lobbySlug);
|
|
933
|
+
}
|
|
934
|
+
try {
|
|
935
|
+
const state = await this._lobby.subscribe();
|
|
936
|
+
if (!this._detached)
|
|
937
|
+
this._diffHydrateLobby(state);
|
|
938
|
+
return state || {};
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
this._log("lobby subscription failed:", err);
|
|
942
|
+
return {};
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// ============ Private: Guards ============
|
|
946
|
+
_assertUsable() {
|
|
947
|
+
if (this._detached) {
|
|
948
|
+
throw new Error("NoLagAgents has been detached — construct a new instance");
|
|
949
|
+
}
|
|
950
|
+
if (!this._isReady) {
|
|
951
|
+
throw new Error('NoLagAgents not ready — await ready() or the "connected" event');
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// ============ Private: Room Setup ============
|
|
955
|
+
_joinRoomInternal(name, filters) {
|
|
956
|
+
this._log(`joining room: ${name}`);
|
|
957
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
958
|
+
const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence, filters);
|
|
959
|
+
this._rooms.set(name, room);
|
|
960
|
+
return room;
|
|
961
|
+
}
|
|
962
|
+
// ============ Private: Scope Filtering ============
|
|
963
|
+
/**
|
|
964
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
965
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
966
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
967
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
968
|
+
*/
|
|
969
|
+
_foreignScope(data) {
|
|
970
|
+
const scope = data?.__scope;
|
|
971
|
+
return typeof scope === "string" && scope !== this._options.appName;
|
|
972
|
+
}
|
|
973
|
+
// ============ Private: Room Presence → Rooms ============
|
|
974
|
+
_handleRoomPresenceJoin(data) {
|
|
975
|
+
if (data.actorTokenId === this._client.actorId)
|
|
976
|
+
return;
|
|
977
|
+
const presence = (data.presence || {});
|
|
978
|
+
if (this._foreignScope(presence))
|
|
979
|
+
return;
|
|
980
|
+
const roomId = data.roomId;
|
|
981
|
+
for (const room of this._targetRooms(roomId)) {
|
|
982
|
+
room._handlePresenceJoin(data.actorTokenId, presence);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
_handleRoomPresenceLeave(data) {
|
|
986
|
+
if (data.actorTokenId === this._client.actorId)
|
|
987
|
+
return;
|
|
988
|
+
const roomId = data.roomId;
|
|
989
|
+
for (const room of this._targetRooms(roomId)) {
|
|
990
|
+
room._handlePresenceLeave(data.actorTokenId);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
_handleRoomPresenceUpdate(data) {
|
|
994
|
+
if (data.actorTokenId === this._client.actorId)
|
|
995
|
+
return;
|
|
996
|
+
const presence = (data.presence || {});
|
|
997
|
+
if (this._foreignScope(presence))
|
|
998
|
+
return;
|
|
999
|
+
const roomId = data.roomId;
|
|
1000
|
+
for (const room of this._targetRooms(roomId)) {
|
|
1001
|
+
room._handlePresenceUpdate(data.actorTokenId, presence);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/** Rooms a presence event targets: the named room, or all when unscoped. */
|
|
1005
|
+
_targetRooms(roomId) {
|
|
1006
|
+
if (roomId && this._rooms.has(roomId))
|
|
1007
|
+
return [this._rooms.get(roomId)];
|
|
1008
|
+
if (roomId)
|
|
1009
|
+
return [];
|
|
1010
|
+
return [...this._rooms.values()];
|
|
1011
|
+
}
|
|
1012
|
+
// ============ Private: Lobby → Rooms ============
|
|
1013
|
+
_handleLobbyJoin(event) {
|
|
1014
|
+
const { actorId, data } = event;
|
|
1015
|
+
if (actorId === this._client.actorId)
|
|
1016
|
+
return;
|
|
1017
|
+
const presence = (data || {});
|
|
1018
|
+
if (this._foreignScope(presence))
|
|
1019
|
+
return;
|
|
1020
|
+
this._log(`lobby presence:join — ${presence.name || actorId}`);
|
|
1021
|
+
for (const room of this._rooms.values()) {
|
|
1022
|
+
room._handlePresenceJoin(actorId, presence);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
_handleLobbyLeave(event) {
|
|
1026
|
+
const { actorId, data } = event;
|
|
1027
|
+
if (actorId === this._client.actorId)
|
|
1028
|
+
return;
|
|
1029
|
+
const presence = (data || {});
|
|
1030
|
+
if (this._foreignScope(presence))
|
|
1031
|
+
return;
|
|
1032
|
+
this._log(`lobby presence:leave — ${actorId}`);
|
|
1033
|
+
for (const room of this._rooms.values()) {
|
|
1034
|
+
room._handlePresenceLeave(actorId);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
_handleLobbyUpdate(event) {
|
|
1038
|
+
const { actorId, data } = event;
|
|
1039
|
+
if (actorId === this._client.actorId)
|
|
1040
|
+
return;
|
|
1041
|
+
const presence = (data || {});
|
|
1042
|
+
if (this._foreignScope(presence))
|
|
1043
|
+
return;
|
|
1044
|
+
for (const room of this._rooms.values()) {
|
|
1045
|
+
room._handlePresenceUpdate(actorId, presence);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Reconcile the rooms' agent registries against a fresh lobby snapshot,
|
|
1050
|
+
* routing each present actor in as a join. One path for initial hydration,
|
|
1051
|
+
* reconnect restore, and the deferred refetch.
|
|
1052
|
+
*/
|
|
1053
|
+
_diffHydrateLobby(state) {
|
|
1054
|
+
for (const roomId of Object.keys(state)) {
|
|
1055
|
+
const roomPresence = state[roomId];
|
|
1056
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
1057
|
+
if (actorId === this._client.actorId)
|
|
1058
|
+
continue;
|
|
1059
|
+
const raw = roomPresence[actorId];
|
|
1060
|
+
// Server returns full actor records with presence nested under .presence
|
|
1061
|
+
const presence = (raw?.presence ?? raw);
|
|
1062
|
+
if (this._foreignScope(presence))
|
|
1063
|
+
continue;
|
|
1064
|
+
for (const room of this._rooms.values()) {
|
|
1065
|
+
room._handlePresenceJoin(actorId, presence);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
// ============ Private: Helpers ============
|
|
1071
|
+
/** Derive presence identity from name/role options when no presence given. */
|
|
1072
|
+
_presenceFromIdentity(options) {
|
|
1073
|
+
if (!options.name && !options.role)
|
|
1074
|
+
return undefined;
|
|
1075
|
+
return {
|
|
1076
|
+
name: options.name ?? (options.agentId ?? "agent"),
|
|
1077
|
+
role: options.role ?? "agent",
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* IncompatibleProtocolError — thrown when an operation would deterministically
|
|
1084
|
+
* fail because every relevant counterpart runs an older agents-protocol
|
|
1085
|
+
* (pre-directed-replies). Failing fast beats burning the correlation timeout.
|
|
1086
|
+
*/
|
|
1087
|
+
class IncompatibleProtocolError extends Error {
|
|
1088
|
+
constructor(operation, agents) {
|
|
1089
|
+
const list = agents.map((a) => `${a.name} (protocol ${a.protocol})`).join(", ");
|
|
1090
|
+
super(`${operation} cannot succeed: every relevant agent advertises agents-protocol < 2 ` +
|
|
1091
|
+
`[${list}]. Protocol >= 2 responders direct replies to the requester; older ones ` +
|
|
1092
|
+
`broadcast and their replies never reach this SDK's filtered subscription. ` +
|
|
1093
|
+
`Upgrade the responders to @nolag/agents >= 0.2.0 / nolag-agents >= 0.3.0. ` +
|
|
1094
|
+
`NOTE: 0.2.x/0.3.0 responders DO have directed replies but do not yet advertise ` +
|
|
1095
|
+
`protocol — if your responders run those versions, pass { allowLegacyResponders: true }.`);
|
|
1096
|
+
this.name = "IncompatibleProtocolError";
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* CorrelationManager — maps correlationIds to pending promises with timeout.
|
|
1102
|
+
* Used by Handoff and Tools patterns for request/response correlation.
|
|
1103
|
+
*/
|
|
1104
|
+
class CorrelationManager {
|
|
1105
|
+
constructor() {
|
|
1106
|
+
this._pending = new Map();
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Register a pending correlation. Returns a promise that resolves
|
|
1110
|
+
* when `resolve()` is called with the matching correlationId.
|
|
1111
|
+
*/
|
|
1112
|
+
register(correlationId, timeoutMs, context) {
|
|
1113
|
+
return new Promise((resolve, reject) => {
|
|
1114
|
+
let timer = null;
|
|
1115
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
1116
|
+
timer = setTimeout(() => {
|
|
1117
|
+
this._pending.delete(correlationId);
|
|
1118
|
+
// Context turns an opaque correlation id into an actionable error —
|
|
1119
|
+
// callers supply what they were waiting for and the likely causes.
|
|
1120
|
+
const what = context ?? `Correlation ${correlationId}`;
|
|
1121
|
+
reject(new Error(`${what} timed out after ${timeoutMs}ms`));
|
|
1122
|
+
}, timeoutMs);
|
|
1123
|
+
}
|
|
1124
|
+
this._pending.set(correlationId, { resolve, reject, timer });
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Resolve a pending correlation with a value.
|
|
1129
|
+
* Returns true if the correlationId was found and resolved.
|
|
1130
|
+
*/
|
|
1131
|
+
resolve(correlationId, value) {
|
|
1132
|
+
const entry = this._pending.get(correlationId);
|
|
1133
|
+
if (!entry)
|
|
1134
|
+
return false;
|
|
1135
|
+
if (entry.timer)
|
|
1136
|
+
clearTimeout(entry.timer);
|
|
1137
|
+
this._pending.delete(correlationId);
|
|
1138
|
+
entry.resolve(value);
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Reject a pending correlation with an error.
|
|
1143
|
+
*/
|
|
1144
|
+
reject(correlationId, error) {
|
|
1145
|
+
const entry = this._pending.get(correlationId);
|
|
1146
|
+
if (!entry)
|
|
1147
|
+
return false;
|
|
1148
|
+
if (entry.timer)
|
|
1149
|
+
clearTimeout(entry.timer);
|
|
1150
|
+
this._pending.delete(correlationId);
|
|
1151
|
+
entry.reject(error);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Check if a correlationId is pending.
|
|
1156
|
+
*/
|
|
1157
|
+
has(correlationId) {
|
|
1158
|
+
return this._pending.has(correlationId);
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Cancel all pending correlations.
|
|
1162
|
+
*/
|
|
1163
|
+
clear() {
|
|
1164
|
+
for (const [id, entry] of this._pending) {
|
|
1165
|
+
if (entry.timer)
|
|
1166
|
+
clearTimeout(entry.timer);
|
|
1167
|
+
entry.reject(new Error(`Correlation ${id} cancelled`));
|
|
1168
|
+
}
|
|
1169
|
+
this._pending.clear();
|
|
1170
|
+
}
|
|
1171
|
+
get size() {
|
|
1172
|
+
return this._pending.size;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function createTaskEnvelope(capability, payload, options) {
|
|
1177
|
+
return {
|
|
1178
|
+
type: "task",
|
|
1179
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1180
|
+
taskId: generateId(),
|
|
1181
|
+
correlationId: generateId(),
|
|
1182
|
+
replyTo: options?.replyTo,
|
|
1183
|
+
capability,
|
|
1184
|
+
payload,
|
|
1185
|
+
tags: options?.tags,
|
|
1186
|
+
priority: options?.priority ?? "medium",
|
|
1187
|
+
metadata: options?.metadata,
|
|
1188
|
+
createdAt: createTimestamp(),
|
|
1189
|
+
createdBy: options?.createdBy,
|
|
1190
|
+
timeout: options?.timeout,
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
function createResultEnvelope(taskId, correlationId, status, payload, error, completedBy, replyTo) {
|
|
1194
|
+
return {
|
|
1195
|
+
type: "result",
|
|
1196
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1197
|
+
correlationId,
|
|
1198
|
+
taskId,
|
|
1199
|
+
status,
|
|
1200
|
+
payload,
|
|
1201
|
+
error,
|
|
1202
|
+
completedAt: createTimestamp(),
|
|
1203
|
+
completedBy,
|
|
1204
|
+
replyTo,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
function createStateEnvelope(key, value, version, updatedBy) {
|
|
1208
|
+
return {
|
|
1209
|
+
type: "state",
|
|
1210
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1211
|
+
key,
|
|
1212
|
+
value,
|
|
1213
|
+
version,
|
|
1214
|
+
updatedBy,
|
|
1215
|
+
updatedAt: createTimestamp(),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
function createEventEnvelope(category, emittedBy, payload, severity = "info") {
|
|
1219
|
+
return {
|
|
1220
|
+
type: "event",
|
|
1221
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1222
|
+
eventId: generateId(),
|
|
1223
|
+
severity,
|
|
1224
|
+
category,
|
|
1225
|
+
emittedBy,
|
|
1226
|
+
payload,
|
|
1227
|
+
timestamp: createTimestamp(),
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
function createApprovalRequest(action, context, requestedBy, options) {
|
|
1231
|
+
return {
|
|
1232
|
+
type: "approval_request",
|
|
1233
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1234
|
+
requestId: generateId(),
|
|
1235
|
+
correlationId: generateId(),
|
|
1236
|
+
action,
|
|
1237
|
+
context,
|
|
1238
|
+
urgency: options?.urgency ?? "medium",
|
|
1239
|
+
requestedBy,
|
|
1240
|
+
requestedAt: createTimestamp(),
|
|
1241
|
+
expiresAt: options?.expiresAt,
|
|
1242
|
+
};
|
|
1243
|
+
}
|
|
1244
|
+
function createApprovalResponse(requestId, correlationId, decision, respondedBy, reason) {
|
|
1245
|
+
return {
|
|
1246
|
+
type: "approval_response",
|
|
1247
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1248
|
+
requestId,
|
|
1249
|
+
correlationId,
|
|
1250
|
+
decision,
|
|
1251
|
+
respondedBy,
|
|
1252
|
+
reason,
|
|
1253
|
+
respondedAt: createTimestamp(),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
function createToolRequest(toolName, args, requestedBy, options) {
|
|
1257
|
+
return {
|
|
1258
|
+
type: "tool_request",
|
|
1259
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1260
|
+
requestId: generateId(),
|
|
1261
|
+
correlationId: generateId(),
|
|
1262
|
+
replyTo: options?.replyTo,
|
|
1263
|
+
toolName,
|
|
1264
|
+
arguments: args,
|
|
1265
|
+
requestedBy,
|
|
1266
|
+
requestedAt: createTimestamp(),
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
function createToolResponse(requestId, correlationId, status, result, error, respondedBy, replyTo) {
|
|
1270
|
+
return {
|
|
1271
|
+
type: "tool_response",
|
|
1272
|
+
protocol: AGENTS_PROTOCOL_VERSION,
|
|
1273
|
+
requestId,
|
|
1274
|
+
correlationId,
|
|
1275
|
+
status,
|
|
1276
|
+
result,
|
|
1277
|
+
error,
|
|
1278
|
+
respondedBy,
|
|
1279
|
+
respondedAt: createTimestamp(),
|
|
1280
|
+
replyTo,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Handoff pattern — dispatch tasks to agents and receive results.
|
|
1286
|
+
*
|
|
1287
|
+
* Orchestrators use `dispatch()` to send work. The SDK checks if any
|
|
1288
|
+
* connected agent has the requested capability (via presence-based
|
|
1289
|
+
* service discovery) before dispatching.
|
|
1290
|
+
*
|
|
1291
|
+
* Workers use `onTask()` with a capabilities filter — they only receive
|
|
1292
|
+
* tasks matching their registered capabilities.
|
|
1293
|
+
*
|
|
1294
|
+
* @example
|
|
1295
|
+
* ```typescript
|
|
1296
|
+
* // Orchestrator
|
|
1297
|
+
* const handoff = new Handoff(room);
|
|
1298
|
+
* const result = await handoff.dispatch('summarize', { text }, { waitForResult: true });
|
|
1299
|
+
*
|
|
1300
|
+
* // Worker
|
|
1301
|
+
* const handoff = new Handoff(room);
|
|
1302
|
+
* handoff.onTask(['summarize', 'translate'], async (task, respond) => {
|
|
1303
|
+
* const output = await processTask(task);
|
|
1304
|
+
* respond('success', { output });
|
|
1305
|
+
* });
|
|
1306
|
+
* ```
|
|
1307
|
+
*/
|
|
1308
|
+
class Handoff {
|
|
1309
|
+
constructor(room) {
|
|
1310
|
+
this._correlations = new CorrelationManager();
|
|
1311
|
+
this._warnedMixed = false;
|
|
1312
|
+
this._room = room;
|
|
1313
|
+
// Wire result correlation
|
|
1314
|
+
this._room.on("result", (envelope) => {
|
|
1315
|
+
this._correlations.resolve(envelope.correlationId, envelope);
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Dispatch a task to agents with the given capability.
|
|
1320
|
+
*
|
|
1321
|
+
* Uses presence-based service discovery to verify at least one agent
|
|
1322
|
+
* can handle the capability before dispatching. Throws if no capable
|
|
1323
|
+
* agent is connected (unless `allowNoWorkers` is set).
|
|
1324
|
+
*/
|
|
1325
|
+
async dispatch(capability, payload, options) {
|
|
1326
|
+
// Service discovery: check if any agent can handle this capability.
|
|
1327
|
+
// Persistent Presence: findAgents includes offline persistent agents, which
|
|
1328
|
+
// the broker wakes on publish — so they satisfy the gate unless requireOnline.
|
|
1329
|
+
if (!options?.allowNoWorkers) {
|
|
1330
|
+
const capable = this._room.findAgents(capability);
|
|
1331
|
+
const usable = options?.requireOnline
|
|
1332
|
+
? capable.filter((a) => a.status === undefined || a.status === "online")
|
|
1333
|
+
: capable;
|
|
1334
|
+
if (usable.length === 0) {
|
|
1335
|
+
throw new Error(`No ${options?.requireOnline ? "online " : ""}agent with capability "${capability}" is available. ` +
|
|
1336
|
+
`Available capabilities: [${this._room.getAvailableCapabilities().join(', ')}]. ` +
|
|
1337
|
+
`Connected agents: ${this._room.getConnectedAgents().length}. ` +
|
|
1338
|
+
`Use { allowNoWorkers: true } to dispatch anyway.`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const envelope = createTaskEnvelope(capability, payload, {
|
|
1342
|
+
...options,
|
|
1343
|
+
createdBy: options?.createdBy ?? this._room.agentId,
|
|
1344
|
+
// Reply address: workers publish the result filter-directed to this
|
|
1345
|
+
// room's results subscription
|
|
1346
|
+
replyTo: options?.replyTo ?? this._room.agentId,
|
|
1347
|
+
});
|
|
1348
|
+
this._room.publishTask(envelope);
|
|
1349
|
+
if (options?.waitForResult) {
|
|
1350
|
+
// Fail fast when the outcome is deterministic: if capable workers are
|
|
1351
|
+
// visible and ALL advertise agents-protocol < 2, their results cannot
|
|
1352
|
+
// reach this dispatcher's filtered subscription. Mixed pools proceed
|
|
1353
|
+
// with a warning (presence is eventually consistent).
|
|
1354
|
+
const capable = this._room.findAgents(capability);
|
|
1355
|
+
if (!options?.allowLegacyResponders && capable.length > 0) {
|
|
1356
|
+
const modern = capable.filter((a) => a.protocol >= 2);
|
|
1357
|
+
if (modern.length === 0) {
|
|
1358
|
+
throw new IncompatibleProtocolError(`Task '${capability}' dispatch with waitForResult`, capable.map((a) => ({ name: a.name, protocol: a.protocol })));
|
|
1359
|
+
}
|
|
1360
|
+
if (modern.length < capable.length && !this._warnedMixed) {
|
|
1361
|
+
this._warnedMixed = true;
|
|
1362
|
+
console.warn(`[nolag-agents] Capability '${capability}' has workers on agents-protocol < 2: ` +
|
|
1363
|
+
capable.filter((a) => a.protocol < 2).map((a) => a.name).join(", ") +
|
|
1364
|
+
". Their results may not be delivered — upgrade them.");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
return this._correlations.register(envelope.correlationId, options.timeout, `Task '${capability}' dispatch (${capable.length} capable worker${capable.length === 1 ? "" : "s"} visible). ` +
|
|
1368
|
+
`Likely causes: worker crashed mid-task, worker on agents-protocol < 2 ` +
|
|
1369
|
+
`(results not directed), or the room is not deliverable`);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
onTask(capabilitiesOrHandler, maybeHandler) {
|
|
1373
|
+
// Single-arg form: onTask(handler) receives all tasks
|
|
1374
|
+
const capabilities = typeof capabilitiesOrHandler === "function" ? '*' : capabilitiesOrHandler;
|
|
1375
|
+
const handler = typeof capabilitiesOrHandler === "function" ? capabilitiesOrHandler : maybeHandler;
|
|
1376
|
+
this._room.on("task", (task) => {
|
|
1377
|
+
// Filter by capability unless wildcard
|
|
1378
|
+
if (capabilities !== '*' && !capabilities.includes(task.capability)) {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
const respond = (status, payload, error) => {
|
|
1382
|
+
const result = createResultEnvelope(task.taskId, task.correlationId, status, payload, error, this._room.agentId,
|
|
1383
|
+
// Direct the result to the dispatcher's filter sub-topic
|
|
1384
|
+
task.replyTo ?? task.createdBy);
|
|
1385
|
+
this._room.publishResult(result);
|
|
1386
|
+
};
|
|
1387
|
+
handler(task, respond);
|
|
1388
|
+
});
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* Get agents capable of handling a specific task type.
|
|
1392
|
+
* Delegates to the room's presence-based service discovery.
|
|
1393
|
+
*/
|
|
1394
|
+
getCapableAgents(capability) {
|
|
1395
|
+
return this._room.findAgents(capability);
|
|
1396
|
+
}
|
|
1397
|
+
/** Cancel all pending correlations */
|
|
1398
|
+
dispose() {
|
|
1399
|
+
this._correlations.clear();
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/**
|
|
1404
|
+
* Inbox pattern — per-agent durable message queues.
|
|
1405
|
+
*
|
|
1406
|
+
* Agents send direct messages to other agents via their inbox.
|
|
1407
|
+
* Messages are persisted and replayed on reconnect (requires persistent sessions).
|
|
1408
|
+
*/
|
|
1409
|
+
class Inbox {
|
|
1410
|
+
constructor(room, agentId) {
|
|
1411
|
+
this._room = room;
|
|
1412
|
+
this._agentId = agentId;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Send a message to another agent's inbox.
|
|
1416
|
+
*/
|
|
1417
|
+
send(to, payload) {
|
|
1418
|
+
const message = {
|
|
1419
|
+
messageId: generateId(),
|
|
1420
|
+
from: this._agentId,
|
|
1421
|
+
to,
|
|
1422
|
+
payload,
|
|
1423
|
+
createdAt: createTimestamp(),
|
|
1424
|
+
};
|
|
1425
|
+
this._room.publishInbox(message);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Register a handler for incoming inbox messages.
|
|
1429
|
+
*/
|
|
1430
|
+
onMessage(handler) {
|
|
1431
|
+
this._room.on("inbox", (envelope) => {
|
|
1432
|
+
const msg = envelope;
|
|
1433
|
+
if (msg.to === this._agentId) {
|
|
1434
|
+
handler(msg);
|
|
1435
|
+
}
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Blackboard pattern — shared state across agents.
|
|
1442
|
+
*
|
|
1443
|
+
* Agents read and write key-value pairs visible to all room participants.
|
|
1444
|
+
* Uses retained messages so state is available on join.
|
|
1445
|
+
*/
|
|
1446
|
+
class Blackboard {
|
|
1447
|
+
constructor(room, agentId) {
|
|
1448
|
+
this._state = new Map();
|
|
1449
|
+
this._room = room;
|
|
1450
|
+
this._agentId = agentId;
|
|
1451
|
+
this._room.on("stateChange", (envelope) => {
|
|
1452
|
+
this._state.set(envelope.key, envelope);
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Set a shared state value.
|
|
1457
|
+
*/
|
|
1458
|
+
set(key, value) {
|
|
1459
|
+
const existing = this._state.get(key);
|
|
1460
|
+
const version = existing ? existing.version + 1 : 1;
|
|
1461
|
+
const envelope = createStateEnvelope(key, value, version, this._agentId);
|
|
1462
|
+
this._state.set(key, envelope);
|
|
1463
|
+
this._room.publishState(envelope);
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Get a shared state value.
|
|
1467
|
+
*/
|
|
1468
|
+
get(key) {
|
|
1469
|
+
return this._state.get(key)?.value;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Get the full state envelope for a key.
|
|
1473
|
+
*/
|
|
1474
|
+
getEnvelope(key) {
|
|
1475
|
+
return this._state.get(key);
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Get all state entries.
|
|
1479
|
+
*/
|
|
1480
|
+
getAll() {
|
|
1481
|
+
return this._state;
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Register a handler for state changes on a specific key.
|
|
1485
|
+
*/
|
|
1486
|
+
onChange(key, handler) {
|
|
1487
|
+
this._room.on("stateChange", (envelope) => {
|
|
1488
|
+
if (envelope.key === key) {
|
|
1489
|
+
handler(envelope);
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/**
|
|
1496
|
+
* Observe pattern — emit and listen to observability events.
|
|
1497
|
+
*
|
|
1498
|
+
* Agents emit structured events; observers/dashboards subscribe to the stream.
|
|
1499
|
+
* Events have severity, category, and emittedBy for filtering.
|
|
1500
|
+
*
|
|
1501
|
+
* `on(handler, filter)` discards non-matching events after they arrive, which
|
|
1502
|
+
* is fine for a quiet room and wasteful for a loud one. `setFilters` moves the
|
|
1503
|
+
* same selection to the broker, so an observer is only sent the categories it
|
|
1504
|
+
* asked for. Emit with a matching `filter` for that to work — see `emit`.
|
|
1505
|
+
*/
|
|
1506
|
+
class Observe {
|
|
1507
|
+
constructor(room, emittedBy) {
|
|
1508
|
+
this._room = room;
|
|
1509
|
+
this._emittedBy = emittedBy;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Emit an observability event.
|
|
1513
|
+
*
|
|
1514
|
+
* Pass `{ filter: category }` to route it server-side, so observers that
|
|
1515
|
+
* called `setFilters` receive only the categories they subscribed to.
|
|
1516
|
+
* Observers with no filters still receive it either way, so tagging is safe
|
|
1517
|
+
* to adopt without coordinating with them.
|
|
1518
|
+
*/
|
|
1519
|
+
emit(category, payload, severity = "info", opts) {
|
|
1520
|
+
const envelope = createEventEnvelope(category, this._emittedBy, payload, severity);
|
|
1521
|
+
this._room.publishEvent(envelope, opts);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* Replace the observer's server-side event filters.
|
|
1525
|
+
*
|
|
1526
|
+
* Scoped to the events topic, so it never disturbs the room's other
|
|
1527
|
+
* subscriptions — notably `inbox`, whose messages are published unfiltered
|
|
1528
|
+
* and would stop arriving if this were applied room-wide.
|
|
1529
|
+
*
|
|
1530
|
+
* An empty array restores the wildcard subscription, which receives every
|
|
1531
|
+
* event on the room.
|
|
1532
|
+
*/
|
|
1533
|
+
setFilters(values) {
|
|
1534
|
+
this._room.setFilters(values, { topic: "events" });
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Listen for events, optionally filtered by category or severity.
|
|
1538
|
+
*/
|
|
1539
|
+
on(handler, filter) {
|
|
1540
|
+
this._room.on("event", (envelope) => {
|
|
1541
|
+
if (filter?.category && envelope.category !== filter.category)
|
|
1542
|
+
return;
|
|
1543
|
+
if (filter?.severity && envelope.severity !== filter.severity)
|
|
1544
|
+
return;
|
|
1545
|
+
handler(envelope);
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/**
|
|
1551
|
+
* Approve pattern — human-in-the-loop approval gates.
|
|
1552
|
+
*
|
|
1553
|
+
* Agents request approval before taking actions; humans (or other agents)
|
|
1554
|
+
* approve or reject via the approval topic.
|
|
1555
|
+
*/
|
|
1556
|
+
class Approve {
|
|
1557
|
+
constructor(room, agentId) {
|
|
1558
|
+
this._correlations = new CorrelationManager();
|
|
1559
|
+
this._room = room;
|
|
1560
|
+
this._agentId = agentId;
|
|
1561
|
+
// Wire approval response correlation
|
|
1562
|
+
this._room.on("approvalResponse", (envelope) => {
|
|
1563
|
+
this._correlations.resolve(envelope.correlationId, envelope);
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Request approval for an action. Returns the approval response.
|
|
1568
|
+
*/
|
|
1569
|
+
async request(action, context, options) {
|
|
1570
|
+
const envelope = createApprovalRequest(action, context, this._agentId, {
|
|
1571
|
+
urgency: options?.urgency,
|
|
1572
|
+
expiresAt: options?.expiresAt,
|
|
1573
|
+
});
|
|
1574
|
+
this._room.publishApproval(envelope);
|
|
1575
|
+
return this._correlations.register(envelope.correlationId, options?.timeout);
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Register a handler for incoming approval requests.
|
|
1579
|
+
* The handler receives the request and a respond function.
|
|
1580
|
+
*/
|
|
1581
|
+
onRequest(handler) {
|
|
1582
|
+
this._room.on("approvalRequest", (request) => {
|
|
1583
|
+
const respond = (decision, reason) => {
|
|
1584
|
+
const response = createApprovalResponse(request.requestId, request.correlationId, decision, this._agentId, reason);
|
|
1585
|
+
this._room.publishApproval(response);
|
|
1586
|
+
};
|
|
1587
|
+
handler(request, respond);
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
/** Cancel all pending correlations */
|
|
1591
|
+
dispose() {
|
|
1592
|
+
this._correlations.clear();
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* Tools pattern — typed RPC over pub/sub for tool invocations.
|
|
1598
|
+
*
|
|
1599
|
+
* Agents register tool handlers; callers invoke tools and receive
|
|
1600
|
+
* correlated responses.
|
|
1601
|
+
*/
|
|
1602
|
+
class Tools {
|
|
1603
|
+
constructor(room, agentId) {
|
|
1604
|
+
this._correlations = new CorrelationManager();
|
|
1605
|
+
this._handlers = new Map();
|
|
1606
|
+
this._warnedMixed = false;
|
|
1607
|
+
this._room = room;
|
|
1608
|
+
this._agentId = agentId;
|
|
1609
|
+
// Wire response correlation
|
|
1610
|
+
this._room.on("toolResponse", (envelope) => {
|
|
1611
|
+
if (!envelope || typeof envelope.correlationId !== "string")
|
|
1612
|
+
return;
|
|
1613
|
+
this._correlations.resolve(envelope.correlationId, envelope);
|
|
1614
|
+
});
|
|
1615
|
+
// Wire request handling
|
|
1616
|
+
this._room.on("toolRequest", async (envelope) => {
|
|
1617
|
+
// The tools topic is an open room surface: anything published there
|
|
1618
|
+
// that is not a tool_response is classified as a request, including
|
|
1619
|
+
// foreign or malformed payloads. A request with no toolName cannot be
|
|
1620
|
+
// routed OR NACKed meaningfully — and it must never be able to crash
|
|
1621
|
+
// the host (this listener is async, so an uncaught throw here becomes
|
|
1622
|
+
// an unhandled rejection that terminates the process).
|
|
1623
|
+
if (!envelope || typeof envelope.toolName !== "string")
|
|
1624
|
+
return;
|
|
1625
|
+
const handler = this._handlers.get(envelope.toolName);
|
|
1626
|
+
// Direct the response back to the requester's filter sub-topic
|
|
1627
|
+
const replyTo = envelope.replyTo ?? envelope.requestedBy;
|
|
1628
|
+
if (!handler) {
|
|
1629
|
+
// Tool requests are load-balanced to EVERY group in the room, so
|
|
1630
|
+
// agents legitimately receive requests meant for other tool servers.
|
|
1631
|
+
// Stay silent unless this agent plausibly owns the tool:
|
|
1632
|
+
// - pure requesters (zero handlers) never answer
|
|
1633
|
+
// - servers answer only within their own namespace (the prefix
|
|
1634
|
+
// before the first '.', e.g. 'backend.*', 'chemistry.*') — a
|
|
1635
|
+
// 'backend.*' server NACKing 'chemistry.analyze' would race and
|
|
1636
|
+
// beat the real chemistry server's response
|
|
1637
|
+
if (!this._ownsNamespace(envelope.toolName))
|
|
1638
|
+
return;
|
|
1639
|
+
// A tool SERVER missing a handler in ITS OWN namespace NACKs instead
|
|
1640
|
+
// of silently ignoring — silence means the requester burns its full
|
|
1641
|
+
// timeout. (Requires homogeneous tool sets within a loadBalanceGroup
|
|
1642
|
+
// — see AGENTS-PROTOCOL.md.)
|
|
1643
|
+
const nack = createToolResponse(envelope.requestId, envelope.correlationId, "error", null, {
|
|
1644
|
+
code: "NO_HANDLER",
|
|
1645
|
+
message: `Agent '${this._agentId}' has no handler for tool '${envelope.toolName}'`,
|
|
1646
|
+
}, this._agentId, replyTo);
|
|
1647
|
+
this._room.publishTools(nack);
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
try {
|
|
1651
|
+
const result = await handler(envelope.arguments);
|
|
1652
|
+
const response = createToolResponse(envelope.requestId, envelope.correlationId, "success", result, undefined, this._agentId, replyTo);
|
|
1653
|
+
this._room.publishTools(response);
|
|
1654
|
+
}
|
|
1655
|
+
catch (err) {
|
|
1656
|
+
const response = createToolResponse(envelope.requestId, envelope.correlationId, "error", null, {
|
|
1657
|
+
code: "TOOL_ERROR",
|
|
1658
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1659
|
+
}, this._agentId, replyTo);
|
|
1660
|
+
this._room.publishTools(response);
|
|
1661
|
+
}
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Register a tool handler.
|
|
1666
|
+
*/
|
|
1667
|
+
register(toolName, handler) {
|
|
1668
|
+
this._handlers.set(toolName, handler);
|
|
1669
|
+
}
|
|
1670
|
+
/** True when this agent hosts handlers in the tool's namespace (prefix
|
|
1671
|
+
* before the first '.'); unprefixed tools match any unprefixed handler. */
|
|
1672
|
+
_ownsNamespace(toolName) {
|
|
1673
|
+
// Belt and braces: a nameless tool belongs to nobody.
|
|
1674
|
+
if (typeof toolName !== "string")
|
|
1675
|
+
return false;
|
|
1676
|
+
if (this._handlers.size === 0)
|
|
1677
|
+
return false;
|
|
1678
|
+
const ns = toolName.includes(".") ? toolName.slice(0, toolName.indexOf(".")) : null;
|
|
1679
|
+
for (const name of this._handlers.keys()) {
|
|
1680
|
+
const handlerNs = name.includes(".") ? name.slice(0, name.indexOf(".")) : null;
|
|
1681
|
+
if (handlerNs === ns)
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Invoke a remote tool and wait for the response.
|
|
1688
|
+
*/
|
|
1689
|
+
async invoke(toolName, args, options) {
|
|
1690
|
+
// Fail fast when the outcome is deterministic: tool servers are visible
|
|
1691
|
+
// in presence; if some exist and ALL advertise protocol < 2, their
|
|
1692
|
+
// replies cannot reach this requester. Mixed pools proceed with a
|
|
1693
|
+
// warning (presence is eventually consistent — hard-failing on one
|
|
1694
|
+
// stale entry would flake).
|
|
1695
|
+
const servers = this._room
|
|
1696
|
+
.getConnectedAgents()
|
|
1697
|
+
.filter((a) => a.role === "tool-server");
|
|
1698
|
+
if (!options?.allowLegacyResponders && servers.length > 0) {
|
|
1699
|
+
const modern = servers.filter((a) => a.protocol >= 2);
|
|
1700
|
+
if (modern.length === 0) {
|
|
1701
|
+
throw new IncompatibleProtocolError(`Tool '${toolName}' invocation`, servers.map((a) => ({ name: a.name, protocol: a.protocol })));
|
|
1702
|
+
}
|
|
1703
|
+
if (modern.length < servers.length && !this._warnedMixed) {
|
|
1704
|
+
this._warnedMixed = true;
|
|
1705
|
+
console.warn(`[nolag-agents] Room '${this._room.name}' has tool servers on agents-protocol < 2: ` +
|
|
1706
|
+
servers.filter((a) => a.protocol < 2).map((a) => a.name).join(", ") +
|
|
1707
|
+
". Their replies may not be delivered — upgrade them.");
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
// replyTo is the room's agentId — the filter sub-topic this room's
|
|
1711
|
+
// results subscription listens on. (this._agentId may differ when a
|
|
1712
|
+
// caller attributes requests to a logical agent; delivery must use the
|
|
1713
|
+
// address that is actually subscribed.)
|
|
1714
|
+
const envelope = createToolRequest(toolName, args, this._agentId, {
|
|
1715
|
+
replyTo: this._room.agentId,
|
|
1716
|
+
});
|
|
1717
|
+
this._room.publishTools(envelope);
|
|
1718
|
+
const serverCount = servers.length;
|
|
1719
|
+
return this._correlations.register(envelope.correlationId, options?.timeout, `Tool '${toolName}' invocation in room '${this._room.name}' ` +
|
|
1720
|
+
`(${serverCount} tool-server${serverCount === 1 ? "" : "s"} visible). ` +
|
|
1721
|
+
`Likely causes: no agent has this tool registered (pre-0.3.0 responders ` +
|
|
1722
|
+
`don't NACK), the responder is offline, or the room is not deliverable ` +
|
|
1723
|
+
`(watch the room 'error' events)`);
|
|
1724
|
+
}
|
|
1725
|
+
/** Cancel all pending correlations */
|
|
1726
|
+
dispose() {
|
|
1727
|
+
this._correlations.clear();
|
|
1728
|
+
this._handlers.clear();
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/** Standard tag prefixes for agent coordination */
|
|
1733
|
+
const TAG_PREFIX = {
|
|
1734
|
+
CAPABILITY: "capability",
|
|
1735
|
+
PRIORITY: "priority",
|
|
1736
|
+
ROLE: "role",
|
|
1737
|
+
SEVERITY: "severity",
|
|
1738
|
+
URGENCY: "urgency",
|
|
1739
|
+
TENANT: "tenant",
|
|
1740
|
+
};
|
|
1741
|
+
/** Boolean flags (used as standalone tags, no prefix) */
|
|
1742
|
+
const TAG_FLAGS = {
|
|
1743
|
+
REQUIRES_HUMAN: "requires_human",
|
|
1744
|
+
REQUIRES_AUDIT: "requires_audit",
|
|
1745
|
+
};
|
|
1746
|
+
/** Helper to create prefixed tags */
|
|
1747
|
+
function tag(prefix, value) {
|
|
1748
|
+
return `${prefix}:${value}`;
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
export { AGENTS_PROTOCOL_VERSION, AgentRoom, Approve, Blackboard, CorrelationManager, EventEmitter, Handoff, Inbox, IncompatibleProtocolError, NoLagAgents, Observe, TAG_FLAGS, TAG_PREFIX, Tools, createApprovalRequest, createApprovalResponse, createEventEnvelope, createResultEnvelope, createStateEnvelope, createTaskEnvelope, createToolRequest, createToolResponse, tag };
|
|
1752
|
+
//# sourceMappingURL=react-native.js.map
|