agentschat-mcp 0.31.0 → 0.32.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,20 +1,25 @@
1
1
  /**
2
2
  * agentschat connector — the connector side of the Hermes relay contract.
3
3
  *
4
- * Single-tenant: one agentschat identity, one tenant. The gateway dials OUT to
5
- * this server's `/relay` WebSocket with an `Authorization: Bearer <upgrade token>`
6
- * header; on a valid token the connection is admitted and the contract's frame
7
- * exchange proceeds (see ws_transport.py on the gateway side):
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
8
  *
9
+ * Frame exchange (see gateway ws_transport.py):
9
10
  * gateway → hello {type:"hello", platform, botId}
10
- * connector→ descriptor {type:"descriptor", descriptor:{...}}
11
- * connector→ inbound {type:"inbound", event:{...}} (agentschat → gateway)
12
- * gateway → outbound {type:"outbound", requestId, action}
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}
13
14
  * connector→ outbound_result {type:"outbound_result", requestId, result}
14
15
  *
15
- * Auth is fail-closed: anything wrong with the upgrade token closes 4401 before
16
- * the socket is admitted (the gateway treats a 4401 before first successful
17
- * handshake as retryable, but we never let an unauthenticated socket through).
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).
18
23
  */
19
24
 
20
25
  import { createServer, type Server as HttpServer, type IncomingMessage } from "node:http";
@@ -22,9 +27,17 @@ import { WebSocketServer, WebSocket } from "ws";
22
27
  import { verifyUpgradeToken, CLOSE_UNAUTHORIZED } from "./auth.ts";
23
28
  import { buildDescriptor, type CapabilityDescriptor } from "./descriptor.ts";
24
29
  import { toWireEvent, type AgentsChatMessage } from "./normalize.ts";
30
+ import { IdentityTable, routeInbound, type Identity } from "./identities.ts";
25
31
 
26
- /** What the connector needs from agentschat to fulfil outbound ops. */
32
+ /** What the connector needs from agentschat to fulfil outbound ops, per identity. */
27
33
  export interface AgentsChatHooks {
34
+ sendMessage(botId: string, chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
35
+ getChatInfo(botId: string, chatId: string): Promise<{ name?: string; type?: string }>;
36
+ sendTyping?(botId: string, chatId: string): Promise<void>;
37
+ }
38
+
39
+ /** Legacy single-tenant hook shape (no botId first arg) — adapted to the per-identity one. */
40
+ interface LegacyHooks {
28
41
  sendMessage(chatId: string, content: string, replyTo?: string): Promise<{ id?: string }>;
29
42
  getChatInfo(chatId: string): Promise<{ name?: string; type?: string }>;
30
43
  sendTyping?(chatId: string): Promise<void>;
@@ -35,29 +48,55 @@ export interface ConnectorConfig {
35
48
  host?: string;
36
49
  /** gatewayId → acceptable secrets (rotation window). */
37
50
  secrets: Record<string, string[]>;
51
+ /**
52
+ * The identities this connector fronts. Multiplex: one per agentschat identity.
53
+ * Single-tenant (legacy): omit and provide `agentschat` — a single implicit
54
+ * identity is derived (botId "default").
55
+ */
56
+ identities?: Identity[];
38
57
  /** Override the descriptor (tests/customization); defaults to the agentschat descriptor. */
39
58
  descriptor?: Partial<CapabilityDescriptor>;
40
- agentschat: AgentsChatHooks;
41
- /** Called for each agentschat message to broadcast to connected gateways. */
59
+ agentschat: AgentsChatHooks | LegacyHooks;
42
60
  logger?: (msg: string) => void;
43
61
  }
44
62
 
45
63
  export interface ConnectorHandle {
46
64
  port: number;
47
65
  stop(): void;
48
- /** Push an agentschat message to every connected gateway as an inbound frame. */
66
+ /** Push an agentschat message to the gateway socket(s) fronting its addressed identity. */
49
67
  injectAgentsChatMessage(msg: AgentsChatMessage): void;
50
68
  connections(): number;
51
69
  }
52
70
 
71
+ /** A connected gateway socket and the set of identities it has hello'd (fronts). */
72
+ interface GatewayConn {
73
+ ws: WebSocket;
74
+ gatewayId: string;
75
+ /** botIds this socket has declared via hello. */
76
+ fronted: Set<string>;
77
+ }
78
+
53
79
  export function startConnector(config: ConnectorConfig): ConnectorHandle {
54
80
  const log = config.logger ?? (() => {});
55
81
  const descriptor = buildDescriptor(config.descriptor);
56
- const sockets = new Set<WebSocket>();
82
+ const single = !config.identities || config.identities.length === 0;
83
+ const table = new IdentityTable(
84
+ single ? [{ botId: "default", agentId: "default", token: "", gatewayId: "", secret: "" }] : config.identities!,
85
+ );
86
+ // Normalize hooks to the per-identity shape. Legacy single-tenant hooks take
87
+ // (chatId, ...); wrap them to ignore the botId. Per-identity hooks take botId first.
88
+ const hooks: AgentsChatHooks = single
89
+ ? {
90
+ sendMessage: (_b, chatId, content, replyTo) => (config.agentschat as LegacyHooks).sendMessage(chatId, content, replyTo),
91
+ getChatInfo: (_b, chatId) => (config.agentschat as LegacyHooks).getChatInfo(chatId),
92
+ sendTyping: (_b, chatId) => (config.agentschat as LegacyHooks).sendTyping?.(chatId) ?? Promise.resolve(),
93
+ }
94
+ : (config.agentschat as AgentsChatHooks);
95
+ const sockets = new Set<GatewayConn>();
57
96
 
58
97
  const http: HttpServer = createServer((_req, res) => {
59
98
  res.writeHead(200, { "Content-Type": "application/json" });
60
- res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1 }));
99
+ res.end(JSON.stringify({ ok: true, service: "agentschat-connector", contract_version: 1, identities: table.size }));
61
100
  });
62
101
 
63
102
  const wss = new WebSocketServer({ noServer: true });
@@ -70,16 +109,11 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
70
109
  }
71
110
  const auth = req.headers.authorization ?? "";
72
111
  const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
73
- // Peek the gateway id (payload head) to select that gateway's verify list.
74
112
  const payload = peekPayload(token);
75
113
  const secrets = payload ? config.secrets[payload] : undefined;
76
114
  const gatewayId = secrets ? verifyUpgradeToken(token, secrets) : null;
77
115
  if (!gatewayId) {
78
116
  log(`[connector] rejecting upgrade: bad/absent token (path=${pathname})`);
79
- // Write a 4401-flavored close: send an HTTP 401 then destroy, and — once the
80
- // WS is established for a valid path — close with the app code. For the
81
- // upgrade itself we must reject the handshake; the client observes a failed
82
- // upgrade. We additionally complete a minimal WS so we can send close 4401.
83
117
  wss.handleUpgrade(req, socket, head, (ws) => {
84
118
  ws.close(CLOSE_UNAUTHORIZED, "unauthorized");
85
119
  });
@@ -91,65 +125,103 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
91
125
  });
92
126
 
93
127
  function onConnection(ws: WebSocket, gatewayId: string) {
94
- sockets.add(ws);
128
+ const conn: GatewayConn = { ws, gatewayId, fronted: new Set() };
129
+ sockets.add(conn);
95
130
  log(`[connector] gateway connected: ${gatewayId} (${sockets.size} total)`);
96
131
 
97
132
  ws.on("message", async (data) => {
98
- let frame: any;
99
- try {
100
- frame = JSON.parse(data.toString());
101
- } catch {
102
- return;
133
+ // Newline-delimited: the gateway may batch frames; split and handle each.
134
+ const text = data.toString();
135
+ for (const line of text.split("\n")) {
136
+ if (!line.trim()) continue;
137
+ let frame: any;
138
+ try {
139
+ frame = JSON.parse(line);
140
+ } catch {
141
+ continue;
142
+ }
143
+ await handleFrame(conn, frame).catch((e) => log(`[connector] frame error: ${e}`));
103
144
  }
104
- await handleFrame(ws, frame).catch((e) => log(`[connector] frame error: ${e}`));
105
145
  });
106
146
  ws.on("close", () => {
107
- sockets.delete(ws);
147
+ sockets.delete(conn);
108
148
  log(`[connector] gateway disconnected: ${gatewayId} (${sockets.size} left)`);
109
149
  });
110
- ws.on("error", () => sockets.delete(ws));
150
+ ws.on("error", () => sockets.delete(conn));
111
151
  }
112
152
 
113
- async function handleFrame(ws: WebSocket, frame: any) {
153
+ async function handleFrame(conn: GatewayConn, frame: any) {
114
154
  const t = frame?.type;
115
155
  if (t === "hello") {
116
- send(ws, { type: "descriptor", descriptor });
156
+ const botId = String(frame.botId ?? "");
157
+ // Single-tenant (no identity table configured): front whatever identity the
158
+ // gateway declares — there's exactly one. Multiplex: the botId MUST be a
159
+ // registered identity (fail closed — never front an identity we can't send as).
160
+ const identity = single
161
+ ? table.all()[0]
162
+ : botId
163
+ ? table.forBot(botId)
164
+ : null;
165
+ if (!identity) {
166
+ send(conn.ws, { type: "error", error: `unknown identity botId: ${botId || "(none)"}` });
167
+ return;
168
+ }
169
+ conn.fronted.add(identity.botId);
170
+ send(conn.ws, { type: "descriptor", descriptor: { ...descriptor, platform: "agentschat" } });
117
171
  return;
118
172
  }
119
173
  if (t === "outbound") {
120
- const result = await handleOutbound(frame.action ?? {});
121
- send(ws, { type: "outbound_result", requestId: frame.requestId, result });
174
+ const result = await handleOutbound(conn, frame);
175
+ send(conn.ws, { type: "outbound_result", requestId: frame.requestId, result });
122
176
  return;
123
177
  }
124
178
  // Unknown / ignored frame types (interrupt, etc.) — additive contract, ignore.
125
179
  }
126
180
 
127
- async function handleOutbound(action: any): Promise<any> {
181
+ async function handleOutbound(conn: GatewayConn, frame: any): Promise<any> {
182
+ const action = frame?.action ?? {};
128
183
  const op = action?.op;
129
184
  const chatId = action?.chat_id ?? "";
185
+ // Which identity sends? The frame's botId picks the egress identity for a
186
+ // multi-identity gateway (contract D-Q1.5b.1: the connector validates the
187
+ // per-frame egress target against the SET of identities THIS socket
188
+ // advertised via hello). Untagged outbound falls back to the FIRST hello'd
189
+ // identity (the session default). Fail closed when the named identity isn't
190
+ // registered or wasn't fronted by this socket — never send with another
191
+ // identity's credentials.
192
+ const firstFronted = [...conn.fronted][0];
193
+ const requested = typeof frame?.botId === "string" && frame.botId ? frame.botId : firstFronted ?? null;
194
+ const identity = single
195
+ ? table.all()[0]
196
+ : requested && conn.fronted.has(requested)
197
+ ? table.forBot(requested)
198
+ : null;
199
+ if (!identity) {
200
+ log(`[connector] outbound failed: no fronted identity for botId=${requested ?? "?"}`);
201
+ return { success: false, error: `no fronted identity for outbound (botId=${requested ?? "?"})` };
202
+ }
130
203
  switch (op) {
131
204
  case "send": {
132
- const r = await config.agentschat.sendMessage(chatId, action.content ?? "", action.reply_to);
205
+ const r = await hooks.sendMessage(identity.botId, chatId, action.content ?? "", action.reply_to);
133
206
  return { success: true, message_id: r?.id };
134
207
  }
135
208
  case "typing": {
136
- await config.agentschat.sendTyping?.(chatId);
209
+ await hooks.sendTyping?.(identity.botId, chatId);
137
210
  return { success: true };
138
211
  }
139
212
  case "get_chat_info": {
140
- const info = await config.agentschat.getChatInfo(chatId);
213
+ const info = await hooks.getChatInfo(identity.botId, chatId);
141
214
  return info ?? {};
142
215
  }
143
216
  default:
217
+ log(`[connector] outbound failed: unsupported op ${String(op)}`);
144
218
  return { success: false, error: `unsupported op: ${String(op)}` };
145
219
  }
146
220
  }
147
221
 
148
222
  function send(ws: WebSocket, obj: any) {
149
- // The relay transport is newline-delimited (gateway ws_transport.py's read loop
150
- // splits on "\n"). A frame without a trailing newline never reaches the
151
- // gateway's frame handler — it sits in the reader's buffer waiting for the
152
- // terminator. This is why the descriptor must end with \n.
223
+ // Newline-delimited (gateway ws_transport.py splits on "\n"): a frame without the
224
+ // terminator never reaches the gateway's frame handler.
153
225
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj) + "\n");
154
226
  }
155
227
 
@@ -160,16 +232,35 @@ export function startConnector(config: ConnectorConfig): ConnectorHandle {
160
232
  return {
161
233
  port,
162
234
  stop() {
163
- for (const ws of sockets) {
164
- try { ws.close(1001, "connector shutdown"); } catch {}
235
+ for (const c of sockets) {
236
+ try { c.ws.close(1001, "connector shutdown"); } catch {}
165
237
  }
166
238
  wss.close();
167
239
  http.close();
168
240
  },
169
241
  injectAgentsChatMessage(msg: AgentsChatMessage) {
170
- const event = toWireEvent(msg, descriptor.platform);
242
+ // Route to the identity this message is addressed to, then only to gateway
243
+ // socket(s) fronting THAT identity — never broadcast across identities.
244
+ // The strongest signal is __botId: which identity's agentschat socket the
245
+ // message ARRIVED on (agentschat only pushes @mentions/DMs to that agent).
246
+ // Fall back to mention/DM-owner routing for hosts that don't tag.
247
+ const bySocket = (msg as any).__botId ? table.forBot(String((msg as any).__botId)) : null;
248
+ const target = bySocket ?? routeInbound(table, {
249
+ channel_id: msg.channel_id,
250
+ mentioned_ids: (msg as any).mentioned_ids,
251
+ dmOwnerBotId: (msg as any).dm_owner,
252
+ }) ?? (table.isSingle() ? table.all()[0] : null);
253
+ if (!target) {
254
+ log(`[connector] inbound unaddressed (channel=${(msg as any).channel_id ?? "?"} mentions=${JSON.stringify((msg as any).mentioned_ids ?? [])}) — dropped`);
255
+ return;
256
+ }
257
+ const event = toWireEvent(msg, "agentschat");
171
258
  if (!event) return;
172
- for (const ws of sockets) send(ws, { type: "inbound", event });
259
+ // Tag the fronting identity so the gateway keys the right session/profile.
260
+ (event.source as any).profile = target.botId;
261
+ for (const conn of sockets) {
262
+ if (conn.fronted.has(target.botId)) send(conn.ws, { type: "inbound", event });
263
+ }
173
264
  },
174
265
  connections() {
175
266
  return sockets.size;