@animalabs/agent-framework 0.5.7 → 0.6.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.
Files changed (116) hide show
  1. package/dist/src/framework.d.ts +67 -0
  2. package/dist/src/framework.d.ts.map +1 -1
  3. package/dist/src/framework.js +469 -26
  4. package/dist/src/framework.js.map +1 -1
  5. package/dist/src/gate/event-gate.d.ts +44 -1
  6. package/dist/src/gate/event-gate.d.ts.map +1 -1
  7. package/dist/src/gate/event-gate.js +152 -8
  8. package/dist/src/gate/event-gate.js.map +1 -1
  9. package/dist/src/gate/gate-script-worker.d.ts +2 -0
  10. package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
  11. package/dist/src/gate/gate-script-worker.js +69 -0
  12. package/dist/src/gate/gate-script-worker.js.map +1 -0
  13. package/dist/src/gate/gate-script.d.ts +44 -0
  14. package/dist/src/gate/gate-script.d.ts.map +1 -0
  15. package/dist/src/gate/gate-script.js +164 -0
  16. package/dist/src/gate/gate-script.js.map +1 -0
  17. package/dist/src/gate/types.d.ts +18 -3
  18. package/dist/src/gate/types.d.ts.map +1 -1
  19. package/dist/src/mcpl/channel-registry.d.ts +43 -1
  20. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  21. package/dist/src/mcpl/channel-registry.js +82 -8
  22. package/dist/src/mcpl/channel-registry.js.map +1 -1
  23. package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
  24. package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
  25. package/dist/src/mcpl/checkpoint-manager.js +15 -0
  26. package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
  27. package/dist/src/mcpl/push-handler.d.ts.map +1 -1
  28. package/dist/src/mcpl/push-handler.js +1 -0
  29. package/dist/src/mcpl/push-handler.js.map +1 -1
  30. package/dist/src/mcpl/server-connection.d.ts +35 -30
  31. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  32. package/dist/src/mcpl/server-connection.js +145 -206
  33. package/dist/src/mcpl/server-connection.js.map +1 -1
  34. package/dist/src/mcpl/transport.d.ts +98 -0
  35. package/dist/src/mcpl/transport.d.ts.map +1 -0
  36. package/dist/src/mcpl/transport.js +281 -0
  37. package/dist/src/mcpl/transport.js.map +1 -0
  38. package/dist/src/mcpl/types.d.ts +42 -2
  39. package/dist/src/mcpl/types.d.ts.map +1 -1
  40. package/dist/src/mcpl/types.js.map +1 -1
  41. package/dist/src/module-registry.d.ts +2 -0
  42. package/dist/src/module-registry.d.ts.map +1 -1
  43. package/dist/src/module-registry.js +9 -2
  44. package/dist/src/module-registry.js.map +1 -1
  45. package/dist/src/prose-segments.d.ts +18 -0
  46. package/dist/src/prose-segments.d.ts.map +1 -0
  47. package/dist/src/prose-segments.js +39 -0
  48. package/dist/src/prose-segments.js.map +1 -0
  49. package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
  50. package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
  51. package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
  52. package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
  53. package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
  54. package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
  55. package/dist/src/runtime/liveness-watchdog.js +106 -0
  56. package/dist/src/runtime/liveness-watchdog.js.map +1 -0
  57. package/dist/src/types/agent.d.ts +10 -0
  58. package/dist/src/types/agent.d.ts.map +1 -1
  59. package/dist/src/types/framework.d.ts +14 -0
  60. package/dist/src/types/framework.d.ts.map +1 -1
  61. package/dist/src/types/module.d.ts +12 -0
  62. package/dist/src/types/module.d.ts.map +1 -1
  63. package/dist/test/channel-registry-routing.test.js +108 -1
  64. package/dist/test/channel-registry-routing.test.js.map +1 -1
  65. package/dist/test/checkpoint-attribution.test.d.ts +2 -0
  66. package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
  67. package/dist/test/checkpoint-attribution.test.js +40 -0
  68. package/dist/test/checkpoint-attribution.test.js.map +1 -0
  69. package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
  70. package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
  71. package/dist/test/event-gate-policy-mutation.test.js +137 -0
  72. package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
  73. package/dist/test/event-gate-script.test.d.ts +2 -0
  74. package/dist/test/event-gate-script.test.d.ts.map +1 -0
  75. package/dist/test/event-gate-script.test.js +95 -0
  76. package/dist/test/event-gate-script.test.js.map +1 -0
  77. package/dist/test/event-gate-tags.test.d.ts +2 -0
  78. package/dist/test/event-gate-tags.test.d.ts.map +1 -0
  79. package/dist/test/event-gate-tags.test.js +76 -0
  80. package/dist/test/event-gate-tags.test.js.map +1 -0
  81. package/dist/test/liveness-watchdog.test.d.ts +2 -0
  82. package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
  83. package/dist/test/liveness-watchdog.test.js +78 -0
  84. package/dist/test/liveness-watchdog.test.js.map +1 -0
  85. package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
  86. package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
  87. package/dist/test/mcpl-ws-transport.test.js +155 -0
  88. package/dist/test/mcpl-ws-transport.test.js.map +1 -0
  89. package/dist/test/prose-segments.test.d.ts +2 -0
  90. package/dist/test/prose-segments.test.d.ts.map +1 -0
  91. package/dist/test/prose-segments.test.js +52 -0
  92. package/dist/test/prose-segments.test.js.map +1 -0
  93. package/dist/test/trunk-channel-routing.test.d.ts +19 -0
  94. package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
  95. package/dist/test/trunk-channel-routing.test.js +175 -0
  96. package/dist/test/trunk-channel-routing.test.js.map +1 -0
  97. package/dist/tsconfig.tsbuildinfo +1 -1
  98. package/package.json +2 -2
  99. package/src/framework.ts +509 -24
  100. package/src/gate/event-gate.ts +153 -8
  101. package/src/gate/gate-script-worker.ts +73 -0
  102. package/src/gate/gate-script.ts +180 -0
  103. package/src/gate/types.ts +19 -3
  104. package/src/mcpl/channel-registry.ts +112 -8
  105. package/src/mcpl/checkpoint-manager.ts +16 -0
  106. package/src/mcpl/push-handler.ts +1 -0
  107. package/src/mcpl/server-connection.ts +153 -224
  108. package/src/mcpl/transport.ts +307 -0
  109. package/src/mcpl/types.ts +49 -2
  110. package/src/module-registry.ts +13 -2
  111. package/src/prose-segments.ts +42 -0
  112. package/src/runtime/liveness-watchdog-worker.ts +66 -0
  113. package/src/runtime/liveness-watchdog.ts +122 -0
  114. package/src/types/agent.ts +10 -0
  115. package/src/types/framework.ts +15 -0
  116. package/src/types/module.ts +13 -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
 
@@ -291,6 +315,16 @@ export interface FeatureSetDeclaration {
291
315
 
292
316
  /** Whether host manages state persistence for this feature set (Section 8.1) */
293
317
  hostState?: boolean;
318
+
319
+ /** Optional, open-world tag ontology for this feature set's events
320
+ * (MCPL RFC-001 §5). A hint catalog — hosts tolerate undeclared tags. */
321
+ tagOntology?: {
322
+ coreTags?: string[];
323
+ tags?: Record<string, unknown>;
324
+ keyed?: Record<string, unknown>;
325
+ defaultTreatment?: unknown[];
326
+ open?: boolean;
327
+ };
294
328
  }
295
329
 
296
330
  /**
@@ -390,6 +424,13 @@ export interface StateCheckpoint {
390
424
  /** Parent checkpoint (null for root) */
391
425
  parent: string | null;
392
426
 
427
+ /**
428
+ * Owning feature set, when the server tags it. Authoritative signal for
429
+ * attributing this checkpoint — lets the host record it to the right set
430
+ * instead of guessing when a server has more than one stateful feature set.
431
+ */
432
+ featureSet?: string;
433
+
393
434
  /**
394
435
  * Full state data (for host-managed state).
395
436
  * Mutually exclusive with `patch`.
@@ -461,6 +502,9 @@ export interface PushEventParams {
461
502
  /** Provenance metadata (server-defined) */
462
503
  origin?: Record<string, unknown>;
463
504
 
505
+ /** Semantic classification the host may route on (MCPL RFC-001). */
506
+ tags?: string[];
507
+
464
508
  /** Event payload */
465
509
  payload: {
466
510
  /** Content for the model to interpret */
@@ -897,6 +941,9 @@ export interface ChannelIncomingMessage {
897
941
 
898
942
  /** Platform-specific metadata */
899
943
  metadata?: Record<string, unknown>;
944
+
945
+ /** Semantic classification the host may route on (MCPL RFC-001). */
946
+ tags?: string[];
900
947
  }
901
948
 
902
949
  /**
@@ -55,6 +55,7 @@ export class ModuleRegistry {
55
55
 
56
56
  private pushEventFn: (event: ProcessEvent) => void;
57
57
  private onTraceFn: (listener: TraceEventListener) => () => void;
58
+ private callToolFn: (call: ToolCall) => Promise<ToolResult>;
58
59
 
59
60
  constructor(
60
61
  store: JsStore,
@@ -68,6 +69,7 @@ export class ModuleRegistry {
68
69
  queryMessages: (filter: MessageQuery) => MessageQueryResult;
69
70
  pushEvent: (event: ProcessEvent) => void;
70
71
  onTrace: (listener: TraceEventListener) => () => void;
72
+ callTool: (call: ToolCall) => Promise<ToolResult>;
71
73
  }
72
74
  ) {
73
75
  this.store = store;
@@ -80,6 +82,7 @@ export class ModuleRegistry {
80
82
  this.queryMessagesFn = options.queryMessages;
81
83
  this.pushEventFn = options.pushEvent;
82
84
  this.onTraceFn = options.onTrace;
85
+ this.callToolFn = options.callTool;
83
86
  }
84
87
 
85
88
  /**
@@ -120,7 +123,8 @@ export class ModuleRegistry {
120
123
  this.getMessageFn,
121
124
  this.queryMessagesFn,
122
125
  this.pushEventFn,
123
- this.onTraceFn
126
+ this.onTraceFn,
127
+ this.callToolFn
124
128
  );
125
129
 
126
130
  this.modules.set(module.name, module);
@@ -372,6 +376,7 @@ class ModuleContextImpl implements ModuleContext {
372
376
  private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
373
377
  private pushEventFn: (event: ProcessEvent) => void;
374
378
  private onTraceFn: (listener: TraceEventListener) => () => void;
379
+ private callToolFn: (call: ToolCall) => Promise<ToolResult>;
375
380
 
376
381
  // External ID mapping stored in module state
377
382
  private externalIdMap: Map<string, MessageId> = new Map();
@@ -390,7 +395,8 @@ class ModuleContextImpl implements ModuleContext {
390
395
  getMessage: (id: MessageId) => StoredMessage | null,
391
396
  queryMessages: (filter: MessageQuery) => MessageQueryResult,
392
397
  pushEvent: (event: ProcessEvent) => void,
393
- onTrace: (listener: TraceEventListener) => () => void
398
+ onTrace: (listener: TraceEventListener) => () => void,
399
+ callTool: (call: ToolCall) => Promise<ToolResult>
394
400
  ) {
395
401
  this.moduleName = moduleName;
396
402
  this.store = store;
@@ -406,6 +412,7 @@ class ModuleContextImpl implements ModuleContext {
406
412
  this.queryMessagesFn = queryMessages;
407
413
  this.pushEventFn = pushEvent;
408
414
  this.onTraceFn = onTrace;
415
+ this.callToolFn = callTool;
409
416
 
410
417
  // Load external ID map from state if exists
411
418
  const state = this.getState<{ externalIdMap?: Record<string, string> }>();
@@ -436,6 +443,10 @@ class ModuleContextImpl implements ModuleContext {
436
443
  return this.onTraceFn(listener);
437
444
  }
438
445
 
446
+ callTool(call: ToolCall): Promise<ToolResult> {
447
+ return this.callToolFn(call);
448
+ }
449
+
439
450
  addMessage(
440
451
  participant: string,
441
452
  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
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Liveness watchdog worker — runs on its own thread, so it keeps ticking even
3
+ * when the main thread is wedged. Watches the shared heartbeat; if it goes stale
4
+ * past the threshold, writes a diagnostic and kills the whole process.
5
+ *
6
+ * Killing from a worker: process.kill(process.pid, signal) signals the whole
7
+ * process. SIGABRT → core dump (if enabled) + terminate; SIGKILL → immediate.
8
+ * Catchable signals (SIGTERM) are avoided — their handler would run on the
9
+ * wedged main thread and never fire.
10
+ */
11
+ import { workerData } from 'node:worker_threads';
12
+ import { writeFileSync } from 'node:fs';
13
+
14
+ interface Data {
15
+ sab: SharedArrayBuffer;
16
+ thresholdMs: number;
17
+ checkMs: number;
18
+ action: 'abort' | 'exit';
19
+ reportPath: string | null;
20
+ }
21
+ const { sab, thresholdMs, checkMs, action, reportPath } = workerData as Data;
22
+
23
+ const hb = new Float64Array(sab, 0, 1);
24
+ const phaseBuf = new Uint8Array(sab, 16, 48);
25
+ const dec = new TextDecoder();
26
+
27
+ function currentPhase(): string {
28
+ return dec.decode(phaseBuf).replace(/\0+$/, '') || 'unknown';
29
+ }
30
+
31
+ function check(): void {
32
+ const last = hb[0];
33
+ const age = Date.now() - last;
34
+ if (last > 0 && age > thresholdMs) {
35
+ const phase = currentPhase();
36
+ const report = {
37
+ ts: new Date().toISOString(),
38
+ event: 'main-thread-wedged',
39
+ ageMs: Math.round(age),
40
+ thresholdMs,
41
+ phase,
42
+ action,
43
+ pid: process.pid,
44
+ };
45
+ const line = `[liveness-watchdog] main thread unresponsive ${Math.round(age)}ms ` +
46
+ `(phase=${phase}) — failing hard via ${action}`;
47
+ try {
48
+ console.error(line);
49
+ } catch {
50
+ /* ignore */
51
+ }
52
+ if (reportPath) {
53
+ try {
54
+ writeFileSync(reportPath, JSON.stringify(report) + '\n', { flag: 'a' });
55
+ } catch {
56
+ /* best effort */
57
+ }
58
+ }
59
+ // Kill the whole process. The supervisor restarts a fresh one.
60
+ process.kill(process.pid, action === 'abort' ? 'SIGABRT' : 'SIGKILL');
61
+ return; // process is dying
62
+ }
63
+ setTimeout(check, checkMs);
64
+ }
65
+
66
+ setTimeout(check, checkMs);
@@ -0,0 +1,122 @@
1
+ /**
2
+ * LivenessWatchdog — detects a wedged (unresponsive) main thread and fails hard.
3
+ *
4
+ * The agent's event loop is single-threaded. A synchronous infinite loop or a
5
+ * self-rescheduling microtask flood (e.g. a pathological compression drain) can
6
+ * peg the thread for minutes: no events, no heartbeat, no recovery — the agent
7
+ * goes silently deaf. A same-thread timer can't catch this (it can't fire while
8
+ * the loop is blocked), so detection must live off-thread.
9
+ *
10
+ * Mechanism: the main thread stamps a heartbeat (and an optional phase label)
11
+ * into shared memory on a timer. A worker thread — which keeps running even when
12
+ * main is wedged — watches the heartbeat; if it goes stale past a threshold it
13
+ * writes a diagnostic and kills the whole process:
14
+ * - 'abort' → SIGABRT (core dump if enabled, for an offline stack) then dies
15
+ * - 'exit' → SIGKILL (clean, quiet; rely on the supervisor to restart)
16
+ * Either way the supervisor (launchd/systemd) restarts a fresh process instead
17
+ * of leaving a deaf one running.
18
+ */
19
+ import { Worker } from 'node:worker_threads';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { dirname, join } from 'node:path';
22
+ import { phaseChannel } from '@animalabs/context-manager';
23
+
24
+ const WORKER_PATH = join(dirname(fileURLToPath(import.meta.url)), 'liveness-watchdog-worker.js');
25
+
26
+ const HEARTBEAT_OFFSET = 0; // Float64: last heartbeat (ms since epoch)
27
+ const PHASE_OFFSET = 16; // Uint8[48]: current phase label (UTF-8)
28
+ const PHASE_LEN = 48;
29
+ const SAB_SIZE = 64;
30
+
31
+ export interface WatchdogOptions {
32
+ /** Wedge threshold: kill if no heartbeat for this long. Default 30000ms. */
33
+ thresholdMs?: number;
34
+ /** Heartbeat cadence. Default 1000ms. */
35
+ heartbeatMs?: number;
36
+ /** 'abort' → SIGABRT (core dump for debugging); 'exit' → SIGKILL. Default 'abort'. */
37
+ action?: 'abort' | 'exit';
38
+ /** Optional file the worker writes a JSON wedge report to before killing. */
39
+ reportPath?: string;
40
+ /** Disable entirely. */
41
+ enabled?: boolean;
42
+ }
43
+
44
+ export class LivenessWatchdog {
45
+ private readonly sab = new SharedArrayBuffer(SAB_SIZE);
46
+ private readonly hb = new Float64Array(this.sab, HEARTBEAT_OFFSET, 1);
47
+ private readonly phaseBuf = new Uint8Array(this.sab, PHASE_OFFSET, PHASE_LEN);
48
+ private readonly enc = new TextEncoder();
49
+ private timer?: ReturnType<typeof setInterval>;
50
+ private worker?: Worker;
51
+ private started = false;
52
+
53
+ constructor(private readonly opts: WatchdogOptions = {}) {}
54
+
55
+ start(): void {
56
+ if (this.started || this.opts.enabled === false) return;
57
+ this.started = true;
58
+ const heartbeatMs = this.opts.heartbeatMs ?? 1000;
59
+ this.setPhase('starting');
60
+ this.beat();
61
+ this.timer = setInterval(() => this.beat(), heartbeatMs);
62
+ this.timer.unref?.();
63
+ try {
64
+ this.worker = new Worker(WORKER_PATH, {
65
+ workerData: {
66
+ sab: this.sab,
67
+ thresholdMs: this.opts.thresholdMs ?? 30_000,
68
+ checkMs: Math.min(heartbeatMs, 2000),
69
+ action: this.opts.action ?? 'abort',
70
+ reportPath: this.opts.reportPath ?? null,
71
+ },
72
+ });
73
+ this.worker.unref();
74
+ this.worker.on('error', (e) => console.error('[liveness-watchdog] worker error:', e.message));
75
+ // Wire the phase channel so labelled synchronous spans (compression,
76
+ // context-build, merge-graph walks) name themselves in a wedge report.
77
+ phaseChannel.report = (label: string) => this.setPhase(label);
78
+ console.error(
79
+ `[liveness-watchdog] armed: threshold=${this.opts.thresholdMs ?? 30_000}ms ` +
80
+ `action=${this.opts.action ?? 'abort'}`,
81
+ );
82
+ } catch (e) {
83
+ console.error('[liveness-watchdog] failed to start:', (e as Error).message);
84
+ }
85
+ this.setPhase('idle');
86
+ }
87
+
88
+ /** Stamp liveness. Called on a timer; the worker watches for staleness. */
89
+ private beat(): void {
90
+ this.hb[0] = Date.now();
91
+ }
92
+
93
+ /**
94
+ * Label the operation the main thread is about to run synchronously, so a
95
+ * wedge report names it (e.g. 'compression-tick', 'context-build'). Keep it
96
+ * short (truncated to 48 bytes). Reset to 'idle' when done.
97
+ */
98
+ setPhase(label: string): void {
99
+ this.phaseBuf.fill(0);
100
+ const bytes = this.enc.encode(label);
101
+ this.phaseBuf.set(bytes.subarray(0, PHASE_LEN));
102
+ }
103
+
104
+ /** Run a synchronous section under a named phase, restoring the prior phase. */
105
+ withPhase<T>(label: string, fn: () => T): T {
106
+ this.setPhase(label);
107
+ try {
108
+ return fn();
109
+ } finally {
110
+ this.setPhase('idle');
111
+ }
112
+ }
113
+
114
+ stop(): void {
115
+ if (this.timer) clearInterval(this.timer);
116
+ this.timer = undefined;
117
+ phaseChannel.report = () => {};
118
+ void this.worker?.terminate();
119
+ this.worker = undefined;
120
+ this.started = false;
121
+ }
122
+ }