@kadi.build/core 0.0.1-alpha.1 → 0.0.1-alpha.3
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/README.md +1145 -216
- package/examples/example-abilities/echo-js/README.md +131 -0
- package/examples/example-abilities/echo-js/agent.json +63 -0
- package/examples/example-abilities/echo-js/package.json +24 -0
- package/examples/example-abilities/echo-js/service.js +43 -0
- package/examples/example-abilities/hash-go/agent.json +53 -0
- package/examples/example-abilities/hash-go/cmd/hash_ability/main.go +340 -0
- package/examples/example-abilities/hash-go/go.mod +3 -0
- package/examples/example-agent/abilities/echo-js/0.0.1/README.md +131 -0
- package/examples/example-agent/abilities/echo-js/0.0.1/agent.json +63 -0
- package/examples/example-agent/abilities/echo-js/0.0.1/package-lock.json +93 -0
- package/examples/example-agent/abilities/echo-js/0.0.1/package.json +24 -0
- package/examples/example-agent/abilities/echo-js/0.0.1/service.js +41 -0
- package/examples/example-agent/abilities/hash-go/0.0.1/agent.json +53 -0
- package/examples/example-agent/abilities/hash-go/0.0.1/bin/hash_ability +0 -0
- package/examples/example-agent/abilities/hash-go/0.0.1/cmd/hash_ability/main.go +340 -0
- package/examples/example-agent/abilities/hash-go/0.0.1/go.mod +3 -0
- package/examples/example-agent/agent.json +39 -0
- package/examples/example-agent/index.js +102 -0
- package/examples/example-agent/package-lock.json +93 -0
- package/examples/example-agent/package.json +17 -0
- package/package.json +4 -2
- package/src/KadiAbility.js +478 -0
- package/src/index.js +65 -0
- package/src/loadAbility.js +1086 -0
- package/src/servers/BaseRpcServer.js +404 -0
- package/src/servers/BrokerRpcServer.js +776 -0
- package/src/servers/StdioRpcServer.js +360 -0
- package/src/transport/BrokerMessageBuilder.js +377 -0
- package/src/transport/IpcMessageBuilder.js +1229 -0
- package/src/utils/agentUtils.js +137 -0
- package/src/utils/commandUtils.js +64 -0
- package/src/utils/configUtils.js +72 -0
- package/src/utils/logger.js +161 -0
- package/src/utils/pathUtils.js +86 -0
- package/broker.js +0 -214
- package/index.js +0 -370
- package/ipc.js +0 -220
- package/ipcInterfaces/pythonAbilityIPC.py +0 -177
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
// IpcMessageBuilder.js
|
|
2
|
+
// ESM module
|
|
3
|
+
// A unified message builder for Kadi ability communication supporting both
|
|
4
|
+
// client-side (sending requests) and server-side (handling requests) operations.
|
|
5
|
+
// Supports multiple transport modes: stdio (LSP-framed), broker (WebSocket), and custom.
|
|
6
|
+
|
|
7
|
+
import { WebSocket } from 'ws';
|
|
8
|
+
import { Broker, IdFactory } from './BrokerMessageBuilder.js';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
//
|
|
11
|
+
// CLIENT SIDE USAGE:
|
|
12
|
+
// import { Ipc } from './IpcMessageBuilder.js';
|
|
13
|
+
//
|
|
14
|
+
// // Option A: bind once
|
|
15
|
+
// const IPC = Ipc.with(rpc);
|
|
16
|
+
// const initRes = await IPC.init({ api: '1.0' });
|
|
17
|
+
// const added = await IPC.call('add', { a: 1, b: 2 });
|
|
18
|
+
//
|
|
19
|
+
// // Option B: inline (no binding)
|
|
20
|
+
// await Ipc.init({ api: '1.0' }, rpc);
|
|
21
|
+
// const sum = await Ipc.call('add', { a: 1, b: 2 }, rpc);
|
|
22
|
+
//
|
|
23
|
+
// SERVER SIDE USAGE:
|
|
24
|
+
// import { IpcServer, StdioTransport } from './IpcMessageBuilder.js';
|
|
25
|
+
//
|
|
26
|
+
// const server = IpcServer.create()
|
|
27
|
+
// .method('add', async ({ a, b }) => ({ result: a + b }))
|
|
28
|
+
// .transport(new StdioTransport())
|
|
29
|
+
// .serve();
|
|
30
|
+
//
|
|
31
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
import { EventEmitter } from 'events';
|
|
34
|
+
import { Readable, Writable } from 'stream';
|
|
35
|
+
|
|
36
|
+
// Frame protocol constants - single source of truth
|
|
37
|
+
const FRAME_HEADERS = {
|
|
38
|
+
CONTENT_LENGTH: 'Kadi-Content-Length',
|
|
39
|
+
CONTENT_TYPE: 'Content-Type'
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const FRAME_VALUES = {
|
|
43
|
+
CONTENT_TYPE_VALUE: 'application/kadi-jsonrpc; charset=utf-8',
|
|
44
|
+
HEADER_DELIMITER: '\r\n\r\n'
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Central wire method map (override via configureMethods). */
|
|
48
|
+
const methodNames = {
|
|
49
|
+
init: '__kadi_init',
|
|
50
|
+
discover: '__kadi_discover'
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
54
|
+
// TRANSPORT LAYER CLASSES (extracted from KadiAbility)
|
|
55
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Frame reader for LSP-style stdio communication with corruption recovery
|
|
59
|
+
*/
|
|
60
|
+
export class StdioFrameReader {
|
|
61
|
+
constructor(input = process.stdin, options = {}) {
|
|
62
|
+
this.input = input;
|
|
63
|
+
this.buffer = Buffer.alloc(0);
|
|
64
|
+
this.maxBufferSize = options.maxBufferSize || 8 * 1024 * 1024; // 8MB default
|
|
65
|
+
|
|
66
|
+
// Pre-compile our delimiter buffers for efficiency
|
|
67
|
+
this.HEADER_DELIM = Buffer.from('\r\n\r\n');
|
|
68
|
+
this.CONTENT_LENGTH_MARKER = Buffer.from('Kadi-Content-Length:');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Set up event-driven message processing
|
|
73
|
+
*/
|
|
74
|
+
onMessage(callback) {
|
|
75
|
+
this.input.on('data', (chunk) => {
|
|
76
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
77
|
+
|
|
78
|
+
if (this.buffer.length > this.maxBufferSize) {
|
|
79
|
+
this._trimBuffer();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Try to extract messages from the buffer
|
|
83
|
+
let result;
|
|
84
|
+
while ((result = this._extractMessage())) {
|
|
85
|
+
callback(result);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.input.on('end', () => {
|
|
90
|
+
// Process any remaining messages in buffer
|
|
91
|
+
let result;
|
|
92
|
+
while ((result = this._extractMessage())) {
|
|
93
|
+
callback(result);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_extractMessage() {
|
|
99
|
+
const contentLengthPos = this.buffer.indexOf(this.CONTENT_LENGTH_MARKER);
|
|
100
|
+
if (contentLengthPos === -1) return null;
|
|
101
|
+
|
|
102
|
+
if (contentLengthPos > 0) {
|
|
103
|
+
const garbage = this.buffer.subarray(0, contentLengthPos);
|
|
104
|
+
if (garbage.length > 100) {
|
|
105
|
+
const preview =
|
|
106
|
+
garbage.length > 50
|
|
107
|
+
? garbage.subarray(0, 50).toString('utf8').replace(/\n/g, '\\n') +
|
|
108
|
+
'...'
|
|
109
|
+
: garbage.toString('utf8').replace(/\n/g, '\\n');
|
|
110
|
+
console.error(
|
|
111
|
+
`[StdioFrameReader] Skipping ${garbage.length} bytes of garbage: "${preview}"`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
this.buffer = this.buffer.subarray(contentLengthPos);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const headerEndPos = this.buffer.indexOf(this.HEADER_DELIM);
|
|
118
|
+
if (headerEndPos === -1) return null;
|
|
119
|
+
|
|
120
|
+
const headerBytes = this.buffer.subarray(0, headerEndPos);
|
|
121
|
+
const headerText = headerBytes.toString('ascii');
|
|
122
|
+
const headers = this._parseHeaders(headerText);
|
|
123
|
+
|
|
124
|
+
const contentLength = parseInt(
|
|
125
|
+
headers[FRAME_HEADERS.CONTENT_LENGTH] || '0',
|
|
126
|
+
10
|
|
127
|
+
);
|
|
128
|
+
if (!Number.isFinite(contentLength) || contentLength < 0) {
|
|
129
|
+
console.error(
|
|
130
|
+
`[StdioFrameReader] Invalid Content-Length: "${headers[FRAME_HEADERS.CONTENT_LENGTH]}" - discarding frame`
|
|
131
|
+
);
|
|
132
|
+
this.buffer = this.buffer.subarray(
|
|
133
|
+
headerEndPos + this.HEADER_DELIM.length
|
|
134
|
+
);
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: 'INVALID_CONTENT_LENGTH',
|
|
138
|
+
message: `Invalid Content-Length: "${headers[FRAME_HEADERS.CONTENT_LENGTH]}"`,
|
|
139
|
+
corruption_type: 'WRONG_LENGTH',
|
|
140
|
+
content_length: headers[FRAME_HEADERS.CONTENT_LENGTH],
|
|
141
|
+
raw: headerBytes.subarray(0, 100)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const bodyStart = headerEndPos + this.HEADER_DELIM.length;
|
|
146
|
+
const bodyEnd = bodyStart + contentLength;
|
|
147
|
+
|
|
148
|
+
if (this.buffer.length < bodyEnd) return null; // Wait for more body bytes
|
|
149
|
+
|
|
150
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyEnd);
|
|
151
|
+
this.buffer = this.buffer.subarray(bodyEnd);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(bodyBytes.toString('utf8'));
|
|
155
|
+
return {
|
|
156
|
+
success: true,
|
|
157
|
+
data
|
|
158
|
+
};
|
|
159
|
+
} catch (err) {
|
|
160
|
+
console.error(
|
|
161
|
+
`[StdioFrameReader] Failed to parse JSON body: ${err.message}`
|
|
162
|
+
);
|
|
163
|
+
console.error(
|
|
164
|
+
`[StdioFrameReader] Body preview: ${bodyBytes.subarray(0, 100).toString('utf8')}...`
|
|
165
|
+
);
|
|
166
|
+
return {
|
|
167
|
+
success: false,
|
|
168
|
+
error: 'JSON_PARSE_FAILED',
|
|
169
|
+
message: err.message,
|
|
170
|
+
corruption_type: 'INVALID_JSON',
|
|
171
|
+
content_length: parseInt(
|
|
172
|
+
headers[FRAME_HEADERS.CONTENT_LENGTH] || '0',
|
|
173
|
+
10
|
|
174
|
+
),
|
|
175
|
+
actual_length: bodyBytes.length,
|
|
176
|
+
raw: bodyBytes.subarray(0, 200) // First 200 bytes for debugging
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
_parseHeaders(headerText) {
|
|
182
|
+
const headers = {};
|
|
183
|
+
const lines = headerText.split('\r\n');
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
const [name, value] = line.split(/:\s*/);
|
|
186
|
+
if (name && value) {
|
|
187
|
+
headers[name] = value;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return headers;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_trimBuffer() {
|
|
194
|
+
console.warn(
|
|
195
|
+
`[StdioFrameReader] Buffer size (${this.buffer.length}) exceeds max (${this.maxBufferSize}), trimming...`
|
|
196
|
+
);
|
|
197
|
+
const lastMarkerPos = this.buffer.lastIndexOf(this.CONTENT_LENGTH_MARKER);
|
|
198
|
+
if (lastMarkerPos > this.maxBufferSize / 2) {
|
|
199
|
+
const keepFrom = Math.max(lastMarkerPos - 1024, 0);
|
|
200
|
+
this.buffer = this.buffer.subarray(keepFrom);
|
|
201
|
+
console.warn(
|
|
202
|
+
`[StdioFrameReader] Trimmed to ${this.buffer.length} bytes, preserved from last frame marker`
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
const keepBytes = Math.floor(this.maxBufferSize / 2);
|
|
206
|
+
this.buffer = this.buffer.subarray(this.buffer.length - keepBytes);
|
|
207
|
+
console.warn(
|
|
208
|
+
`[StdioFrameReader] Emergency trim: kept last ${keepBytes} bytes`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Frame writer for LSP-style stdio communication
|
|
216
|
+
*/
|
|
217
|
+
export class StdioFrameWriter {
|
|
218
|
+
constructor(output = process.stdout) {
|
|
219
|
+
this.output = output;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async write(message) {
|
|
223
|
+
const body = Buffer.from(JSON.stringify(message), 'utf-8');
|
|
224
|
+
const header = `${FRAME_HEADERS.CONTENT_LENGTH}: ${body.length}\r\n${FRAME_HEADERS.CONTENT_TYPE}: ${FRAME_VALUES.CONTENT_TYPE_VALUE}\r\n\r\n`;
|
|
225
|
+
|
|
226
|
+
// Make the write atomic by combining header and body into a single buffer
|
|
227
|
+
const fullFrame = Buffer.concat([Buffer.from(header, 'ascii'), body]);
|
|
228
|
+
|
|
229
|
+
await this.writeBuffer(fullFrame);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
writeBuffer(buffer) {
|
|
233
|
+
return new Promise((resolve, reject) => {
|
|
234
|
+
this.output.write(buffer, (err) => {
|
|
235
|
+
if (err) reject(err);
|
|
236
|
+
else resolve();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Allow consumers to override one or more method strings. */
|
|
243
|
+
export function configureMethods(overrides = {}) {
|
|
244
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
245
|
+
if (!(k in methodNames)) throw new Error(`Unknown method key: ${k}`);
|
|
246
|
+
if (typeof v !== 'string' || !v.length)
|
|
247
|
+
throw new Error(`Bad method string for ${k}`);
|
|
248
|
+
methodNames[k] = v;
|
|
249
|
+
}
|
|
250
|
+
return { ...methodNames };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Lightweight, chainable message builder that *doesn't* manage ids.
|
|
255
|
+
* It mirrors your transport, which already assigns ids on `rpc.call`.
|
|
256
|
+
*/
|
|
257
|
+
class MsgBuilder {
|
|
258
|
+
/** @param {string} method */
|
|
259
|
+
constructor(method) {
|
|
260
|
+
this._method = method;
|
|
261
|
+
this._params = undefined;
|
|
262
|
+
this._isNotification = false;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Attach/override params. */
|
|
266
|
+
params(obj) {
|
|
267
|
+
this._params = obj ?? undefined;
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Mark as a notification (fire‑and‑forget). */
|
|
272
|
+
asNotification(on = true) {
|
|
273
|
+
this._isNotification = !!on;
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Plain call tuple for your `rpc.call(method, params)` */
|
|
278
|
+
forCall() {
|
|
279
|
+
return { method: this._method, params: this._params };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Plain notify tuple for your `rpc.notify(method, params)` */
|
|
283
|
+
forNotify() {
|
|
284
|
+
return { method: this._method, params: this._params };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Convenience: send with your existing rpc. */
|
|
288
|
+
send(rpc) {
|
|
289
|
+
const { method, params } = this.forCall();
|
|
290
|
+
return rpc.call(method, params);
|
|
291
|
+
}
|
|
292
|
+
notifyWith(rpc) {
|
|
293
|
+
const { method, params } = this.forNotify();
|
|
294
|
+
return rpc.notify(method, params);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Optional: build a raw JSON-RPC object (supply your own id). */
|
|
298
|
+
toJsonRpc(id) {
|
|
299
|
+
const obj = { jsonrpc: '2.0', method: this._method };
|
|
300
|
+
if (this._params !== undefined) obj.params = this._params;
|
|
301
|
+
if (!this._isNotification) obj.id = id;
|
|
302
|
+
return obj;
|
|
303
|
+
}
|
|
304
|
+
/** Convenience: full LSP frame (headers + body) as a string. */
|
|
305
|
+
toJsonRpcFrame(id) {
|
|
306
|
+
const body = JSON.stringify(this.toJsonRpc(id));
|
|
307
|
+
return (
|
|
308
|
+
`Kadi-Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n` + body
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
// TRANSPORT ABSTRACTION
|
|
315
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Base transport interface that all transports must implement
|
|
319
|
+
*/
|
|
320
|
+
export class BaseTransport extends EventEmitter {
|
|
321
|
+
/**
|
|
322
|
+
* Start listening for incoming messages
|
|
323
|
+
* @returns {AsyncIterator} Async iterator of incoming messages
|
|
324
|
+
*/
|
|
325
|
+
async *listen() {
|
|
326
|
+
throw new Error('Transport must implement listen()');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Send a message
|
|
331
|
+
* @param {Object} message - JSON-RPC message to send
|
|
332
|
+
*/
|
|
333
|
+
async send(message) {
|
|
334
|
+
throw new Error('Transport must implement send()');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Close the transport
|
|
339
|
+
*/
|
|
340
|
+
async close() {
|
|
341
|
+
// Default implementation - transports can override
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Stdio transport using LSP framing
|
|
347
|
+
*/
|
|
348
|
+
export class StdioTransport extends BaseTransport {
|
|
349
|
+
constructor(options = {}) {
|
|
350
|
+
super();
|
|
351
|
+
this.reader = new StdioFrameReader(options.stdin, options);
|
|
352
|
+
this.writer = new StdioFrameWriter(options.stdout);
|
|
353
|
+
this._messageQueue = [];
|
|
354
|
+
this._listeners = [];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async *listen() {
|
|
358
|
+
// Set up the message handler
|
|
359
|
+
this.reader.onMessage((result) => {
|
|
360
|
+
if (!result.success) {
|
|
361
|
+
// Frame parsing failed - emit error and skip
|
|
362
|
+
this.emit(
|
|
363
|
+
'error',
|
|
364
|
+
new Error(`Frame corruption: ${result.error} - ${result.message}`)
|
|
365
|
+
);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this._messageQueue.push(result.data);
|
|
370
|
+
this._notifyListeners();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Yield messages as they arrive
|
|
374
|
+
while (true) {
|
|
375
|
+
if (this._messageQueue.length > 0) {
|
|
376
|
+
yield this._messageQueue.shift();
|
|
377
|
+
} else {
|
|
378
|
+
// Wait for the next message
|
|
379
|
+
await new Promise((resolve) => {
|
|
380
|
+
this._listeners.push(resolve);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
_notifyListeners() {
|
|
387
|
+
const listeners = this._listeners.splice(0);
|
|
388
|
+
listeners.forEach((resolve) => resolve());
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async send(message) {
|
|
392
|
+
await this.writer.write(message);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Start serving requests using this transport
|
|
397
|
+
*
|
|
398
|
+
* Creates an internal IpcServer instance and delegates serving to it.
|
|
399
|
+
* This makes StdioTransport self-contained like BrokerTransport.
|
|
400
|
+
*
|
|
401
|
+
* @param {Object} ability - KadiAbility instance with handlers and metadata
|
|
402
|
+
*/
|
|
403
|
+
async serve(ability) {
|
|
404
|
+
if (!ability) {
|
|
405
|
+
throw new Error('StdioTransport.serve() requires an ability instance');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Create an internal IpcServer with the ability's metadata
|
|
409
|
+
const server = new IpcServer({
|
|
410
|
+
name: ability.name,
|
|
411
|
+
version: ability.version,
|
|
412
|
+
description: ability.description
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Copy all handlers from the ability to the server
|
|
416
|
+
for (const [methodName, handler] of ability._handlers) {
|
|
417
|
+
server.method(methodName, handler);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Set this transport and start serving
|
|
421
|
+
server.transport(this);
|
|
422
|
+
|
|
423
|
+
// Forward events from server to ability
|
|
424
|
+
server.on('start', (data) => ability.emit('start', data));
|
|
425
|
+
server.on('error', (error) => ability.emit('error', error));
|
|
426
|
+
server.on('stop', () => ability.emit('stop'));
|
|
427
|
+
server.on('request', (data) => ability.emit('request', data));
|
|
428
|
+
server.on('response', (data) => ability.emit('response', data));
|
|
429
|
+
|
|
430
|
+
await server.serve();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Broker transport using native broker protocol
|
|
436
|
+
*
|
|
437
|
+
* This transport connects abilities to the Kadi broker via WebSocket
|
|
438
|
+
* and speaks the broker's native protocol directly (agent.message/ability.result),
|
|
439
|
+
* bypassing JSON-RPC translation entirely.
|
|
440
|
+
*/
|
|
441
|
+
export class BrokerTransport extends BaseTransport {
|
|
442
|
+
constructor(options = {}) {
|
|
443
|
+
super(); // Call BaseTransport constructor
|
|
444
|
+
|
|
445
|
+
this.brokerUrl = options.brokerUrl || 'ws://localhost:8080';
|
|
446
|
+
this.abilityName = options.abilityName || 'unnamed-ability';
|
|
447
|
+
this.abilityVersion = options.abilityVersion || '1.0.0';
|
|
448
|
+
this.description = options.description || 'A Kadi ability';
|
|
449
|
+
this.ability = options.ability || null; // Reference to KadiAbility instance
|
|
450
|
+
|
|
451
|
+
this._ws = null;
|
|
452
|
+
this._isConnected = false;
|
|
453
|
+
this._idFactory = new IdFactory();
|
|
454
|
+
this._isServing = false;
|
|
455
|
+
this._requestMetadata = new Map(); // Store broker metadata by request ID
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Start serving - connects to broker and handles incoming tool calls directly
|
|
460
|
+
* This method preserves the original working behavior while making BrokerTransport
|
|
461
|
+
* inherit from BaseTransport for consistency.
|
|
462
|
+
*
|
|
463
|
+
* @param {Object} ability - KadiAbility instance with handlers and metadata
|
|
464
|
+
*/
|
|
465
|
+
async serve(ability) {
|
|
466
|
+
// If ability is passed, use it; otherwise fall back to constructor option
|
|
467
|
+
if (ability) {
|
|
468
|
+
this.ability = ability;
|
|
469
|
+
}
|
|
470
|
+
console.error('[BrokerTransport] Starting broker service...');
|
|
471
|
+
|
|
472
|
+
// Connect to broker and complete handshake
|
|
473
|
+
await this._connect();
|
|
474
|
+
await this._handshake();
|
|
475
|
+
|
|
476
|
+
this._isServing = true;
|
|
477
|
+
|
|
478
|
+
// Set up message handler for incoming ability calls
|
|
479
|
+
this._ws.on('message', async (data) => {
|
|
480
|
+
try {
|
|
481
|
+
const message = JSON.parse(data.toString());
|
|
482
|
+
console.error(
|
|
483
|
+
`[BrokerTransport] Received message:`,
|
|
484
|
+
JSON.stringify(message, null, 2)
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Handle incoming tool calls directly using the ability reference
|
|
488
|
+
if (message.method === 'agent.message' && message.params) {
|
|
489
|
+
console.error(
|
|
490
|
+
'[BrokerTransport] Detected agent.message, handling tool call...'
|
|
491
|
+
);
|
|
492
|
+
await this._handleToolCallDirect(message);
|
|
493
|
+
} else if (message.method === 'ability.result') {
|
|
494
|
+
// This would be responses to tools we called - not needed for basic abilities
|
|
495
|
+
console.error(
|
|
496
|
+
'[BrokerTransport] Received ability.result (not handling)'
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
console.error(
|
|
500
|
+
'[BrokerTransport] Message not handled:',
|
|
501
|
+
message.method
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
} catch (err) {
|
|
505
|
+
console.error(
|
|
506
|
+
'[BrokerTransport] Failed to parse message:',
|
|
507
|
+
err.message
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Keep the connection alive
|
|
513
|
+
this._startHeartbeat();
|
|
514
|
+
|
|
515
|
+
console.error(
|
|
516
|
+
'[BrokerTransport] Broker service started, ready to receive tool calls'
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
// Keep the process alive
|
|
520
|
+
return new Promise((resolve, reject) => {
|
|
521
|
+
this._ws.on('close', () => {
|
|
522
|
+
this._isServing = false;
|
|
523
|
+
console.error('[BrokerTransport] Connection closed');
|
|
524
|
+
resolve();
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
this._ws.on('error', (err) => {
|
|
528
|
+
this._isServing = false;
|
|
529
|
+
console.error('[BrokerTransport] WebSocket error:', err);
|
|
530
|
+
reject(err);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Listen for incoming broker messages and yield them as JSON-RPC requests
|
|
537
|
+
*
|
|
538
|
+
* This method connects to the broker, performs handshake, and then yields
|
|
539
|
+
* incoming tool calls as standard JSON-RPC requests that can be handled
|
|
540
|
+
* by the IpcServer.serve() loop.
|
|
541
|
+
*/
|
|
542
|
+
async *listen() {
|
|
543
|
+
// Ensure we're connected and registered
|
|
544
|
+
if (!this._isConnected) {
|
|
545
|
+
await this._connect();
|
|
546
|
+
await this._handshake();
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.error('[BrokerTransport] Starting message listening...');
|
|
550
|
+
|
|
551
|
+
// Create a queue for incoming messages
|
|
552
|
+
const messageQueue = [];
|
|
553
|
+
const listeners = [];
|
|
554
|
+
|
|
555
|
+
// Set up message handler for incoming ability calls
|
|
556
|
+
this._ws.on('message', async (data) => {
|
|
557
|
+
try {
|
|
558
|
+
const message = JSON.parse(data.toString());
|
|
559
|
+
|
|
560
|
+
// Handle incoming tool calls - convert to JSON-RPC format
|
|
561
|
+
if (message.method === 'agent.message' && message.params) {
|
|
562
|
+
const { toolName, args, requestId, from } = message.params;
|
|
563
|
+
|
|
564
|
+
console.error(`[BrokerTransport] Received tool call: ${toolName}`);
|
|
565
|
+
|
|
566
|
+
// Store broker metadata for response handling
|
|
567
|
+
this._requestMetadata.set(requestId, { requestId, from });
|
|
568
|
+
|
|
569
|
+
// Convert broker message to JSON-RPC request format
|
|
570
|
+
const jsonRpcRequest = {
|
|
571
|
+
jsonrpc: '2.0',
|
|
572
|
+
id: requestId, // Use broker's requestId as JSON-RPC id
|
|
573
|
+
method: toolName,
|
|
574
|
+
params: args || {}
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
messageQueue.push(jsonRpcRequest);
|
|
578
|
+
this._notifyListeners(listeners);
|
|
579
|
+
}
|
|
580
|
+
// Handle other broker messages (ping responses, etc.)
|
|
581
|
+
else if (message.method === 'ability.result') {
|
|
582
|
+
console.error(
|
|
583
|
+
'[BrokerTransport] Received ability.result (not handling in listen)'
|
|
584
|
+
);
|
|
585
|
+
} else {
|
|
586
|
+
console.error(
|
|
587
|
+
'[BrokerTransport] Message not handled:',
|
|
588
|
+
message.method
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
} catch (err) {
|
|
592
|
+
console.error(
|
|
593
|
+
'[BrokerTransport] Failed to parse message:',
|
|
594
|
+
err.message
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// Keep the connection alive
|
|
600
|
+
this._startHeartbeat();
|
|
601
|
+
|
|
602
|
+
console.error('[BrokerTransport] Ready to receive tool calls via listen()');
|
|
603
|
+
|
|
604
|
+
// Yield messages as they arrive
|
|
605
|
+
while (true) {
|
|
606
|
+
if (messageQueue.length > 0) {
|
|
607
|
+
yield messageQueue.shift();
|
|
608
|
+
} else {
|
|
609
|
+
// Wait for the next message
|
|
610
|
+
await new Promise((resolve) => {
|
|
611
|
+
listeners.push(resolve);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Send response back to broker using the broker protocol
|
|
619
|
+
*
|
|
620
|
+
* Converts JSON-RPC response back to broker protocol format and sends it.
|
|
621
|
+
* Uses the broker metadata stored in the original request to route the response.
|
|
622
|
+
*/
|
|
623
|
+
async send(message) {
|
|
624
|
+
if (!this._ws || !this._isConnected) {
|
|
625
|
+
throw new Error('BrokerTransport: WebSocket not connected');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// If this is a JSON-RPC response, convert it to broker protocol
|
|
629
|
+
if (message.jsonrpc === '2.0' && message.id !== undefined) {
|
|
630
|
+
// Get the broker metadata for this request
|
|
631
|
+
const brokerMeta = this._requestMetadata.get(message.id);
|
|
632
|
+
if (!brokerMeta) {
|
|
633
|
+
console.error(
|
|
634
|
+
`[BrokerTransport] No broker metadata found for request ${message.id}`
|
|
635
|
+
);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Clean up the metadata after use
|
|
640
|
+
this._requestMetadata.delete(message.id);
|
|
641
|
+
|
|
642
|
+
let brokerResponse;
|
|
643
|
+
|
|
644
|
+
if (message.error) {
|
|
645
|
+
// Error response
|
|
646
|
+
brokerResponse = {
|
|
647
|
+
jsonrpc: '2.0',
|
|
648
|
+
method: 'ability.result',
|
|
649
|
+
params: {
|
|
650
|
+
requestId: brokerMeta.requestId,
|
|
651
|
+
toSessionId: brokerMeta.from,
|
|
652
|
+
error: message.error.message || 'Unknown error'
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
} else {
|
|
656
|
+
// Success response
|
|
657
|
+
brokerResponse = {
|
|
658
|
+
jsonrpc: '2.0',
|
|
659
|
+
method: 'ability.result',
|
|
660
|
+
params: {
|
|
661
|
+
requestId: brokerMeta.requestId,
|
|
662
|
+
toSessionId: brokerMeta.from,
|
|
663
|
+
result: message.result
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
console.error(
|
|
669
|
+
`[BrokerTransport] Sending broker response:`,
|
|
670
|
+
JSON.stringify(brokerResponse, null, 2)
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
this._ws.send(JSON.stringify(brokerResponse));
|
|
674
|
+
} else {
|
|
675
|
+
// Fallback: send the message directly as JSON-RPC over WebSocket
|
|
676
|
+
this._ws.send(JSON.stringify(message));
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Start heartbeat to keep connection alive
|
|
682
|
+
*/
|
|
683
|
+
_startHeartbeat() {
|
|
684
|
+
setInterval(() => {
|
|
685
|
+
if (this._isConnected && this._ws.readyState === 1) {
|
|
686
|
+
this._ws.send(
|
|
687
|
+
JSON.stringify({
|
|
688
|
+
jsonrpc: '2.0',
|
|
689
|
+
method: 'kadi.ping'
|
|
690
|
+
})
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
}, 25000); // 25 seconds as per agentA.js
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Close the broker connection
|
|
698
|
+
*/
|
|
699
|
+
async close() {
|
|
700
|
+
if (this._ws) {
|
|
701
|
+
this._isConnected = false;
|
|
702
|
+
this._isServing = false;
|
|
703
|
+
this._ws.close();
|
|
704
|
+
this._ws = null;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Handle incoming tool calls from the broker using the original direct approach
|
|
710
|
+
* This preserves the working behavior while keeping the new listen()/send() methods available
|
|
711
|
+
*/
|
|
712
|
+
async _handleToolCallDirect(message) {
|
|
713
|
+
const { toolName, args, requestId, from } = message.params;
|
|
714
|
+
|
|
715
|
+
console.error(`[BrokerTransport] Handling tool call: ${toolName}`);
|
|
716
|
+
|
|
717
|
+
try {
|
|
718
|
+
// Get the handler directly from the ability
|
|
719
|
+
if (!this.ability || !this.ability._handlers) {
|
|
720
|
+
throw new Error('No ability or handlers available');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const handler = this.ability._handlers.get(toolName);
|
|
724
|
+
if (!handler) {
|
|
725
|
+
throw new Error(`Tool '${toolName}' not found`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Call the handler directly with the arguments
|
|
729
|
+
console.error(
|
|
730
|
+
`[BrokerTransport] Calling handler for ${toolName} with args:`,
|
|
731
|
+
args
|
|
732
|
+
);
|
|
733
|
+
const result = await handler(args || {});
|
|
734
|
+
console.error(`[BrokerTransport] Handler result:`, result);
|
|
735
|
+
|
|
736
|
+
// Send the result back to the broker using native broker protocol
|
|
737
|
+
const response = {
|
|
738
|
+
jsonrpc: '2.0',
|
|
739
|
+
method: 'ability.result',
|
|
740
|
+
params: {
|
|
741
|
+
requestId,
|
|
742
|
+
toSessionId: from,
|
|
743
|
+
result
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
console.error(
|
|
748
|
+
`[BrokerTransport] Sending response:`,
|
|
749
|
+
JSON.stringify(response, null, 2)
|
|
750
|
+
);
|
|
751
|
+
this._ws.send(JSON.stringify(response));
|
|
752
|
+
} catch (error) {
|
|
753
|
+
console.error(`[BrokerTransport] Error handling tool call:`, error);
|
|
754
|
+
|
|
755
|
+
// Send error response
|
|
756
|
+
const errorResponse = {
|
|
757
|
+
jsonrpc: '2.0',
|
|
758
|
+
method: 'ability.result',
|
|
759
|
+
params: {
|
|
760
|
+
requestId,
|
|
761
|
+
toSessionId: from,
|
|
762
|
+
error: error.message
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
this._ws.send(JSON.stringify(errorResponse));
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Helper function to notify listeners waiting for messages
|
|
772
|
+
*/
|
|
773
|
+
_notifyListeners(listeners) {
|
|
774
|
+
const listenersToNotify = listeners.splice(0);
|
|
775
|
+
listenersToNotify.forEach((resolve) => resolve());
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async _connect() {
|
|
779
|
+
return new Promise((resolve, reject) => {
|
|
780
|
+
this._ws = new WebSocket(this.brokerUrl);
|
|
781
|
+
|
|
782
|
+
this._ws.on('open', () => {
|
|
783
|
+
this._isConnected = true;
|
|
784
|
+
console.error(
|
|
785
|
+
`[BrokerTransport] Connected to broker at ${this.brokerUrl}`
|
|
786
|
+
);
|
|
787
|
+
resolve();
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
this._ws.on('error', (err) => {
|
|
791
|
+
console.error('[BrokerTransport] WebSocket error:', err.message || err);
|
|
792
|
+
console.error(
|
|
793
|
+
'[BrokerTransport] Failed to connect to broker at:',
|
|
794
|
+
this.brokerUrl
|
|
795
|
+
);
|
|
796
|
+
reject(
|
|
797
|
+
new Error(
|
|
798
|
+
`Failed to connect to broker at ${this.brokerUrl}: ${err.message || err}`
|
|
799
|
+
)
|
|
800
|
+
);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
this._ws.on('close', () => {
|
|
804
|
+
this._isConnected = false;
|
|
805
|
+
console.error('[BrokerTransport] Disconnected from broker');
|
|
806
|
+
});
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async _handshake() {
|
|
811
|
+
// Step 1: Send hello as agent (abilities connect as agents in the current broker)
|
|
812
|
+
const hello = Broker.hello({ role: 'agent' })
|
|
813
|
+
.id(this._idFactory.next())
|
|
814
|
+
.build();
|
|
815
|
+
this._ws.send(JSON.stringify(hello));
|
|
816
|
+
|
|
817
|
+
// Wait for hello response with nonce
|
|
818
|
+
const helloResponse = await this._waitForResponse(hello.id);
|
|
819
|
+
if (!helloResponse.result || !helloResponse.result.nonce) {
|
|
820
|
+
throw new Error('Invalid hello response from broker');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const nonce = helloResponse.result.nonce;
|
|
824
|
+
console.error(`[BrokerTransport] Received nonce: ${nonce}`);
|
|
825
|
+
|
|
826
|
+
// Step 2: Generate ephemeral keys and authenticate
|
|
827
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
828
|
+
const publicKeyBase64 = publicKey
|
|
829
|
+
.export({ format: 'der', type: 'spki' })
|
|
830
|
+
.toString('base64');
|
|
831
|
+
|
|
832
|
+
const signature = crypto
|
|
833
|
+
.sign(null, Buffer.from(nonce), privateKey)
|
|
834
|
+
.toString('base64');
|
|
835
|
+
|
|
836
|
+
const authenticate = Broker.authenticate({
|
|
837
|
+
publicKeyBase64Der: publicKeyBase64,
|
|
838
|
+
privateKey, // This will be used to generate signature
|
|
839
|
+
nonce,
|
|
840
|
+
wantNewId: true
|
|
841
|
+
})
|
|
842
|
+
.id(this._idFactory.next())
|
|
843
|
+
.build();
|
|
844
|
+
|
|
845
|
+
console.error(`[BrokerTransport] Sending authentication...`);
|
|
846
|
+
this._ws.send(JSON.stringify(authenticate));
|
|
847
|
+
|
|
848
|
+
// Wait for authentication response
|
|
849
|
+
const authResponse = await this._waitForResponse(authenticate.id);
|
|
850
|
+
if (!authResponse.result || !authResponse.result.agentId) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`Authentication failed: ${JSON.stringify(authResponse.error)}`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
console.error(
|
|
857
|
+
`[BrokerTransport] Authenticated as agent: ${authResponse.result.agentId}`
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// Step 3: Register capabilities (extract tools from ability)
|
|
861
|
+
if (
|
|
862
|
+
this.ability &&
|
|
863
|
+
typeof this.ability.extractToolsForBroker === 'function'
|
|
864
|
+
) {
|
|
865
|
+
try {
|
|
866
|
+
console.error('[BrokerTransport] Extracting tools from ability...');
|
|
867
|
+
const tools = await this.ability.extractToolsForBroker();
|
|
868
|
+
console.error(
|
|
869
|
+
`[BrokerTransport] Extracted tools:`,
|
|
870
|
+
JSON.stringify(tools, null, 2)
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
if (tools.length > 0) {
|
|
874
|
+
const registerCapabilities = Broker.registerCapabilities({
|
|
875
|
+
displayName: this.abilityName,
|
|
876
|
+
tools,
|
|
877
|
+
mailboxMode: 'persistent',
|
|
878
|
+
scopes: ['global']
|
|
879
|
+
})
|
|
880
|
+
.id(this._idFactory.next())
|
|
881
|
+
.build();
|
|
882
|
+
|
|
883
|
+
console.error(
|
|
884
|
+
`[BrokerTransport] Sending registration:`,
|
|
885
|
+
JSON.stringify(registerCapabilities, null, 2)
|
|
886
|
+
);
|
|
887
|
+
this._ws.send(JSON.stringify(registerCapabilities));
|
|
888
|
+
|
|
889
|
+
// Wait for registration response
|
|
890
|
+
const registrationResponse = await this._waitForResponse(
|
|
891
|
+
registerCapabilities.id
|
|
892
|
+
);
|
|
893
|
+
console.error(
|
|
894
|
+
`[BrokerTransport] Registration response:`,
|
|
895
|
+
JSON.stringify(registrationResponse, null, 2)
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
console.error(
|
|
899
|
+
`[BrokerTransport] Registered ${tools.length} capabilities with broker`
|
|
900
|
+
);
|
|
901
|
+
} else {
|
|
902
|
+
console.error(
|
|
903
|
+
'[BrokerTransport] No tools found, skipping capability registration'
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
} catch (err) {
|
|
907
|
+
console.error(
|
|
908
|
+
`[BrokerTransport] Failed to register capabilities: ${err.message}`
|
|
909
|
+
);
|
|
910
|
+
console.error(`[BrokerTransport] Error details:`, err);
|
|
911
|
+
throw err;
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
console.error(
|
|
915
|
+
'[BrokerTransport] No ability reference or extractToolsForBroker method not found'
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
console.error(
|
|
920
|
+
'[BrokerTransport] Handshake completed, ready to receive calls'
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
// Note: Authentication step would go here in a production system
|
|
924
|
+
// For now, abilities connect without explicit authentication
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async _waitForResponse(messageId, timeoutMs = 5000) {
|
|
928
|
+
return new Promise((resolve, reject) => {
|
|
929
|
+
const timeout = setTimeout(() => {
|
|
930
|
+
reject(
|
|
931
|
+
new Error(`Timeout waiting for response to message ${messageId}`)
|
|
932
|
+
);
|
|
933
|
+
}, timeoutMs);
|
|
934
|
+
|
|
935
|
+
const handler = (data) => {
|
|
936
|
+
try {
|
|
937
|
+
const message = JSON.parse(data.toString());
|
|
938
|
+
if (message.id === messageId) {
|
|
939
|
+
clearTimeout(timeout);
|
|
940
|
+
this._ws.off('message', handler);
|
|
941
|
+
resolve(message);
|
|
942
|
+
}
|
|
943
|
+
} catch (err) {
|
|
944
|
+
// Ignore parsing errors for non-matching messages
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
this._ws.on('message', handler);
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
_notifyListeners() {
|
|
953
|
+
const listeners = this._listeners.splice(0);
|
|
954
|
+
listeners.forEach((resolve) => resolve());
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
959
|
+
// SERVER-SIDE COMPONENTS
|
|
960
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Server-side message builders for responses
|
|
964
|
+
*/
|
|
965
|
+
export const IpcResponse = {
|
|
966
|
+
/** Build a success response */
|
|
967
|
+
success(id, result) {
|
|
968
|
+
return { jsonrpc: '2.0', id, result };
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
/** Build an error response */
|
|
972
|
+
error(id, code, message, data = null) {
|
|
973
|
+
const error = { code, message };
|
|
974
|
+
if (data !== null) error.data = data;
|
|
975
|
+
return { jsonrpc: '2.0', id, error };
|
|
976
|
+
},
|
|
977
|
+
|
|
978
|
+
/** Common error responses */
|
|
979
|
+
methodNotFound(id, method) {
|
|
980
|
+
return this.error(id, -32601, `Method not found: ${method}`);
|
|
981
|
+
},
|
|
982
|
+
|
|
983
|
+
parseError(id) {
|
|
984
|
+
return this.error(id, -32700, 'Parse error');
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
internalError(id, message, data) {
|
|
988
|
+
return this.error(id, -32000, message || 'Internal error', data);
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Simple ability server that can work with any transport
|
|
994
|
+
*/
|
|
995
|
+
export class IpcServer extends EventEmitter {
|
|
996
|
+
constructor(options = {}) {
|
|
997
|
+
super();
|
|
998
|
+
this.name = options.name || 'unnamed-ability';
|
|
999
|
+
this.version = options.version || '1.0.0';
|
|
1000
|
+
this.description = options.description || '';
|
|
1001
|
+
this._handlers = new Map();
|
|
1002
|
+
this._transport = null;
|
|
1003
|
+
|
|
1004
|
+
// Register built-in discovery methods
|
|
1005
|
+
this._registerBuiltins();
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/**
|
|
1009
|
+
* Register a method handler
|
|
1010
|
+
*/
|
|
1011
|
+
method(name, handler) {
|
|
1012
|
+
if (typeof name === 'object') {
|
|
1013
|
+
// Batch registration
|
|
1014
|
+
for (const [methodName, methodHandler] of Object.entries(name)) {
|
|
1015
|
+
if (typeof methodHandler !== 'function') {
|
|
1016
|
+
throw new TypeError(
|
|
1017
|
+
`Handler for method "${methodName}" must be a function`
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
this._handlers.set(methodName, methodHandler);
|
|
1021
|
+
}
|
|
1022
|
+
} else {
|
|
1023
|
+
if (typeof handler !== 'function') {
|
|
1024
|
+
throw new TypeError(`Handler for method "${name}" must be a function`);
|
|
1025
|
+
}
|
|
1026
|
+
this._handlers.set(name, handler);
|
|
1027
|
+
}
|
|
1028
|
+
return this; // Chainable
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Set the transport for this server
|
|
1033
|
+
*/
|
|
1034
|
+
transport(transport) {
|
|
1035
|
+
this._transport = transport;
|
|
1036
|
+
return this;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Start serving requests
|
|
1041
|
+
*/
|
|
1042
|
+
async serve() {
|
|
1043
|
+
if (!this._transport) {
|
|
1044
|
+
throw new Error(
|
|
1045
|
+
'No transport configured. Use .transport(transport) first.'
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
this.emit('start', {
|
|
1050
|
+
name: this.name,
|
|
1051
|
+
version: this.version,
|
|
1052
|
+
methods: Array.from(this._handlers.keys()).filter(
|
|
1053
|
+
(m) => !m.startsWith('__kadi_')
|
|
1054
|
+
)
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
try {
|
|
1058
|
+
for await (const request of this._transport.listen()) {
|
|
1059
|
+
await this._handleRequest(request);
|
|
1060
|
+
}
|
|
1061
|
+
} catch (error) {
|
|
1062
|
+
this.emit('error', error);
|
|
1063
|
+
throw error;
|
|
1064
|
+
} finally {
|
|
1065
|
+
this.emit('stop');
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
/**
|
|
1070
|
+
* Handle a single request
|
|
1071
|
+
*/
|
|
1072
|
+
async _handleRequest(request) {
|
|
1073
|
+
try {
|
|
1074
|
+
const { id, method, params = {} } = request;
|
|
1075
|
+
|
|
1076
|
+
this.emit('request', { id, method, params });
|
|
1077
|
+
|
|
1078
|
+
const handler = this._handlers.get(method);
|
|
1079
|
+
if (!handler) {
|
|
1080
|
+
const response = IpcResponse.methodNotFound(id, method);
|
|
1081
|
+
await this._transport.send(response);
|
|
1082
|
+
this.emit('response', { id, error: response.error });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
const result = await handler(params);
|
|
1088
|
+
const response = IpcResponse.success(id, result);
|
|
1089
|
+
await this._transport.send(response);
|
|
1090
|
+
this.emit('response', { id, result });
|
|
1091
|
+
} catch (handlerError) {
|
|
1092
|
+
const response = IpcResponse.internalError(
|
|
1093
|
+
id,
|
|
1094
|
+
handlerError.message,
|
|
1095
|
+
handlerError.stack
|
|
1096
|
+
);
|
|
1097
|
+
await this._transport.send(response);
|
|
1098
|
+
this.emit('response', { id, error: response.error });
|
|
1099
|
+
this.emit('handler-error', { method, error: handlerError });
|
|
1100
|
+
}
|
|
1101
|
+
} catch (protocolError) {
|
|
1102
|
+
this.emit('protocol-error', protocolError);
|
|
1103
|
+
|
|
1104
|
+
if (request?.id) {
|
|
1105
|
+
try {
|
|
1106
|
+
const response = IpcResponse.parseError(request.id);
|
|
1107
|
+
await this._transport.send(response);
|
|
1108
|
+
} catch {
|
|
1109
|
+
// Ignore write errors - connection might be broken
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Register built-in discovery methods
|
|
1117
|
+
*/
|
|
1118
|
+
_registerBuiltins() {
|
|
1119
|
+
this._handlers.set('__kadi_init', async () => ({
|
|
1120
|
+
name: this.name,
|
|
1121
|
+
version: this.version,
|
|
1122
|
+
description: this.description,
|
|
1123
|
+
functions: this._getFunctionDescriptions()
|
|
1124
|
+
}));
|
|
1125
|
+
|
|
1126
|
+
this._handlers.set('__kadi_discover', async () => ({
|
|
1127
|
+
functions: this._getFunctionDescriptions()
|
|
1128
|
+
}));
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Get function descriptions for discovery
|
|
1133
|
+
*/
|
|
1134
|
+
_getFunctionDescriptions() {
|
|
1135
|
+
const functions = {};
|
|
1136
|
+
for (const [name, handler] of this._handlers) {
|
|
1137
|
+
if (name.startsWith('__kadi_')) continue;
|
|
1138
|
+
|
|
1139
|
+
functions[name] = {
|
|
1140
|
+
description: handler.description || `Handler for ${name}`,
|
|
1141
|
+
inputSchema: handler.inputSchema || { type: 'object' },
|
|
1142
|
+
outputSchema: handler.outputSchema || { type: 'object' }
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return functions;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Add metadata to a handler function
|
|
1150
|
+
*/
|
|
1151
|
+
describe(handler, metadata) {
|
|
1152
|
+
Object.assign(handler, metadata);
|
|
1153
|
+
return handler;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* Static factory method
|
|
1158
|
+
*/
|
|
1159
|
+
static create(options) {
|
|
1160
|
+
return new IpcServer(options);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1165
|
+
// CLIENT-SIDE COMPONENTS
|
|
1166
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
1167
|
+
export const Ipc = {
|
|
1168
|
+
/** __kadi_init — Initialize the child process context. */
|
|
1169
|
+
init(params = {}, rpc) {
|
|
1170
|
+
const b = new MsgBuilder(methodNames.init).params(params);
|
|
1171
|
+
return rpc ? b.send(rpc) : b;
|
|
1172
|
+
},
|
|
1173
|
+
|
|
1174
|
+
/** __kadi_discover — Ask the child to enumerate capabilities. */
|
|
1175
|
+
discover(params = {}, rpc) {
|
|
1176
|
+
const b = new MsgBuilder(methodNames.discover).params(params);
|
|
1177
|
+
return rpc ? b.send(rpc) : b;
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
/** Call any ability method explicitly. */
|
|
1181
|
+
call(method, params = {}, rpc) {
|
|
1182
|
+
if (!method) throw new Error('Ipc.call: method is required');
|
|
1183
|
+
const b = new MsgBuilder(String(method)).params(params);
|
|
1184
|
+
return rpc ? b.send(rpc) : b;
|
|
1185
|
+
},
|
|
1186
|
+
|
|
1187
|
+
/** Notification (fire‑and‑forget). */
|
|
1188
|
+
notify(method, params = {}, rpc) {
|
|
1189
|
+
if (!method) throw new Error('Ipc.notify: method is required');
|
|
1190
|
+
const b = new MsgBuilder(String(method))
|
|
1191
|
+
.params(params)
|
|
1192
|
+
.asNotification(true);
|
|
1193
|
+
return rpc ? b.notifyWith(rpc) : b;
|
|
1194
|
+
},
|
|
1195
|
+
|
|
1196
|
+
/** Bind your rpc once and get call‑through functions. */
|
|
1197
|
+
with(rpc) {
|
|
1198
|
+
return {
|
|
1199
|
+
init: (params = {}) => Ipc.init(params, rpc),
|
|
1200
|
+
discover: (params = {}) => Ipc.discover(params, rpc),
|
|
1201
|
+
call: (method, params = {}) => Ipc.call(method, params, rpc),
|
|
1202
|
+
notify: (method, params = {}) => Ipc.notify(method, params, rpc)
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
const IpcMessageBuilder = {
|
|
1208
|
+
// Client-side
|
|
1209
|
+
MsgBuilder,
|
|
1210
|
+
Ipc,
|
|
1211
|
+
|
|
1212
|
+
// Server-side
|
|
1213
|
+
IpcServer,
|
|
1214
|
+
IpcResponse,
|
|
1215
|
+
|
|
1216
|
+
// Transport layer
|
|
1217
|
+
BaseTransport,
|
|
1218
|
+
StdioTransport,
|
|
1219
|
+
BrokerTransport,
|
|
1220
|
+
StdioFrameReader,
|
|
1221
|
+
StdioFrameWriter,
|
|
1222
|
+
|
|
1223
|
+
// Configuration
|
|
1224
|
+
configureMethods,
|
|
1225
|
+
_methodNames: methodNames
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
export default IpcMessageBuilder;
|
|
1229
|
+
export { FRAME_HEADERS, FRAME_VALUES };
|