@animalabs/agent-framework 0.5.6 → 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.
- package/dist/src/agent.d.ts +20 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +45 -14
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +220 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1489 -69
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +71 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +292 -9
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/gate-script-worker.d.ts +2 -0
- package/dist/src/gate/gate-script-worker.d.ts.map +1 -0
- package/dist/src/gate/gate-script-worker.js +69 -0
- package/dist/src/gate/gate-script-worker.js.map +1 -0
- package/dist/src/gate/gate-script.d.ts +44 -0
- package/dist/src/gate/gate-script.d.ts.map +1 -0
- package/dist/src/gate/gate-script.js +164 -0
- package/dist/src/gate/gate-script.js.map +1 -0
- package/dist/src/gate/types.d.ts +35 -3
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +93 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +260 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.d.ts +7 -0
- package/dist/src/mcpl/checkpoint-manager.d.ts.map +1 -1
- package/dist/src/mcpl/checkpoint-manager.js +15 -0
- package/dist/src/mcpl/checkpoint-manager.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +161 -0
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
- package/dist/src/mcpl/conversation-router.js +179 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +10 -1
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +2 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +35 -30
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +149 -206
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/transport.d.ts +98 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -0
- package/dist/src/mcpl/transport.js +281 -0
- package/dist/src/mcpl/transport.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +42 -2
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +5 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +16 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +10 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +83 -0
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/prose-segments.d.ts +18 -0
- package/dist/src/prose-segments.d.ts.map +1 -0
- package/dist/src/prose-segments.js +39 -0
- package/dist/src/prose-segments.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts +2 -0
- package/dist/src/runtime/liveness-watchdog-worker.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog-worker.js +57 -0
- package/dist/src/runtime/liveness-watchdog-worker.js.map +1 -0
- package/dist/src/runtime/liveness-watchdog.d.ts +36 -0
- package/dist/src/runtime/liveness-watchdog.d.ts.map +1 -0
- package/dist/src/runtime/liveness-watchdog.js +106 -0
- package/dist/src/runtime/liveness-watchdog.js.map +1 -0
- package/dist/src/tool-result-history.d.ts +17 -0
- package/dist/src/tool-result-history.d.ts.map +1 -0
- package/dist/src/tool-result-history.js +60 -0
- package/dist/src/tool-result-history.js.map +1 -0
- package/dist/src/types/agent.d.ts +21 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +22 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +27 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +28 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.d.ts +2 -0
- package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-routing.test.js +182 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -0
- package/dist/test/checkpoint-attribution.test.d.ts +2 -0
- package/dist/test/checkpoint-attribution.test.d.ts.map +1 -0
- package/dist/test/checkpoint-attribution.test.js +40 -0
- package/dist/test/checkpoint-attribution.test.js.map +1 -0
- package/dist/test/conversation-router.test.d.ts +2 -0
- package/dist/test/conversation-router.test.d.ts.map +1 -0
- package/dist/test/conversation-router.test.js +160 -0
- package/dist/test/conversation-router.test.js.map +1 -0
- package/dist/test/conversation-routing.test.d.ts +7 -0
- package/dist/test/conversation-routing.test.d.ts.map +1 -0
- package/dist/test/conversation-routing.test.js +203 -0
- package/dist/test/conversation-routing.test.js.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts +2 -0
- package/dist/test/event-gate-policy-mutation.test.d.ts.map +1 -0
- package/dist/test/event-gate-policy-mutation.test.js +137 -0
- package/dist/test/event-gate-policy-mutation.test.js.map +1 -0
- package/dist/test/event-gate-script.test.d.ts +2 -0
- package/dist/test/event-gate-script.test.d.ts.map +1 -0
- package/dist/test/event-gate-script.test.js +95 -0
- package/dist/test/event-gate-script.test.js.map +1 -0
- package/dist/test/event-gate-tags.test.d.ts +2 -0
- package/dist/test/event-gate-tags.test.d.ts.map +1 -0
- package/dist/test/event-gate-tags.test.js +76 -0
- package/dist/test/event-gate-tags.test.js.map +1 -0
- package/dist/test/framework.test.js +73 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +41 -0
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
- package/dist/test/helpers/mock-membrane.js +161 -0
- package/dist/test/helpers/mock-membrane.js.map +1 -0
- package/dist/test/inference-failure-observability.test.d.ts +2 -0
- package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
- package/dist/test/inference-failure-observability.test.js +75 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -0
- package/dist/test/liveness-watchdog.test.d.ts +2 -0
- package/dist/test/liveness-watchdog.test.d.ts.map +1 -0
- package/dist/test/liveness-watchdog.test.js +78 -0
- package/dist/test/liveness-watchdog.test.js.map +1 -0
- package/dist/test/mcpl-ws-transport.test.d.ts +2 -0
- package/dist/test/mcpl-ws-transport.test.d.ts.map +1 -0
- package/dist/test/mcpl-ws-transport.test.js +155 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -0
- package/dist/test/prose-segments.test.d.ts +2 -0
- package/dist/test/prose-segments.test.d.ts.map +1 -0
- package/dist/test/prose-segments.test.js +52 -0
- package/dist/test/prose-segments.test.js.map +1 -0
- package/dist/test/tool-result-history.test.d.ts +7 -0
- package/dist/test/tool-result-history.test.d.ts.map +1 -0
- package/dist/test/tool-result-history.test.js +77 -0
- package/dist/test/tool-result-history.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.d.ts +19 -0
- package/dist/test/trunk-channel-routing.test.d.ts.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +175 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +53 -18
- package/src/framework.ts +1685 -71
- package/src/gate/event-gate.ts +297 -9
- package/src/gate/gate-script-worker.ts +73 -0
- package/src/gate/gate-script.ts +180 -0
- package/src/gate/types.ts +36 -3
- package/src/index.ts +12 -0
- package/src/mcpl/channel-registry.ts +331 -6
- package/src/mcpl/checkpoint-manager.ts +16 -0
- package/src/mcpl/conversation-router.ts +284 -0
- package/src/mcpl/feature-set-manager.ts +14 -1
- package/src/mcpl/index.ts +12 -0
- package/src/mcpl/push-handler.ts +1 -0
- package/src/mcpl/server-connection.ts +157 -224
- package/src/mcpl/transport.ts +307 -0
- package/src/mcpl/types.ts +49 -2
- package/src/module-registry.ts +25 -2
- package/src/modules/discord/index.ts +93 -0
- package/src/prose-segments.ts +42 -0
- package/src/runtime/liveness-watchdog-worker.ts +66 -0
- package/src/runtime/liveness-watchdog.ts +122 -0
- package/src/tool-result-history.ts +57 -0
- package/src/types/agent.ts +21 -0
- package/src/types/framework.ts +24 -0
- package/src/types/module.ts +29 -0
- package/src/types/trace.ts +36 -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
|
-
/**
|
|
169
|
-
|
|
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
|
/**
|
package/src/module-registry.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
SpeechContext,
|
|
22
22
|
SpeechHandlerOptions,
|
|
23
23
|
ProcessEvent,
|
|
24
|
+
TraceEventListener,
|
|
24
25
|
} from './types/index.js';
|
|
25
26
|
import type { Agent } from './agent.js';
|
|
26
27
|
|
|
@@ -53,6 +54,8 @@ export class ModuleRegistry {
|
|
|
53
54
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
54
55
|
|
|
55
56
|
private pushEventFn: (event: ProcessEvent) => void;
|
|
57
|
+
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
58
|
+
private callToolFn: (call: ToolCall) => Promise<ToolResult>;
|
|
56
59
|
|
|
57
60
|
constructor(
|
|
58
61
|
store: JsStore,
|
|
@@ -65,6 +68,8 @@ export class ModuleRegistry {
|
|
|
65
68
|
getMessage: (id: MessageId) => StoredMessage | null;
|
|
66
69
|
queryMessages: (filter: MessageQuery) => MessageQueryResult;
|
|
67
70
|
pushEvent: (event: ProcessEvent) => void;
|
|
71
|
+
onTrace: (listener: TraceEventListener) => () => void;
|
|
72
|
+
callTool: (call: ToolCall) => Promise<ToolResult>;
|
|
68
73
|
}
|
|
69
74
|
) {
|
|
70
75
|
this.store = store;
|
|
@@ -76,6 +81,8 @@ export class ModuleRegistry {
|
|
|
76
81
|
this.getMessageFn = options.getMessage;
|
|
77
82
|
this.queryMessagesFn = options.queryMessages;
|
|
78
83
|
this.pushEventFn = options.pushEvent;
|
|
84
|
+
this.onTraceFn = options.onTrace;
|
|
85
|
+
this.callToolFn = options.callTool;
|
|
79
86
|
}
|
|
80
87
|
|
|
81
88
|
/**
|
|
@@ -115,7 +122,9 @@ export class ModuleRegistry {
|
|
|
115
122
|
this.removeMessageFn,
|
|
116
123
|
this.getMessageFn,
|
|
117
124
|
this.queryMessagesFn,
|
|
118
|
-
this.pushEventFn
|
|
125
|
+
this.pushEventFn,
|
|
126
|
+
this.onTraceFn,
|
|
127
|
+
this.callToolFn
|
|
119
128
|
);
|
|
120
129
|
|
|
121
130
|
this.modules.set(module.name, module);
|
|
@@ -366,6 +375,8 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
366
375
|
private getMessageFn: (id: MessageId) => StoredMessage | null;
|
|
367
376
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
368
377
|
private pushEventFn: (event: ProcessEvent) => void;
|
|
378
|
+
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
379
|
+
private callToolFn: (call: ToolCall) => Promise<ToolResult>;
|
|
369
380
|
|
|
370
381
|
// External ID mapping stored in module state
|
|
371
382
|
private externalIdMap: Map<string, MessageId> = new Map();
|
|
@@ -383,7 +394,9 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
383
394
|
removeMessage: (id: MessageId) => void,
|
|
384
395
|
getMessage: (id: MessageId) => StoredMessage | null,
|
|
385
396
|
queryMessages: (filter: MessageQuery) => MessageQueryResult,
|
|
386
|
-
pushEvent: (event: ProcessEvent) => void
|
|
397
|
+
pushEvent: (event: ProcessEvent) => void,
|
|
398
|
+
onTrace: (listener: TraceEventListener) => () => void,
|
|
399
|
+
callTool: (call: ToolCall) => Promise<ToolResult>
|
|
387
400
|
) {
|
|
388
401
|
this.moduleName = moduleName;
|
|
389
402
|
this.store = store;
|
|
@@ -398,6 +411,8 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
398
411
|
this.getMessageFn = getMessage;
|
|
399
412
|
this.queryMessagesFn = queryMessages;
|
|
400
413
|
this.pushEventFn = pushEvent;
|
|
414
|
+
this.onTraceFn = onTrace;
|
|
415
|
+
this.callToolFn = callTool;
|
|
401
416
|
|
|
402
417
|
// Load external ID map from state if exists
|
|
403
418
|
const state = this.getState<{ externalIdMap?: Record<string, string> }>();
|
|
@@ -424,6 +439,14 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
424
439
|
return this.registry.getModule<T>(name);
|
|
425
440
|
}
|
|
426
441
|
|
|
442
|
+
onTrace(listener: TraceEventListener): () => void {
|
|
443
|
+
return this.onTraceFn(listener);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
callTool(call: ToolCall): Promise<ToolResult> {
|
|
447
|
+
return this.callToolFn(call);
|
|
448
|
+
}
|
|
449
|
+
|
|
427
450
|
addMessage(
|
|
428
451
|
participant: string,
|
|
429
452
|
content: ContentBlock[],
|
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
ToolCall,
|
|
19
19
|
ToolResult,
|
|
20
20
|
SpeechContext,
|
|
21
|
+
TraceEvent,
|
|
21
22
|
} from '../../types/index.js';
|
|
22
23
|
import type {
|
|
23
24
|
DiscordModuleConfig,
|
|
@@ -56,6 +57,24 @@ const DEFAULT_CONFIG: Partial<DiscordModuleConfig> = {
|
|
|
56
57
|
historyScrollback: 50,
|
|
57
58
|
};
|
|
58
59
|
|
|
60
|
+
// Re-trigger the Discord typing indicator on this interval. Discord's
|
|
61
|
+
// indicator auto-expires after ~10s, so we refresh comfortably inside that.
|
|
62
|
+
const TYPING_REFRESH_MS = 8_000;
|
|
63
|
+
|
|
64
|
+
// Absolute safety cap on a single typing keepalive. If a terminal trace event
|
|
65
|
+
// is somehow missed (crash, dropped stream), this guarantees typing can't get
|
|
66
|
+
// stuck on forever.
|
|
67
|
+
const TYPING_MAX_MS = 5 * 60_000;
|
|
68
|
+
|
|
69
|
+
// Trace event types that terminate an agent's turn — any of these stops the
|
|
70
|
+
// typing keepalive. They fire once per turn, after all tool-call cycles.
|
|
71
|
+
const TURN_TERMINAL_TRACES = new Set([
|
|
72
|
+
'inference:turn_ended',
|
|
73
|
+
'inference:aborted',
|
|
74
|
+
'inference:failed',
|
|
75
|
+
'inference:exhausted',
|
|
76
|
+
]);
|
|
77
|
+
|
|
59
78
|
// Image MIME types that Claude can process
|
|
60
79
|
const SUPPORTED_IMAGE_TYPES = [
|
|
61
80
|
'image/jpeg',
|
|
@@ -95,6 +114,15 @@ export class DiscordModule implements Module {
|
|
|
95
114
|
// Last message sent by a tool (for thought-editing)
|
|
96
115
|
private lastSentMessage: { channelId: string; messageId: string } | null = null;
|
|
97
116
|
|
|
117
|
+
// Typing keepalive: agentName -> { interval, deadline }. Started on
|
|
118
|
+
// inference:started, stopped on the turn's terminal trace event. Discord's
|
|
119
|
+
// typing indicator auto-expires after ~10s, so we re-trigger on an interval
|
|
120
|
+
// to keep it alive for the whole turn (including tool-call cycles).
|
|
121
|
+
private typingLoops: Map<string, { interval: ReturnType<typeof setInterval>; deadline: number }> = new Map();
|
|
122
|
+
|
|
123
|
+
// Unsubscribe handle for the trace subscription (cleared in stop()).
|
|
124
|
+
private unsubscribeTrace: (() => void) | null = null;
|
|
125
|
+
|
|
98
126
|
constructor(client: DiscordClientInterface, config: DiscordModuleConfig) {
|
|
99
127
|
this.client = client;
|
|
100
128
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
@@ -114,6 +142,22 @@ export class DiscordModule implements Module {
|
|
|
114
142
|
// Register as speech handler
|
|
115
143
|
ctx.registerSpeechHandler('*');
|
|
116
144
|
|
|
145
|
+
// Typing keepalive: bracket the indicator between an agent starting
|
|
146
|
+
// inference and its turn ending, so it stays alive for the whole turn
|
|
147
|
+
// (including tool-call cycles) and stops cleanly whether the agent
|
|
148
|
+
// responds, errors, or decides to stay silent.
|
|
149
|
+
if (this.config.autoTyping) {
|
|
150
|
+
this.unsubscribeTrace = ctx.onTrace((event: TraceEvent) => {
|
|
151
|
+
const agentName = (event as { agentName?: string }).agentName;
|
|
152
|
+
if (!agentName) return;
|
|
153
|
+
if (event.type === 'inference:started') {
|
|
154
|
+
this.startTypingLoop(agentName);
|
|
155
|
+
} else if (TURN_TERMINAL_TRACES.has(event.type)) {
|
|
156
|
+
this.stopTypingLoop(agentName);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
117
161
|
// Set up Discord event handlers
|
|
118
162
|
this.client.onMessage((message) => this.handleDiscordMessage(message));
|
|
119
163
|
this.client.onMessageEdit((id, content) => this.handleDiscordMessageEdit(id, content));
|
|
@@ -146,10 +190,59 @@ export class DiscordModule implements Module {
|
|
|
146
190
|
this.ctx.setState(this.state);
|
|
147
191
|
}
|
|
148
192
|
|
|
193
|
+
// Tear down typing keepalive: drop the trace subscription and clear any
|
|
194
|
+
// in-flight intervals so nothing leaks across teardown/recreate.
|
|
195
|
+
this.unsubscribeTrace?.();
|
|
196
|
+
this.unsubscribeTrace = null;
|
|
197
|
+
for (const { interval } of this.typingLoops.values()) {
|
|
198
|
+
clearInterval(interval);
|
|
199
|
+
}
|
|
200
|
+
this.typingLoops.clear();
|
|
201
|
+
|
|
149
202
|
await this.client.disconnect();
|
|
150
203
|
this.ctx = null;
|
|
151
204
|
}
|
|
152
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Start (or refresh) the typing keepalive for an agent's reply channel.
|
|
208
|
+
* Idempotent: a fresh `inference:started` resets the safety deadline and
|
|
209
|
+
* restarts the interval. No-op if we can't resolve a channel for the agent.
|
|
210
|
+
*/
|
|
211
|
+
private startTypingLoop(agentName: string): void {
|
|
212
|
+
const replyCtx = this.replyContexts.get(agentName) ?? this.replyContexts.get('default');
|
|
213
|
+
if (!replyCtx) return;
|
|
214
|
+
const channel = replyCtx.threadId ?? replyCtx.channelId;
|
|
215
|
+
|
|
216
|
+
// Restart cleanly if one is already running for this agent.
|
|
217
|
+
this.stopTypingLoop(agentName);
|
|
218
|
+
|
|
219
|
+
const deadline = Date.now() + TYPING_MAX_MS;
|
|
220
|
+
const fire = () => {
|
|
221
|
+
if (Date.now() >= deadline) {
|
|
222
|
+
this.stopTypingLoop(agentName);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.client.sendTyping(channel).catch((err) => {
|
|
226
|
+
console.warn('Discord: Failed to send typing indicator:', err);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
fire(); // immediate feedback, don't wait a full interval
|
|
231
|
+
const interval = setInterval(fire, TYPING_REFRESH_MS);
|
|
232
|
+
// Don't keep the process alive solely for a typing indicator.
|
|
233
|
+
(interval as { unref?: () => void }).unref?.();
|
|
234
|
+
this.typingLoops.set(agentName, { interval, deadline });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Stop the typing keepalive for an agent, if one is running. */
|
|
238
|
+
private stopTypingLoop(agentName: string): void {
|
|
239
|
+
const loop = this.typingLoops.get(agentName);
|
|
240
|
+
if (loop) {
|
|
241
|
+
clearInterval(loop.interval);
|
|
242
|
+
this.typingLoops.delete(agentName);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
153
246
|
getTools(): ToolDefinition[] {
|
|
154
247
|
return [
|
|
155
248
|
{
|
|
@@ -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
|
+
}
|