agentschat-mcp 0.33.7 → 0.34.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.
@@ -1,25 +1,14 @@
1
1
  /**
2
- * agentschat connector — the connector side of the Hermes relay contract.
2
+ * AgentsChat relay connector. Repeated hello frames register identities on a
3
+ * connection authenticated with a gatewayId AND a specific signing secret.
4
+ * Every identity must match both credentials; outbound additionally requires
5
+ * that identity's hello on the sending connection. There is no global fallback
6
+ * and no chat-sticky identity inference.
3
7
  *
4
- * Single-tenant AND multiplex: one relay WS server fronts one OR MORE agentschat
5
- * identities (one per Hermes profile/agent). Hermes fronts multiple identities on
6
- * one WS by sending one `hello` per (platform, botId); here platform is always
7
- * "agentschat" and botId is the agentschat agent_id.
8
- *
9
- * Frame exchange (see gateway ws_transport.py):
10
- * gateway → hello {type:"hello", platform, botId}
11
- * connector→ descriptor {type:"descriptor", descriptor:{...}} (one per hello)
12
- * connector→ inbound {type:"inbound", event:{...}} (agentschat → gateway)
13
- * gateway → outbound {type:"outbound", requestId, platform?, action}
14
- * connector→ outbound_result {type:"outbound_result", requestId, result}
15
- *
16
- * Auth is fail-closed: anything wrong with the upgrade token closes 4401 before the
17
- * socket is admitted.
18
- *
19
- * The single highest-correctness invariant (multiplex): identity A's messages are
20
- * NEVER routed to or sent as identity B. Inbound routes to the socket(s) fronting the
21
- * addressed identity; outbound uses the sending identity's own token; an identity the
22
- * connector has no credentials for is rejected at hello (fail closed).
8
+ * Use separate single-identity gateways for Hermes transports that stamp the
9
+ * first botId for a platform on all outbound actions. A shared multi-hello WS
10
+ * requires a client that supplies the correct explicit outbound botId.
11
+ * All connector frames are newline-terminated (relay wire contract v1).
23
12
  */
24
13
 
25
14
  import { createServer, type Server as HttpServer, type IncomingMessage } from "node:http";
@@ -27,7 +16,8 @@ import { WebSocketServer, WebSocket } from "ws";
27
16
  import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
28
17
  import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
29
18
  import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
30
- import { IdentityTable, routeInbound, hermesSourceProfile, type Identity } from "./identities.ts";
19
+ import { MessageDedup } from "../src/dedup.ts";
20
+ import { IdentityTable, routeInboundTargets, hermesSourceProfile, type Identity } from "./identities.ts";
31
21
 
32
22
  /** What the connector needs from agentschat to fulfil outbound ops, per identity. */
33
23
  export interface AgentsChatHooks {
@@ -43,7 +33,15 @@ export interface AgentsChatHooks {
43
33
  * [] when there is nothing worth attaching. Best-effort: failures must not
44
34
  * block delivery of the addressed message itself.
45
35
  */
46
- getChannelContext?(botId: string, chatId: string, sinceTs?: string, excludeId?: string): Promise<Array<{ text: string; user_name?: string; user_id?: string }> | null>;
36
+ getChannelContext?(botId: string, chatId: string, sinceTs?: string, excludeId?: string, signal?: AbortSignal): Promise<Array<{ text: string; user_name?: string; user_id?: string }> | null>;
37
+ }
38
+
39
+ /** Only a structured status may be exposed, never a response body. */
40
+ export class PlatformHttpError extends Error {
41
+ constructor(readonly status: number) { super("platform HTTP failure"); }
42
+ }
43
+ export class PlatformNetworkError extends Error {
44
+ constructor() { super("platform network failure"); }
47
45
  }
48
46
 
49
47
  /** Legacy single-tenant hook shape (no botId first arg) — adapted to the per-identity one. */
@@ -73,16 +71,24 @@ export interface ConnectorConfig {
73
71
 
74
72
  export interface ConnectorHandle {
75
73
  port: number;
74
+ ready: Promise<void>;
76
75
  stop(): void;
77
76
  /** Push an agentschat message to the gateway socket(s) fronting its addressed identity. */
78
77
  injectAgentsChatMessage(msg: AgentsChatMessage): void | Promise<void>;
79
78
  connections(): number;
79
+ /**
80
+ * Hot-replace the identity table (and upgrade-auth secrets derived from it).
81
+ * Used when RELAY_IDENTITIES is reloaded without restarting the process.
82
+ */
83
+ reloadIdentities(identities: Identity[]): void;
80
84
  }
81
85
 
82
86
  /** A connected gateway socket and the set of identities it has hello'd (fronts). */
83
87
  interface GatewayConn {
84
88
  ws: WebSocket;
85
89
  gatewayId: string;
90
+ secret: string;
91
+ legacyAlias?: string;
86
92
  /** botIds this socket has declared via hello. */
87
93
  fronted: Set<string>;
88
94
  }
@@ -90,16 +96,18 @@ interface GatewayConn {
90
96
  export function startConnector(config: ConnectorConfig): ConnectorHandle {
91
97
  const log = config.logger ?? (() => {});
92
98
  const descriptor = buildDescriptor(config.descriptor);
93
- // "legacy" = no identity table configured (the pre-multiplex test/embedding
94
- // path): a single derived identity and chatId-first hooks. "single" = the
95
- // table holds exactly one identity (legacy OR a one-entry RELAY_IDENTITIES) —
96
- // it gates hello acceptance and outbound identity resolution, NOT inbound
97
- // group forwarding (unaddressed group chatter is dropped in every mode).
99
+ // No identity table: legacy single-tenant embedding with chatId-first hooks.
100
+ // Configured tables, including N=1, require an exact authenticated botId.
98
101
  const legacy = !config.identities || config.identities.length === 0;
99
102
  const table = new IdentityTable(
100
103
  legacy ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities!,
101
104
  );
102
- const single = table.isSingle();
105
+ function safeLabel(value: string) {
106
+ for (const id of table.all()) for (const secret of [id.token, id.secret]) {
107
+ if (secret) value = value.split(secret).join("[redacted]");
108
+ }
109
+ return JSON.stringify(value.slice(0, 128));
110
+ }
103
111
  // Normalize hooks to the per-identity shape. Legacy single-tenant hooks take
104
112
  // (chatId, ...); wrap them to ignore the botId. Per-identity hooks take botId first.
105
113
  const hooks: AgentsChatHooks = legacy
@@ -112,10 +120,11 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
112
120
  }
113
121
  : (config.agentschat as AgentsChatHooks);
114
122
  const sockets = new Set<GatewayConn>();
115
- // `${botId}:${chatId}` → timestamp of the last message ADDRESSED to that
123
+ // JSON [botId, chatId] → timestamp of the last message ADDRESSED to that
116
124
  // identity in that channel. Drives the getChannelContext `sinceTs` window.
117
125
  const lastAddressed = new Map<string, string>();
118
-
126
+ const delivered = new MessageDedup();
127
+ const inboundQueues = new Map<string, Promise<void>>();
119
128
  const http: HttpServer = createServer((_req, res) => {
120
129
  res.writeHead(200, { "Content-Type": "application/json" });
121
130
  res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
@@ -133,7 +142,8 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
133
142
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
134
143
  const payload = peekPayload(token);
135
144
  const secrets = payload ? config.secrets[payload] : undefined;
136
- const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
145
+ const secret = secrets?.find((s) => verifyUpgradeToken(token, [s]) === payload);
146
+ const gatewayId = secret ? payload : null;
137
147
  if (!gatewayId) {
138
148
  log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
139
149
  wss.handleUpgrade(req, socket, head, (ws) => {
@@ -142,14 +152,14 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
142
152
  return;
143
153
  }
144
154
  wss.handleUpgrade(req, socket, head, (ws) => {
145
- onConnection(ws, gatewayId);
155
+ onConnection(ws, gatewayId, secret!);
146
156
  });
147
157
  });
148
158
 
149
- function onConnection(ws: WebSocket, gatewayId: string) {
150
- const conn: GatewayConn = { ws, gatewayId, fronted: new Set() };
159
+ function onConnection(ws: WebSocket, gatewayId: string, secret: string) {
160
+ const conn: GatewayConn = { ws, gatewayId, secret, fronted: new Set() };
151
161
  sockets.add(conn);
152
- log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
162
+ log(`[connector] gateway connected: ${safeLabel(gatewayId)} (${sockets.size} total)`);
153
163
 
154
164
  ws.on("message", async (data) => {
155
165
  // Newline-delimited: the gateway may batch frames; split and handle each.
@@ -167,7 +177,7 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
167
177
  });
168
178
  ws.on("close", () => {
169
179
  sockets.delete(conn);
170
- log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
180
+ log(`[connector] gateway disconnected: ${safeLabel(gatewayId)} (${sockets.size} left)`);
171
181
  });
172
182
  ws.on("error", () => sockets.delete(conn));
173
183
  }
@@ -179,21 +189,39 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
179
189
  // Single-tenant (no identity table configured): front whatever identity the
180
190
  // gateway declares — there's exactly one. Multiplex: the botId MUST be a
181
191
  // registered identity (fail closed — never front an identity we can't send as).
182
- const identity = single
183
- ? table.all()[0]
184
- : botId
185
- ? table.forBot(botId)
186
- : null;
187
- if (!identity) {
188
- send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
192
+ const identity = legacy ? table.all()[0] : table.forBot(botId);
193
+ const reason = frame.platform !== "agentschat" ? "unsupported_platform" : !identity ? "unknown_identity"
194
+ : !legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret) ? "credential_mismatch" : null;
195
+ const labels = `gatewayId=${safeLabel(conn.gatewayId)} platform=${safeLabel(frame.platform === "agentschat" ? "agentschat" : "(unsupported)")} botId=${safeLabel(identity ? botId : "(unknown)")}`;
196
+ if (reason) {
197
+ log(`[connector] hello rejected ${labels} reason=${reason}`);
198
+ conn.fronted.clear();
199
+ send(conn.ws, { type: "error", error: `hello rejected: ${reason}` });
200
+ // Upgrade credentials were accepted; this hello does not match the
201
+ // configured identity/protocol. Fail closed, but allow configuration repair
202
+ // to recover through Hermes's normal reconnect path. Repeated 4401 after
203
+ // a prior descriptor latches credential revocation in ws_transport.py.
204
+ conn.ws.close(1002, `hello rejected: ${reason}`);
189
205
  return;
190
206
  }
191
- conn.fronted.add(identity.botId);
207
+ conn.fronted.add(identity!.botId);
208
+ if (legacy) conn.legacyAlias = botId;
209
+ log(`[connector] hello accepted ${labels}`);
192
210
  send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
193
211
  return;
194
212
  }
195
213
  if (t === "outbound") {
196
- const result = await handleOutbound(conn, frame);
214
+ let result;
215
+ try { result = await handleOutbound(conn, frame); }
216
+ catch (error) {
217
+ const status = error instanceof PlatformHttpError && Number.isInteger(error.status) && error.status >= 400 && error.status <= 599 ? error.status : undefined;
218
+ result = status !== undefined
219
+ ? { success: false, error: `agentschat HTTP ${status}`, code: "platform_http_error", status, ambiguous: status >= 500 }
220
+ : error instanceof PlatformNetworkError
221
+ ? { success: false, error: "agentschat network failure; outcome unknown", code: "platform_network_error", ambiguous: true }
222
+ : { success: false, error: "platform operation failed; outcome unknown", code: "platform_operation_failed", ambiguous: true };
223
+ log(`[connector] outbound failed: ${result.code}${status ? ` status=${status}` : ""}`);
224
+ }
197
225
  send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
198
226
  return;
199
227
  }
@@ -204,23 +232,16 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
204
232
  const action = frame?.action ?? {};
205
233
  const op = action?.op;
206
234
  const chatId = action?.chat_id ?? "";
207
- // Which identity sends? The frame's botId picks the egress identity for a
208
- // multi-identity gateway (contract D-Q1.5b.1: the connector validates the
209
- // per-frame egress target against the SET of identities THIS socket
210
- // advertised via hello). Untagged outbound falls back to the FIRST hello'd
211
- // identity (the session default). Fail closed when the named identity isn't
212
- // registered or wasn't fronted by this socket — never send with another
213
- // identity's credentials.
214
- const firstFronted = [...conn.fronted][0];
215
- const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
216
- const identity = single
217
- ? table.all()[0]
218
- : requested && conn.fronted.has(requested)
219
- ? table.forBot(requested)
220
- : null;
235
+ const firstFronted = conn.fronted.size === 1 ? [...conn.fronted][0] : undefined;
236
+ let requested = frame.botId === undefined ? firstFronted ?? null
237
+ : typeof frame.botId === "string" && frame.botId ? frame.botId : null;
238
+ if (legacy && requested && requested === conn.legacyAlias) requested = "default";
239
+ let identity = requested && conn.fronted.has(requested) ? table.forBot(requested) : null;
240
+ if (identity && ((!legacy && (identity.gatewayId !== conn.gatewayId || identity.secret !== conn.secret)) ||
241
+ (frame.platform !== undefined && frame.platform !== "agentschat"))) identity = null;
221
242
  if (!identity) {
222
- log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
223
- return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
243
+ log(`[connector] outbound failed: no usable identity for botId=${requested ?? "?"}`);
244
+ return { success: false, error: `no usable identity for outbound (botId=${requested ?? "?"})` };
224
245
  }
225
246
  switch (op) {
226
247
  case "send": {
@@ -247,12 +268,18 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
247
268
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj) + "\n");
248
269
  }
249
270
 
271
+ const ready = new Promise<void>((resolve, reject) => {
272
+ http.once("listening", resolve);
273
+ http.once("error", reject);
274
+ });
250
275
  http.listen(config.port, config.host ?? "127.0.0.1");
251
- const address = http.address();
252
- const port = typeof address === "object" && address ? address.port : config.port;
253
276
 
254
277
  return {
255
- port,
278
+ ready,
279
+ get port() {
280
+ const address = http.address();
281
+ return typeof address === "object" && address ? address.port : config.port;
282
+ },
256
283
  stop() {
257
284
  for (const c of sockets) {
258
285
  try { c.ws.close(1001, "connector shutdown"); } catch {}
@@ -273,59 +300,102 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
273
300
  // unaddressed is dropped — injecting joined-channel chatter into the
274
301
  // agent's session would burn its tokens on messages not meant for it.
275
302
  const isDm = typeof msg.channel_id === "string" && msg.channel_id.startsWith("dm-");
276
- const bySocket = (msg as any).__botId ? table.forBot(String((msg as any).__botId)) : null;
277
- const target = isDm
278
- ? bySocket ?? routeInbound(table, {
279
- channel_id: msg.channel_id,
280
- dmOwnerBotId: (msg as any).dm_owner,
281
- }) ?? (table.isSingle() ? table.all()[0] : null)
282
- : routeInbound(table, {
303
+ const owner = (msg as any).__botId ?? (msg as any).dm_owner;
304
+ const targets = isDm
305
+ ? [owner !== undefined ? (typeof owner === "string" ? table.forBot(owner) : null)
306
+ : table.isSingle() ? table.all()[0] : null].filter((id): id is Identity => !!id)
307
+ : routeInboundTargets(table, {
283
308
  channel_id: msg.channel_id,
284
309
  mentioned_ids: (msg as any).mentioned_ids,
285
310
  content: msg.content,
286
311
  });
287
- if (!target) {
312
+ if (!targets.length) {
288
313
  log(`[connector] inbound unaddressed (channel=${(msg as any).channel_id ?? "?"} mentions=${JSON.stringify((msg as any).mentioned_ids ?? [])}) — dropped`);
289
314
  return;
290
315
  }
291
- const baseEvent = toWireEvent(msg, "agentschat");
292
- if (!baseEvent) return;
293
- if (!isDm) {
294
- // Attach "what happened since you were last addressed" so the agent gets
295
- // the conversation BETWEEN its @-mentions without being injected into
296
- // every unaddressed message. Upstream renders event.context into the
297
- // event's channel_context ("[Recent channel messages]") — gateway needs
298
- // no change. Best-effort: a context failure never delays the message.
299
- const key = `${target.botId}:${msg.channel_id}`;
300
- const since = lastAddressed.get(key);
301
- if (hooks.getChannelContext) {
302
- try {
303
- const ctx = await hooks.getChannelContext(target.botId, msg.channel_id!, since, msg.id);
304
- if (ctx && ctx.length) {
305
- baseEvent.context = ctx.slice(-10).map((c) => ({
306
- text: String(c?.text ?? "").slice(0, 500),
307
- source: { user_name: c?.user_name, user_id: c?.user_id },
308
- }));
316
+ await Promise.all(targets.map(target => {
317
+ const key = JSON.stringify([target.botId, msg.channel_id]);
318
+ const previous = inboundQueues.get(key) ?? Promise.resolve();
319
+ const job = previous.catch(() => {}).then(async () => {
320
+ if (msg.sender_id === target.agentId) return;
321
+ const baseEvent = toWireEvent(msg, "agentschat");
322
+ if (!baseEvent) return;
323
+ if (![...sockets].some(c => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId))) return;
324
+ const deliveryKey = typeof msg.id === "string" && msg.id
325
+ ? JSON.stringify([msg.channel_id, msg.id, target.botId]) : null;
326
+ // Reserve synchronously, before async context fetch, across mirrored sockets.
327
+ if (deliveryKey && delivered.recordOrSkip(deliveryKey)) return;
328
+ if (!isDm) {
329
+ // Attach "what happened since you were last addressed" so the agent gets
330
+ // the conversation BETWEEN its @-mentions without being injected into
331
+ // every unaddressed message. Upstream renders event.context into the
332
+ // event's channel_context ("[Recent channel messages]") — gateway needs
333
+ // no change. Fetch errors fall back to delivering without context.
334
+ const since = lastAddressed.get(key);
335
+ if (hooks.getChannelContext) {
336
+ const controller = new AbortController();
337
+ let timer: ReturnType<typeof setTimeout> | undefined;
338
+ try {
339
+ const ctx = await Promise.race([
340
+ hooks.getChannelContext(target.botId, msg.channel_id!, since, msg.id, controller.signal),
341
+ new Promise<null>(resolve => { timer = setTimeout(() => { controller.abort(); resolve(null); }, 1000); }),
342
+ ]);
343
+ if (ctx && ctx.length) {
344
+ baseEvent.context = ctx.slice(-10).map((c) => ({
345
+ text: String(c?.text ?? "").slice(0, 500),
346
+ source: { user_name: c?.user_name, user_id: c?.user_id },
347
+ }));
348
+ }
349
+ } catch {
350
+ log(`[connector] context fetch failed (delivering without it)`);
351
+ } finally {
352
+ if (timer) clearTimeout(timer);
353
+ }
309
354
  }
310
- } catch (e) {
311
- log(`[connector] context fetch failed for ${key} (delivering without it): ${e}`);
355
+ if (msg.timestamp && Number.isFinite(Date.parse(msg.timestamp)) &&
356
+ (!since || Date.parse(msg.timestamp) > Date.parse(since))) lastAddressed.set(key, msg.timestamp);
312
357
  }
313
- }
314
- if (msg.timestamp) lastAddressed.set(key, msg.timestamp);
315
- }
316
- for (const conn of sockets) {
317
- if (!conn.fronted.has(target.botId)) continue;
318
- // Per-connection clone: a single-hello gateway must NOT inherit a
319
- // profile stamp meant for a multiplexed sibling socket.
320
- const event = { ...baseEvent, source: { ...baseEvent.source } };
321
- const profile = hermesSourceProfile(target, conn.fronted.size);
322
- if (profile) (event.source as any).profile = profile;
323
- send(conn.ws, { type: "inbound", event });
324
- }
358
+ const deliverTo = [...sockets].filter((c) => c.ws.readyState === WebSocket.OPEN && c.fronted.has(target.botId)).slice(0, 1);
359
+ if (deliverTo.length === 0) {
360
+ log(`[connector] inbound dropped for botId=${target.botId}: no agentschat-fronted gateway socket`);
361
+ return;
362
+ }
363
+ for (const conn of deliverTo) {
364
+ // Per-connection clone: a single-hello gateway must NOT inherit a
365
+ // profile stamp meant for a multiplexed sibling socket.
366
+ const event = { ...baseEvent, source: { ...baseEvent.source } };
367
+ const profile = hermesSourceProfile(target, conn.fronted.size);
368
+ if (profile) (event.source as any).profile = profile;
369
+ send(conn.ws, { type: "inbound", event });
370
+ }
371
+ });
372
+ inboundQueues.set(key, job);
373
+ return job.finally(() => {
374
+ if (inboundQueues.get(key) === job) inboundQueues.delete(key);
375
+ });
376
+ }));
325
377
  },
326
378
  connections() {
327
379
  return sockets.size;
328
380
  },
381
+ reloadIdentities(next: Identity[]) {
382
+ if (!next || next.length === 0) {
383
+ throw new Error("reloadIdentities requires a non-empty identity list");
384
+ }
385
+ table.replace(next);
386
+ for (const conn of sockets) {
387
+ for (const botId of conn.fronted) {
388
+ const id = table.forBot(botId);
389
+ if (!id || id.gatewayId !== conn.gatewayId || id.secret !== conn.secret) conn.fronted.delete(botId);
390
+ }
391
+ }
392
+ // Rebuild upgrade-auth secrets in place so the upgrade handler sees them.
393
+ for (const k of Object.keys(config.secrets)) delete config.secrets[k];
394
+ for (const id of next) {
395
+ (config.secrets[id.gatewayId] ??= []).push(id.secret);
396
+ }
397
+ log(`[connector] identities reloaded: ${table.size}`);
398
+ },
329
399
  };
330
400
  }
331
401