@animalabs/agent-framework 0.5.7 → 0.6.2

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.
Files changed (179) hide show
  1. package/dist/src/agent.d.ts +4 -0
  2. package/dist/src/agent.d.ts.map +1 -1
  3. package/dist/src/agent.js +15 -0
  4. package/dist/src/agent.js.map +1 -1
  5. package/dist/src/framework.d.ts +243 -0
  6. package/dist/src/framework.d.ts.map +1 -1
  7. package/dist/src/framework.js +1378 -114
  8. package/dist/src/framework.js.map +1 -1
  9. package/dist/src/gate/event-gate.d.ts +64 -2
  10. package/dist/src/gate/event-gate.d.ts.map +1 -1
  11. package/dist/src/gate/event-gate.js +212 -12
  12. package/dist/src/gate/event-gate.js.map +1 -1
  13. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  14. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script-worker.js +69 -0
  16. package/dist/src/gate/gate-script-worker.js.map +1 -0
  17. package/dist/src/gate/gate-script.d.ts +44 -0
  18. package/dist/src/gate/gate-script.d.ts.map +1 -0
  19. package/dist/src/gate/gate-script.js +164 -0
  20. package/dist/src/gate/gate-script.js.map +1 -0
  21. package/dist/src/gate/types.d.ts +18 -3
  22. package/dist/src/gate/types.d.ts.map +1 -1
  23. package/dist/src/index.d.ts +3 -0
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +4 -0
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/mcpl/channel-registry.d.ts +60 -2
  28. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  29. package/dist/src/mcpl/channel-registry.js +114 -9
  30. package/dist/src/mcpl/channel-registry.js.map +1 -1
  31. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  32. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  33. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  34. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  35. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  36. package/dist/src/mcpl/push-handler.js +5 -0
  37. package/dist/src/mcpl/push-handler.js.map +1 -1
  38. package/dist/src/mcpl/server-connection.d.ts +64 -30
  39. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  40. package/dist/src/mcpl/server-connection.js +247 -210
  41. package/dist/src/mcpl/server-connection.js.map +1 -1
  42. package/dist/src/mcpl/transport.d.ts +98 -0
  43. package/dist/src/mcpl/transport.d.ts.map +1 -0
  44. package/dist/src/mcpl/transport.js +281 -0
  45. package/dist/src/mcpl/transport.js.map +1 -0
  46. package/dist/src/mcpl/types.d.ts +59 -3
  47. package/dist/src/mcpl/types.d.ts.map +1 -1
  48. package/dist/src/mcpl/types.js.map +1 -1
  49. package/dist/src/module-registry.d.ts +17 -1
  50. package/dist/src/module-registry.d.ts.map +1 -1
  51. package/dist/src/module-registry.js +101 -6
  52. package/dist/src/module-registry.js.map +1 -1
  53. package/dist/src/prose-segments.d.ts +18 -0
  54. package/dist/src/prose-segments.d.ts.map +1 -0
  55. package/dist/src/prose-segments.js +39 -0
  56. package/dist/src/prose-segments.js.map +1 -0
  57. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  58. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  59. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  60. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  61. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  62. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  63. package/dist/src/runtime/liveness-watchdog.js +106 -0
  64. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  65. package/dist/src/types/agent.d.ts +47 -0
  66. package/dist/src/types/agent.d.ts.map +1 -1
  67. package/dist/src/types/framework.d.ts +14 -0
  68. package/dist/src/types/framework.d.ts.map +1 -1
  69. package/dist/src/types/module.d.ts +58 -0
  70. package/dist/src/types/module.d.ts.map +1 -1
  71. package/dist/src/types/trace.d.ts +42 -0
  72. package/dist/src/types/trace.d.ts.map +1 -1
  73. package/dist/test/agent-cache-ttl.test.d.ts +2 -0
  74. package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
  75. package/dist/test/agent-cache-ttl.test.js +25 -0
  76. package/dist/test/agent-cache-ttl.test.js.map +1 -0
  77. package/dist/test/channel-registry-routing.test.js +108 -1
  78. package/dist/test/channel-registry-routing.test.js.map +1 -1
  79. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  80. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  81. package/dist/test/checkpoint-attribution.test.js +40 -0
  82. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  83. package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
  84. package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
  85. package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
  86. package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
  87. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  88. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  89. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  90. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  91. package/dist/test/event-gate-script.test.d.ts +2 -0
  92. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  93. package/dist/test/event-gate-script.test.js +95 -0
  94. package/dist/test/event-gate-script.test.js.map +1 -0
  95. package/dist/test/event-gate-tags.test.d.ts +2 -0
  96. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  97. package/dist/test/event-gate-tags.test.js +76 -0
  98. package/dist/test/event-gate-tags.test.js.map +1 -0
  99. package/dist/test/gather-context-timeout.test.d.ts +2 -0
  100. package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
  101. package/dist/test/gather-context-timeout.test.js +120 -0
  102. package/dist/test/gather-context-timeout.test.js.map +1 -0
  103. package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
  104. package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
  105. package/dist/test/inference-exhaustion-rewind.test.js +181 -0
  106. package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
  107. package/dist/test/inference-failure-observability.test.js +4 -0
  108. package/dist/test/inference-failure-observability.test.js.map +1 -1
  109. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  110. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  111. package/dist/test/liveness-watchdog.test.js +78 -0
  112. package/dist/test/liveness-watchdog.test.js.map +1 -0
  113. package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
  114. package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
  115. package/dist/test/mcpl-reconnect-events.test.js +119 -0
  116. package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
  117. package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
  118. package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
  119. package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
  120. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
  121. package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
  122. package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
  123. package/dist/test/mcpl-request-timeout.test.js +123 -0
  124. package/dist/test/mcpl-request-timeout.test.js.map +1 -0
  125. package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
  126. package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
  127. package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
  128. package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
  129. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  130. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  131. package/dist/test/mcpl-ws-transport.test.js +155 -0
  132. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  133. package/dist/test/prose-segments.test.d.ts +2 -0
  134. package/dist/test/prose-segments.test.d.ts.map +1 -0
  135. package/dist/test/prose-segments.test.js +52 -0
  136. package/dist/test/prose-segments.test.js.map +1 -0
  137. package/dist/test/refusal-rewind.test.d.ts +2 -0
  138. package/dist/test/refusal-rewind.test.d.ts.map +1 -0
  139. package/dist/test/refusal-rewind.test.js +108 -0
  140. package/dist/test/refusal-rewind.test.js.map +1 -0
  141. package/dist/test/state-scaling-e2e.test.d.ts +22 -0
  142. package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
  143. package/dist/test/state-scaling-e2e.test.js +200 -0
  144. package/dist/test/state-scaling-e2e.test.js.map +1 -0
  145. package/dist/test/state-scaling.test.d.ts +12 -0
  146. package/dist/test/state-scaling.test.d.ts.map +1 -0
  147. package/dist/test/state-scaling.test.js +243 -0
  148. package/dist/test/state-scaling.test.js.map +1 -0
  149. package/dist/test/tool-result-content.test.d.ts +2 -0
  150. package/dist/test/tool-result-content.test.d.ts.map +1 -0
  151. package/dist/test/tool-result-content.test.js +37 -0
  152. package/dist/test/tool-result-content.test.js.map +1 -0
  153. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  154. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  155. package/dist/test/trunk-channel-routing.test.js +175 -0
  156. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  157. package/dist/tsconfig.tsbuildinfo +1 -1
  158. package/package.json +4 -3
  159. package/src/agent.ts +16 -0
  160. package/src/framework.ts +1576 -121
  161. package/src/gate/event-gate.ts +209 -12
  162. package/src/gate/gate-script-worker.ts +73 -0
  163. package/src/gate/gate-script.ts +180 -0
  164. package/src/gate/types.ts +19 -3
  165. package/src/index.ts +10 -0
  166. package/src/mcpl/channel-registry.ts +143 -8
  167. package/src/mcpl/checkpoint-manager.ts +16 -0
  168. package/src/mcpl/push-handler.ts +5 -0
  169. package/src/mcpl/server-connection.ts +262 -227
  170. package/src/mcpl/transport.ts +307 -0
  171. package/src/mcpl/types.ts +68 -3
  172. package/src/module-registry.ts +126 -6
  173. package/src/prose-segments.ts +42 -0
  174. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  175. package/src/runtime/liveness-watchdog.ts +122 -0
  176. package/src/types/agent.ts +48 -0
  177. package/src/types/framework.ts +15 -0
  178. package/src/types/module.ts +65 -0
  179. package/src/types/trace.ts +51 -0
@@ -0,0 +1,307 @@
1
+ /**
2
+ * McplTransport — the byte-level seam under McplServerConnection.
3
+ *
4
+ * An MCPL connection is newline-delimited JSON-RPC over a duplex stream. The
5
+ * only things the connection needs from its underlying transport are: write a
6
+ * line, receive lines, surface diagnostics/errors, and know when it closed. By
7
+ * abstracting exactly that, the handshake, message routing, and every send path
8
+ * stay transport-agnostic and run IDENTICALLY over a spawned stdio child or a
9
+ * WebSocket — which is the whole point of network MCPL.
10
+ *
11
+ * Two implementations:
12
+ * - StdioTransport — spawns `config.command` and speaks over stdin/stdout;
13
+ * child stderr is surfaced as 'stderr' lines.
14
+ * - WebSocketTransport — dials `config.url` (ws:// or wss://) with the `ws`
15
+ * library, appending `?token=` when `config.token` is
16
+ * set; each WS text frame is one JSON-RPC line.
17
+ */
18
+
19
+ import { spawn, type ChildProcess } from 'node:child_process';
20
+ import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
21
+ import { EventEmitter } from 'node:events';
22
+ import WebSocket from 'ws';
23
+
24
+ import type { McplServerConfig } from './types.js';
25
+
26
+ /** Why/how a transport closed. `code`/`signal` are populated for stdio child
27
+ * exits; `reason` carries a WebSocket close reason or an error message. */
28
+ export interface TransportCloseInfo {
29
+ code?: number | null;
30
+ signal?: NodeJS.Signals | null;
31
+ reason?: string;
32
+ }
33
+
34
+ /**
35
+ * A line-oriented duplex transport to an MCPL server.
36
+ *
37
+ * Events:
38
+ * - `'line'` `(line: string)` one inbound NDJSON line (no newline)
39
+ * - `'stderr'` `(line: string)` diagnostic line (stdio child stderr; never WS)
40
+ * - `'error'` `(err: Error)` transport-level error
41
+ * - `'close'` `(info: TransportCloseInfo)` transport closed (once)
42
+ *
43
+ * Inbound `'line'`/`'stderr'` events are buffered until the first matching
44
+ * listener attaches (and re-buffered whenever there are none), so no message is
45
+ * lost across the handshake→routing handoff regardless of attach timing.
46
+ */
47
+ export abstract class McplTransport extends EventEmitter {
48
+ abstract readonly kind: 'stdio' | 'websocket';
49
+
50
+ private _closed = false;
51
+ private lineBuffer: string[] = [];
52
+ private stderrBuffer: string[] = [];
53
+
54
+ protected constructor() {
55
+ super();
56
+ // Flush buffered inbound lines the moment a consumer subscribes.
57
+ this.on('newListener', (event) => {
58
+ if (event === 'line' && this.lineBuffer.length > 0) {
59
+ const pending = this.lineBuffer;
60
+ this.lineBuffer = [];
61
+ queueMicrotask(() => { for (const l of pending) this.emit('line', l); });
62
+ } else if (event === 'stderr' && this.stderrBuffer.length > 0) {
63
+ const pending = this.stderrBuffer;
64
+ this.stderrBuffer = [];
65
+ queueMicrotask(() => { for (const l of pending) this.emit('stderr', l); });
66
+ }
67
+ });
68
+ }
69
+
70
+ get closed(): boolean {
71
+ return this._closed;
72
+ }
73
+
74
+ /** Deliver an inbound line, buffering it if nobody is listening yet. */
75
+ protected pushLine(line: string): void {
76
+ if (this.listenerCount('line') > 0) this.emit('line', line);
77
+ else this.lineBuffer.push(line);
78
+ }
79
+
80
+ /** Deliver a diagnostic line, buffering it if nobody is listening yet. */
81
+ protected pushStderr(line: string): void {
82
+ if (this.listenerCount('stderr') > 0) this.emit('stderr', line);
83
+ else this.stderrBuffer.push(line);
84
+ }
85
+
86
+ /** Emit 'close' exactly once. */
87
+ protected markClosed(info: TransportCloseInfo): void {
88
+ if (this._closed) return;
89
+ this._closed = true;
90
+ this.emit('close', info);
91
+ }
92
+
93
+ /** Write one JSON-RPC message as an NDJSON line. */
94
+ abstract writeLine(json: string): void;
95
+
96
+ /** Close the transport and release resources. Resolves once fully closed. */
97
+ abstract close(): Promise<void>;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // stdio
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export class StdioTransport extends McplTransport {
105
+ readonly kind = 'stdio' as const;
106
+
107
+ private constructor(
108
+ private readonly child: ChildProcess,
109
+ private readonly rl: ReadlineInterface,
110
+ ) {
111
+ super();
112
+
113
+ this.rl.on('line', (line: string) => this.pushLine(line));
114
+
115
+ // Stitch stderr chunks into whole lines (a chunk can split a line).
116
+ let carry = '';
117
+ this.child.stderr?.on('data', (chunk: Buffer) => {
118
+ const text = carry + chunk.toString('utf8');
119
+ const lines = text.split('\n');
120
+ carry = lines.pop() ?? '';
121
+ for (const l of lines) if (l.length > 0) this.pushStderr(l);
122
+ });
123
+
124
+ this.child.on('error', (err) => this.emit('error', err));
125
+ this.child.on('exit', (code, signal) =>
126
+ this.markClosed({ code, signal, reason: 'child process exited' }),
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Spawn `config.command` and return a ready stdio transport. Rejects if the
132
+ * command is missing (a config bug) or the process fails to spawn.
133
+ */
134
+ static spawn(config: McplServerConfig): StdioTransport {
135
+ if (!config.command) {
136
+ throw new Error(
137
+ `MCPL server "${config.id}": stdio transport requires "command" (none provided)`,
138
+ );
139
+ }
140
+ const child = spawn(config.command, config.args ?? [], {
141
+ stdio: ['pipe', 'pipe', 'pipe'],
142
+ env: { ...process.env, ...config.env },
143
+ });
144
+ const rl = createInterface({ input: child.stdout! });
145
+ return new StdioTransport(child, rl);
146
+ }
147
+
148
+ writeLine(json: string): void {
149
+ // stdin may be gone if the child died between the closed-check and here.
150
+ this.child.stdin?.write(json + '\n');
151
+ }
152
+
153
+ async close(): Promise<void> {
154
+ this.markClosed({ reason: 'closed by host' });
155
+ this.rl.close();
156
+ if (this.child && !this.child.killed) {
157
+ this.child.kill();
158
+ }
159
+ await new Promise<void>((resolve) => {
160
+ if (!this.child || this.child.exitCode !== null || this.child.killed) {
161
+ resolve();
162
+ } else {
163
+ this.child.once('exit', () => resolve());
164
+ }
165
+ });
166
+ }
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // WebSocket
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /** Timeout for the WebSocket to reach OPEN before we give up (ms). */
174
+ const WS_OPEN_TIMEOUT_MS = 15_000;
175
+
176
+ export class WebSocketTransport extends McplTransport {
177
+ readonly kind = 'websocket' as const;
178
+
179
+ private constructor(private readonly ws: WebSocket) {
180
+ super();
181
+
182
+ this.ws.on('message', (data: WebSocket.RawData, isBinary: boolean) => {
183
+ // MCPL frames are UTF-8 JSON text. A frame MAY contain multiple
184
+ // newline-delimited messages or a trailing newline; split defensively.
185
+ const text = isBinary ? (data as Buffer).toString('utf8') : String(data);
186
+ for (const line of text.split('\n')) {
187
+ const trimmed = line.trim();
188
+ if (trimmed.length > 0) this.pushLine(trimmed);
189
+ }
190
+ });
191
+
192
+ this.ws.on('error', (err: Error) => this.emit('error', err));
193
+ this.ws.on('close', (code: number, reason: Buffer) =>
194
+ this.markClosed({ code, reason: reason?.toString('utf8') || 'websocket closed' }),
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Dial `config.url` and resolve once the socket is OPEN. Appends `?token=`
200
+ * when `config.token` is set. Rejects on connect error / timeout (and tears
201
+ * the socket down) so a failed dial never leaks an open handle.
202
+ */
203
+ static open(config: McplServerConfig): Promise<WebSocketTransport> {
204
+ const url = buildWebSocketUrl(config);
205
+ const ws = new WebSocket(url);
206
+
207
+ return new Promise<WebSocketTransport>((resolve, reject) => {
208
+ let settled = false;
209
+ const timer = setTimeout(() => {
210
+ if (settled) return;
211
+ settled = true;
212
+ ws.removeAllListeners();
213
+ try { ws.terminate(); } catch { /* already gone */ }
214
+ reject(new Error(`MCPL server "${config.id}" WebSocket connect timed out after ${WS_OPEN_TIMEOUT_MS}ms (${url})`));
215
+ }, WS_OPEN_TIMEOUT_MS);
216
+
217
+ ws.once('open', () => {
218
+ if (settled) return;
219
+ settled = true;
220
+ clearTimeout(timer);
221
+ ws.removeAllListeners('error'); // the instance re-attaches its own handlers
222
+ resolve(new WebSocketTransport(ws));
223
+ });
224
+
225
+ ws.once('error', (err: Error) => {
226
+ if (settled) return;
227
+ settled = true;
228
+ clearTimeout(timer);
229
+ ws.removeAllListeners();
230
+ try { ws.terminate(); } catch { /* already gone */ }
231
+ reject(new Error(`MCPL server "${config.id}" WebSocket connect failed: ${err.message} (${url})`));
232
+ });
233
+ });
234
+ }
235
+
236
+ writeLine(json: string): void {
237
+ if (this.ws.readyState === WebSocket.OPEN) {
238
+ this.ws.send(json);
239
+ }
240
+ }
241
+
242
+ async close(): Promise<void> {
243
+ this.markClosed({ reason: 'closed by host' });
244
+ await new Promise<void>((resolve) => {
245
+ if (this.ws.readyState === WebSocket.CLOSED) {
246
+ resolve();
247
+ return;
248
+ }
249
+ this.ws.once('close', () => resolve());
250
+ try {
251
+ this.ws.close();
252
+ } catch {
253
+ try { this.ws.terminate(); } catch { /* already gone */ }
254
+ resolve();
255
+ }
256
+ });
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Build the dial URL: validate it's a ws(s):// URL and append the auth token as
262
+ * a `token` query param when provided (preserving any existing query).
263
+ */
264
+ export function buildWebSocketUrl(config: McplServerConfig): string {
265
+ if (!config.url) {
266
+ throw new Error(`MCPL server "${config.id}": websocket transport requires "url" (none provided)`);
267
+ }
268
+ let parsed: URL;
269
+ try {
270
+ parsed = new URL(config.url);
271
+ } catch {
272
+ throw new Error(`MCPL server "${config.id}": invalid WebSocket url "${config.url}"`);
273
+ }
274
+ if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') {
275
+ throw new Error(
276
+ `MCPL server "${config.id}": websocket url must be ws:// or wss://, got "${parsed.protocol}"`,
277
+ );
278
+ }
279
+ if (config.token) {
280
+ parsed.searchParams.set('token', config.token);
281
+ }
282
+ return parsed.toString();
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Factory
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** True when the config selects the WebSocket transport (explicit `transport`,
290
+ * or a `url` with no `command`). */
291
+ export function isWebSocketTransport(config: McplServerConfig): boolean {
292
+ if (config.transport === 'websocket') return true;
293
+ if (config.transport === 'stdio') return false;
294
+ return Boolean(config.url) && !config.command;
295
+ }
296
+
297
+ /**
298
+ * Open the transport selected by `config` (WebSocket when `transport` is
299
+ * 'websocket' or a bare `url` is given; stdio otherwise). The returned transport
300
+ * is connected at the byte level but has NOT performed the MCPL handshake.
301
+ */
302
+ export async function openTransport(config: McplServerConfig): Promise<McplTransport> {
303
+ if (isWebSocketTransport(config)) {
304
+ return WebSocketTransport.open(config);
305
+ }
306
+ return StdioTransport.spawn(config);
307
+ }
package/src/mcpl/types.ts CHANGED
@@ -165,8 +165,12 @@ export interface McplServerConfig {
165
165
  /** Unique server identifier */
166
166
  id: string;
167
167
 
168
- /** Command to spawn the server process (stdio transport) */
169
- command: string;
168
+ /**
169
+ * Command to spawn the server process (stdio transport). Mutually exclusive
170
+ * with `url`. Required for the stdio transport; omit it (and set `url`) for
171
+ * the WebSocket transport.
172
+ */
173
+ command?: string;
170
174
 
171
175
  /** Arguments for the command */
172
176
  args?: string[];
@@ -174,6 +178,26 @@ export interface McplServerConfig {
174
178
  /** Environment variables for the child process */
175
179
  env?: Record<string, string>;
176
180
 
181
+ /**
182
+ * WebSocket URL for the network transport (`ws://` or `wss://`). Mutually
183
+ * exclusive with `command`. When set (or `transport: 'websocket'`), the host
184
+ * dials this endpoint instead of spawning a process — newline-delimited
185
+ * JSON-RPC MCPL runs over the socket identically to stdio.
186
+ */
187
+ url?: string;
188
+
189
+ /**
190
+ * Transport selector. Defaults to `'websocket'` when a bare `url` is given
191
+ * (no `command`), `'stdio'` otherwise. Set explicitly to disambiguate.
192
+ */
193
+ transport?: 'stdio' | 'websocket';
194
+
195
+ /**
196
+ * Bearer token for WebSocket auth. Appended to `url` as a `token` query
197
+ * parameter (`?token=…`) on connect. Ignored by the stdio transport.
198
+ */
199
+ token?: string;
200
+
177
201
  /** Feature sets to enable on connect */
178
202
  enabledFeatureSets?: string[];
179
203
 
@@ -210,11 +234,29 @@ export interface McplServerConfig {
210
234
  reconnect?: boolean;
211
235
 
212
236
  /**
213
- * Interval between reconnection attempts in milliseconds.
237
+ * Base interval between reconnection attempts in milliseconds.
238
+ * The actual delay doubles with each consecutive failure (with ±25% jitter)
239
+ * up to `reconnectMaxIntervalMs`, and resets after a successful handshake.
214
240
  * Default: 5000 (5 seconds).
215
241
  */
216
242
  reconnectIntervalMs?: number;
217
243
 
244
+ /**
245
+ * Ceiling for the exponential reconnect backoff in milliseconds.
246
+ * Default: 300000 (5 minutes).
247
+ */
248
+ reconnectMaxIntervalMs?: number;
249
+
250
+ /**
251
+ * Per-request timeout in milliseconds for outbound JSON-RPC requests
252
+ * (tools/call, tools/list, channels/*, hooks). A live-but-stuck server that
253
+ * accepts a request and never responds would otherwise freeze the awaiting
254
+ * agent turn forever; with the timeout the pending request rejects with a
255
+ * descriptive error, which the framework surfaces as a normal isError
256
+ * tool_result. Set 0 to disable. Default: 60000 (60 seconds).
257
+ */
258
+ requestTimeoutMs?: number;
259
+
218
260
  /**
219
261
  * Optional callback to filter which incoming channel messages should trigger
220
262
  * agent inference. Receives the text content and message metadata.
@@ -291,6 +333,16 @@ export interface FeatureSetDeclaration {
291
333
 
292
334
  /** Whether host manages state persistence for this feature set (Section 8.1) */
293
335
  hostState?: boolean;
336
+
337
+ /** Optional, open-world tag ontology for this feature set's events
338
+ * (MCPL RFC-001 §5). A hint catalog — hosts tolerate undeclared tags. */
339
+ tagOntology?: {
340
+ coreTags?: string[];
341
+ tags?: Record<string, unknown>;
342
+ keyed?: Record<string, unknown>;
343
+ defaultTreatment?: unknown[];
344
+ open?: boolean;
345
+ };
294
346
  }
295
347
 
296
348
  /**
@@ -390,6 +442,13 @@ export interface StateCheckpoint {
390
442
  /** Parent checkpoint (null for root) */
391
443
  parent: string | null;
392
444
 
445
+ /**
446
+ * Owning feature set, when the server tags it. Authoritative signal for
447
+ * attributing this checkpoint — lets the host record it to the right set
448
+ * instead of guessing when a server has more than one stateful feature set.
449
+ */
450
+ featureSet?: string;
451
+
393
452
  /**
394
453
  * Full state data (for host-managed state).
395
454
  * Mutually exclusive with `patch`.
@@ -461,6 +520,9 @@ export interface PushEventParams {
461
520
  /** Provenance metadata (server-defined) */
462
521
  origin?: Record<string, unknown>;
463
522
 
523
+ /** Semantic classification the host may route on (MCPL RFC-001). */
524
+ tags?: string[];
525
+
464
526
  /** Event payload */
465
527
  payload: {
466
528
  /** Content for the model to interpret */
@@ -897,6 +959,9 @@ export interface ChannelIncomingMessage {
897
959
 
898
960
  /** Platform-specific metadata */
899
961
  metadata?: Record<string, unknown>;
962
+
963
+ /** Semantic classification the host may route on (MCPL RFC-001). */
964
+ tags?: string[];
900
965
  }
901
966
 
902
967
  /**
@@ -27,6 +27,25 @@ import type { Agent } from './agent.js';
27
27
 
28
28
  const MODULE_STATE_PREFIX = 'modules/';
29
29
 
30
+ /**
31
+ * True when a registerState failure means "this state id already exists" —
32
+ * the one failure that is safe to swallow on an idempotent re-registration.
33
+ * Anything else (closed store, IO error) must propagate: swallowing it defers
34
+ * the real error to a later write with far worse context.
35
+ */
36
+ export function isStateExistsError(err: unknown): boolean {
37
+ return err instanceof Error && err.message.includes('State already exists');
38
+ }
39
+
40
+ /**
41
+ * Hard ceiling for a single module's gatherContext budget, regardless of the
42
+ * `contextTimeoutMs` it declares. gatherContext runs on the critical path of
43
+ * every inference turn, so an unbounded self-declared budget would let one
44
+ * wedged module stall every agent. 30s is generous for legitimate work (e.g. a
45
+ * retrieval module's sequential LLM calls) while keeping the worst case bounded.
46
+ */
47
+ const MAX_CONTEXT_TIMEOUT_MS = 30_000;
48
+
30
49
  /**
31
50
  * Registered speech handler.
32
51
  */
@@ -55,6 +74,7 @@ export class ModuleRegistry {
55
74
 
56
75
  private pushEventFn: (event: ProcessEvent) => void;
57
76
  private onTraceFn: (listener: TraceEventListener) => () => void;
77
+ private callToolFn: (call: ToolCall) => Promise<ToolResult>;
58
78
 
59
79
  constructor(
60
80
  store: JsStore,
@@ -68,6 +88,7 @@ export class ModuleRegistry {
68
88
  queryMessages: (filter: MessageQuery) => MessageQueryResult;
69
89
  pushEvent: (event: ProcessEvent) => void;
70
90
  onTrace: (listener: TraceEventListener) => () => void;
91
+ callTool: (call: ToolCall) => Promise<ToolResult>;
71
92
  }
72
93
  ) {
73
94
  this.store = store;
@@ -80,6 +101,7 @@ export class ModuleRegistry {
80
101
  this.queryMessagesFn = options.queryMessages;
81
102
  this.pushEventFn = options.pushEvent;
82
103
  this.onTraceFn = options.onTrace;
104
+ this.callToolFn = options.callTool;
83
105
  }
84
106
 
85
107
  /**
@@ -120,7 +142,8 @@ export class ModuleRegistry {
120
142
  this.getMessageFn,
121
143
  this.queryMessagesFn,
122
144
  this.pushEventFn,
123
- this.onTraceFn
145
+ this.onTraceFn,
146
+ this.callToolFn
124
147
  );
125
148
 
126
149
  this.modules.set(module.name, module);
@@ -298,20 +321,44 @@ export class ModuleRegistry {
298
321
  /**
299
322
  * Gather context injections from all modules before inference.
300
323
  * Calls each module's gatherContext() in parallel with a per-module timeout.
301
- * Fail-open: timed-out or erroring modules are skipped silently.
324
+ * Fail-open: timed-out or erroring modules are skipped (with a stderr line).
325
+ *
326
+ * The per-module budget is `module.contextTimeoutMs` when declared, else the
327
+ * `timeoutMs` default (15s). Modules whose gatherContext does real work —
328
+ * e.g. a retrieval module making sequential LLM calls with backoff — should
329
+ * declare a larger `contextTimeoutMs` rather than silently losing their
330
+ * injection every turn to the shared default. The declared value is clamped
331
+ * to MAX_CONTEXT_TIMEOUT_MS so no single module can hold every turn hostage.
302
332
  * Adapted from Anarchid/agent-framework@mcpl-module-proto.
303
333
  */
304
- async gatherContext(agentName: string, timeoutMs = 5000): Promise<ContextInjection[]> {
334
+ async gatherContext(agentName: string, timeoutMs = 15_000): Promise<ContextInjection[]> {
305
335
  const injections: ContextInjection[] = [];
306
336
  const promises: Promise<void>[] = [];
307
337
 
308
338
  for (const module of this.modules.values()) {
309
339
  if (!module.gatherContext) continue;
310
340
 
341
+ // Clamp the per-module budget. A module declaring `contextTimeoutMs`
342
+ // legitimately needs more than the shared default (e.g. a retrieval
343
+ // module doing sequential LLM calls), but it must not be able to hold
344
+ // EVERY inference turn of EVERY agent hostage: the fail-open contract is
345
+ // "a wedged module never blocks inference for long", so cap the wait.
346
+ const budgetMs = Math.min(
347
+ module.contextTimeoutMs ?? timeoutMs,
348
+ MAX_CONTEXT_TIMEOUT_MS,
349
+ );
350
+ let timer: ReturnType<typeof setTimeout> | undefined;
311
351
  const promise = Promise.race([
312
352
  module.gatherContext(agentName).then(r => injections.push(...r)),
313
- new Promise<void>((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs)),
314
- ]).catch(err => console.error(`[${module.name}] gatherContext:`, err));
353
+ new Promise<void>((_, rej) => {
354
+ timer = setTimeout(
355
+ () => rej(new Error(`gatherContext timed out after ${budgetMs}ms`)),
356
+ budgetMs,
357
+ );
358
+ }),
359
+ ])
360
+ .catch(err => console.error(`[${module.name}] gatherContext:`, err))
361
+ .finally(() => clearTimeout(timer));
315
362
 
316
363
  promises.push(promise as Promise<void>);
317
364
  }
@@ -372,6 +419,7 @@ class ModuleContextImpl implements ModuleContext {
372
419
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
373
420
  private pushEventFn: (event: ProcessEvent) => void;
374
421
  private onTraceFn: (listener: TraceEventListener) => () => void;
422
+ private callToolFn: (call: ToolCall) => Promise<ToolResult>;
375
423
 
376
424
  // External ID mapping stored in module state
377
425
  private externalIdMap: Map<string, MessageId> = new Map();
@@ -390,7 +438,8 @@ class ModuleContextImpl implements ModuleContext {
390
438
  getMessage: (id: MessageId) => StoredMessage | null,
391
439
  queryMessages: (filter: MessageQuery) => MessageQueryResult,
392
440
  pushEvent: (event: ProcessEvent) => void,
393
- onTrace: (listener: TraceEventListener) => () => void
441
+ onTrace: (listener: TraceEventListener) => () => void,
442
+ callTool: (call: ToolCall) => Promise<ToolResult>
394
443
  ) {
395
444
  this.moduleName = moduleName;
396
445
  this.store = store;
@@ -406,6 +455,7 @@ class ModuleContextImpl implements ModuleContext {
406
455
  this.queryMessagesFn = queryMessages;
407
456
  this.pushEventFn = pushEvent;
408
457
  this.onTraceFn = onTrace;
458
+ this.callToolFn = callTool;
409
459
 
410
460
  // Load external ID map from state if exists
411
461
  const state = this.getState<{ externalIdMap?: Record<string, string> }>();
@@ -428,6 +478,72 @@ class ModuleContextImpl implements ModuleContext {
428
478
  this.store.setStateJson(this.stateId, fullState);
429
479
  }
430
480
 
481
+ /** Log-state names this context has registered (registerLogState is
482
+ * contract-required in start(), so this is populated before any use). */
483
+ private registeredLogStates = new Set<string>();
484
+
485
+ private logStateId(name: string): string {
486
+ if (!name || name === 'state' || name.includes('/')) {
487
+ throw new Error(
488
+ `Invalid module log state name '${name}': must be non-empty, not 'state', and contain no '/'`
489
+ );
490
+ }
491
+ return `${MODULE_STATE_PREFIX}${this.moduleName}/${name}`;
492
+ }
493
+
494
+ /**
495
+ * Like logStateId, but only for names that went through registerLogState.
496
+ * Chronicle happily appends to an unregistered state — it just never gets a
497
+ * snapshot strategy, so every reconstruction replays the full op chain from
498
+ * record one. A forgotten registration or a typo'd name in one call site
499
+ * must be an immediate, attributable error, not a silent O(n²)-read trap.
500
+ */
501
+ private registeredLogStateId(name: string): string {
502
+ if (!this.registeredLogStates.has(name)) {
503
+ throw new Error(
504
+ `Module log state '${name}' is not registered for module '${this.moduleName}' — ` +
505
+ `call registerLogState('${name}') in start() before using it`
506
+ );
507
+ }
508
+ return this.logStateId(name);
509
+ }
510
+
511
+ registerLogState(
512
+ name: string,
513
+ opts?: { deltaSnapshotEvery?: number; fullSnapshotEvery?: number }
514
+ ): void {
515
+ const id = this.logStateId(name);
516
+ try {
517
+ this.store.registerState({
518
+ id,
519
+ strategy: 'append_log',
520
+ deltaSnapshotEvery: opts?.deltaSnapshotEvery ?? 100,
521
+ fullSnapshotEvery: opts?.fullSnapshotEvery ?? 20,
522
+ });
523
+ } catch (err) {
524
+ if (!isStateExistsError(err)) throw err;
525
+ // Already registered (previous run) — idempotent path.
526
+ }
527
+ this.registeredLogStates.add(name);
528
+ }
529
+
530
+ appendToLog<T>(name: string, item: T): void {
531
+ this.store.appendToStateJson(this.registeredLogStateId(name), item);
532
+ }
533
+
534
+ editLogItem<T>(name: string, index: number, item: T): void {
535
+ this.store.editStateItem(this.registeredLogStateId(name), index, Buffer.from(JSON.stringify(item)));
536
+ }
537
+
538
+ getLog<T>(name: string): T[] {
539
+ const data = this.store.getStateJson(this.registeredLogStateId(name));
540
+ return Array.isArray(data) ? (data as T[]) : [];
541
+ }
542
+
543
+ getLogLength(name: string): number {
544
+ return this.store.getStateLen(this.registeredLogStateId(name)) ?? 0;
545
+ }
546
+
431
547
  getModule<T extends Module>(name: string): T | null {
432
548
  return this.registry.getModule<T>(name);
433
549
  }
@@ -436,6 +552,10 @@ class ModuleContextImpl implements ModuleContext {
436
552
  return this.onTraceFn(listener);
437
553
  }
438
554
 
555
+ callTool(call: ToolCall): Promise<ToolResult> {
556
+ return this.callToolFn(call);
557
+ }
558
+
439
559
  addMessage(
440
560
  participant: string,
441
561
  content: ContentBlock[],
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Split an assistant turn's content blocks into ordered prose segments.
3
+ *
4
+ * A single turn can interleave prose and tool calls — "msgA → [tool] → msgB →
5
+ * [tool] → msgC". The membrane accumulates the WHOLE turn (all tool rounds) into
6
+ * one `response.content` array in provider order, so a left-to-right walk that
7
+ * breaks at each tool boundary reconstructs the emission order.
8
+ *
9
+ * Host output routing used to join every text block into a single trailing post,
10
+ * collapsing those distinct messages into one (item 4). This helper instead
11
+ * yields each contiguous run of text — the segments a surface should deliver as
12
+ * separate, ordered messages. Contiguous text blocks merge into one segment;
13
+ * `tool_use` / `tool_result` blocks are segment boundaries; empty or
14
+ * whitespace-only runs are dropped.
15
+ */
16
+
17
+ import type { ContentBlock } from '@animalabs/membrane';
18
+
19
+ export function splitProseSegments(content: readonly ContentBlock[]): string[] {
20
+ const segments: string[] = [];
21
+ let buf: string[] = [];
22
+
23
+ const flush = (): void => {
24
+ const s = buf.join('\n').trim();
25
+ if (s) segments.push(s);
26
+ buf = [];
27
+ };
28
+
29
+ for (const block of content) {
30
+ if (block.type === 'text') {
31
+ buf.push((block as ContentBlock & { type: 'text' }).text);
32
+ } else if (block.type === 'tool_use' || block.type === 'tool_result') {
33
+ flush();
34
+ }
35
+ // Other block types (thinking, redacted_thinking, image, …) are neither
36
+ // prose nor boundaries: they don't reach a channel and don't separate two
37
+ // prose messages, so they're skipped without flushing.
38
+ }
39
+ flush();
40
+
41
+ return segments;
42
+ }