@beeos-ai/bma-executor-sdk 0.0.0-stage → 0.4.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/README.md +170 -2
- package/dist/agent.d.ts +126 -0
- package/dist/agent.js +478 -0
- package/dist/agent.js.map +1 -0
- package/dist/client.d.ts +170 -0
- package/dist/client.js +598 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.js +85 -0
- package/dist/errors.js.map +1 -0
- package/dist/generated/executor.d.ts +1218 -0
- package/dist/generated/executor.js +455 -0
- package/dist/generated/executor.js.map +1 -0
- package/dist/harness.d.ts +261 -0
- package/dist/harness.js +809 -0
- package/dist/harness.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/local.d.ts +49 -0
- package/dist/local.js +169 -0
- package/dist/local.js.map +1 -0
- package/dist/rpc.d.ts +38 -0
- package/dist/rpc.js +123 -0
- package/dist/rpc.js.map +1 -0
- package/dist/transport.d.ts +53 -0
- package/dist/transport.js +177 -0
- package/dist/transport.js.map +1 -0
- package/package.json +35 -4
package/dist/harness.js
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// Harness ("brain") end of the BMA executor protocol: accepts executor
|
|
2
|
+
// control and MCP channels on any transport and drives them with
|
|
3
|
+
// session.assign / tool.call / tool.cancel / session.stop, plus the agent
|
|
4
|
+
// capability. It speaks the same protocol as the BMA service, so an executor
|
|
5
|
+
// cannot tell a local harness from the cloud. Mirrors go/executor Harness.
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { WebSocketServer } from 'ws';
|
|
9
|
+
import { ChannelScope, JsonRpcErrorCode, ProtocolVersion, } from './generated/executor.js';
|
|
10
|
+
import { channelUrl } from './client.js';
|
|
11
|
+
import { AssignRejectedError, deferred, NotConnectedError, rpcError, RpcError, SessionEndedError, ToolOutcomeUnknownError, } from './errors.js';
|
|
12
|
+
import { DEFAULT_PROTOCOLS, listenHttpLocal, localUrl, refuseUpgrade } from './local.js';
|
|
13
|
+
import { RpcPeer } from './rpc.js';
|
|
14
|
+
import { dialWebSocket, FrameChannel, inProcessPair, MAX_FRAME_BYTES, webSocketConn } from './transport.js';
|
|
15
|
+
export const DEFAULT_HARNESS_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
16
|
+
export const DEFAULT_HARNESS_LEASE_TTL_MS = 60_000;
|
|
17
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
18
|
+
const MAX_REMEMBERED_EVENT_IDS = 1024;
|
|
19
|
+
function randomId(bytes) {
|
|
20
|
+
return randomBytes(bytes).toString('hex');
|
|
21
|
+
}
|
|
22
|
+
export class Harness {
|
|
23
|
+
options;
|
|
24
|
+
executorsById = new Map();
|
|
25
|
+
/** @internal */
|
|
26
|
+
sessionsById = new Map();
|
|
27
|
+
changed = deferred();
|
|
28
|
+
nextConn = 0;
|
|
29
|
+
wss;
|
|
30
|
+
/** @internal */
|
|
31
|
+
ledger = new AgentLedger();
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.options = {
|
|
34
|
+
...options,
|
|
35
|
+
heartbeatIntervalMs: options.heartbeatIntervalMs ?? DEFAULT_HARNESS_HEARTBEAT_INTERVAL_MS,
|
|
36
|
+
leaseTtlMs: options.leaseTtlMs ?? DEFAULT_HARNESS_LEASE_TTL_MS,
|
|
37
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS,
|
|
38
|
+
protocols: options.protocols ?? DEFAULT_PROTOCOLS,
|
|
39
|
+
};
|
|
40
|
+
this.wss = new WebSocketServer({
|
|
41
|
+
noServer: true,
|
|
42
|
+
maxPayload: MAX_FRAME_BYTES,
|
|
43
|
+
handleProtocols: (offered) => this.options.protocols.find((protocol) => offered.has(protocol)) ?? false,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Executors currently registered (connected or within their lease). */
|
|
47
|
+
executors() {
|
|
48
|
+
return [...this.executorsById.values()];
|
|
49
|
+
}
|
|
50
|
+
/** Resolves with a connected executor, waiting for one to register. */
|
|
51
|
+
async waitExecutor(signal) {
|
|
52
|
+
for (;;) {
|
|
53
|
+
const executor = this.executors().find((candidate) => candidate.connected);
|
|
54
|
+
if (executor)
|
|
55
|
+
return executor;
|
|
56
|
+
const changed = this.changed.promise;
|
|
57
|
+
await (signal
|
|
58
|
+
? Promise.race([
|
|
59
|
+
changed,
|
|
60
|
+
new Promise((_resolve, reject) => {
|
|
61
|
+
if (signal.aborted)
|
|
62
|
+
reject(signal.reason);
|
|
63
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
64
|
+
}),
|
|
65
|
+
])
|
|
66
|
+
: changed);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** @internal */
|
|
70
|
+
signal() {
|
|
71
|
+
const changed = this.changed;
|
|
72
|
+
this.changed = deferred();
|
|
73
|
+
changed.resolve();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Handles an HTTP upgrade for /v1/executors/channel style URLs: the control
|
|
77
|
+
* channel by default, an MCP channel with ?channel=mcp&session_id=&server_key=.
|
|
78
|
+
*/
|
|
79
|
+
async handleUpgrade(request, socket, head) {
|
|
80
|
+
const refuse = (status, message) => refuseUpgrade(socket, status, message);
|
|
81
|
+
let principal = {};
|
|
82
|
+
if (this.options.authenticate) {
|
|
83
|
+
try {
|
|
84
|
+
principal = await this.options.authenticate(request);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
refuse(401, 'Unauthorized');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const query = new URL(request.url ?? '/', 'http://localhost').searchParams;
|
|
92
|
+
const channel = query.get('channel') || ChannelScope.Control;
|
|
93
|
+
if (channel !== ChannelScope.Control && channel !== ChannelScope.Mcp) {
|
|
94
|
+
refuse(400, 'Bad Request');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const offered = (request.headers['sec-websocket-protocol'] ?? '').split(',').map((value) => value.trim());
|
|
98
|
+
if (!this.options.protocols.some((protocol) => offered.includes(protocol))) {
|
|
99
|
+
refuse(400, 'Bad Request');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const target = {
|
|
103
|
+
principal,
|
|
104
|
+
channel,
|
|
105
|
+
sessionId: query.get('session_id') ?? undefined,
|
|
106
|
+
serverKey: query.get('server_key') ?? undefined,
|
|
107
|
+
};
|
|
108
|
+
if (channel === ChannelScope.Mcp && !this.mcpAllowed(target)) {
|
|
109
|
+
refuse(403, 'Forbidden');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => this.accept(webSocketConn(ws), target));
|
|
113
|
+
}
|
|
114
|
+
/** Serves executor channels on an HTTP server's upgrade event. */
|
|
115
|
+
attach(server) {
|
|
116
|
+
server.on('upgrade', (request, socket, head) => void this.handleUpgrade(request, socket, head));
|
|
117
|
+
}
|
|
118
|
+
/** Listens on host:port (defaults 127.0.0.1, an ephemeral port) and returns the ws:// URL. */
|
|
119
|
+
async listen(port = 0, host = '127.0.0.1') {
|
|
120
|
+
const server = createServer((_request, response) => response.writeHead(426).end());
|
|
121
|
+
this.attach(server);
|
|
122
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
123
|
+
const address = server.address();
|
|
124
|
+
if (!address || typeof address === 'string')
|
|
125
|
+
throw new Error('unexpected listen address');
|
|
126
|
+
return {
|
|
127
|
+
url: `ws://${host}:${address.port}/v1/executors/channel`,
|
|
128
|
+
close: () => new Promise((resolve) => {
|
|
129
|
+
for (const client of this.wss.clients)
|
|
130
|
+
client.terminate();
|
|
131
|
+
server.closeAllConnections();
|
|
132
|
+
server.close(() => resolve());
|
|
133
|
+
}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Listens on a Unix domain socket or Windows named pipe (\\.\pipe\<name>)
|
|
138
|
+
* and returns its unix:// or npipe:// URL. Same handler, subprotocols,
|
|
139
|
+
* authenticate and close codes as listen; set authenticate (for example
|
|
140
|
+
* bearerAuthenticator with the local token).
|
|
141
|
+
*/
|
|
142
|
+
async listenLocal(path) {
|
|
143
|
+
const server = createServer((_request, response) => response.writeHead(426).end());
|
|
144
|
+
this.attach(server);
|
|
145
|
+
const closeServer = await listenHttpLocal(server, path);
|
|
146
|
+
return {
|
|
147
|
+
url: localUrl(path),
|
|
148
|
+
close: () => {
|
|
149
|
+
for (const client of this.wss.clients)
|
|
150
|
+
client.terminate();
|
|
151
|
+
return closeServer();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Dials an executor that listens (listenExecutor) at a unix://, npipe:// or
|
|
157
|
+
* ws:// URL: the local "who dials" inversion. The harness also dials the
|
|
158
|
+
* MCP channels of that executor's sessions (HarnessSession.mcp).
|
|
159
|
+
*/
|
|
160
|
+
async dialExecutor(url, options = {}) {
|
|
161
|
+
const conn = await dialWebSocket(channelUrl(url, { channel: ChannelScope.Control }), {
|
|
162
|
+
protocols: this.options.protocols,
|
|
163
|
+
token: options.token,
|
|
164
|
+
});
|
|
165
|
+
this.accept(conn, { dialBack: { url, token: options.token } });
|
|
166
|
+
}
|
|
167
|
+
/** @internal Opens the MCP channel of a session whose executor this harness dialed. */
|
|
168
|
+
async dialMcp(executor, sessionId, serverKey) {
|
|
169
|
+
const dialBack = executor.dialBack;
|
|
170
|
+
const target = { channel: ChannelScope.Mcp, sessionId, serverKey };
|
|
171
|
+
const conn = await dialWebSocket(channelUrl(dialBack.url, target), { protocols: this.options.protocols, token: dialBack.token });
|
|
172
|
+
this.accept(conn, { principal: { executorId: executor.id }, ...target });
|
|
173
|
+
}
|
|
174
|
+
/** A Dialer for an ExecutorClient in the same process. */
|
|
175
|
+
inProcessDialer(principal = {}) {
|
|
176
|
+
return async (target) => {
|
|
177
|
+
const options = { principal, channel: target.channel, sessionId: target.sessionId, serverKey: target.serverKey };
|
|
178
|
+
if (target.channel === ChannelScope.Mcp && !this.mcpAllowed(options))
|
|
179
|
+
throw new Error('session is not active for this executor');
|
|
180
|
+
const [executorEnd, harnessEnd] = inProcessPair(this.options.protocols[0] ?? ProtocolVersion.BmaExecutorV11);
|
|
181
|
+
this.accept(harnessEnd, options);
|
|
182
|
+
return executorEnd;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/** Takes over an established connection (any transport). */
|
|
186
|
+
accept(conn, options = {}) {
|
|
187
|
+
if (options.channel === ChannelScope.Mcp) {
|
|
188
|
+
const session = options.sessionId ? this.sessionsById.get(options.sessionId) : undefined;
|
|
189
|
+
if (!session || !options.serverKey || !this.mcpAllowed(options)) {
|
|
190
|
+
conn.close(1008, 'session is not active for this executor');
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
session.attachMcp(options.serverKey, new HarnessMcpChannel(conn));
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
new HarnessConn(this, conn, options.principal ?? {}, `conn_${++this.nextConn}_${randomId(4)}`, options.dialBack);
|
|
197
|
+
}
|
|
198
|
+
/** Only the executor holding the session may serve its MCP servers. */
|
|
199
|
+
mcpAllowed(options) {
|
|
200
|
+
const session = options.sessionId ? this.sessionsById.get(options.sessionId) : undefined;
|
|
201
|
+
if (!session || !options.serverKey)
|
|
202
|
+
return false;
|
|
203
|
+
const pinned = options.principal?.executorId;
|
|
204
|
+
return !pinned || pinned === session.executor.id;
|
|
205
|
+
}
|
|
206
|
+
/** @internal */
|
|
207
|
+
executorFor(id) {
|
|
208
|
+
let executor = this.executorsById.get(id);
|
|
209
|
+
if (!executor) {
|
|
210
|
+
executor = new HarnessExecutor(this, id);
|
|
211
|
+
this.executorsById.set(id, executor);
|
|
212
|
+
}
|
|
213
|
+
return executor;
|
|
214
|
+
}
|
|
215
|
+
/** @internal */
|
|
216
|
+
forgetExecutor(executor) {
|
|
217
|
+
if (this.executorsById.get(executor.id) === executor)
|
|
218
|
+
this.executorsById.delete(executor.id);
|
|
219
|
+
this.signal();
|
|
220
|
+
}
|
|
221
|
+
/** @internal */
|
|
222
|
+
log(level, message, error) {
|
|
223
|
+
this.options.onLog?.({ level, message, error });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** One registered executor; it outlives a connection within its lease. */
|
|
227
|
+
export class HarnessExecutor {
|
|
228
|
+
harness;
|
|
229
|
+
id;
|
|
230
|
+
/** @internal */ conn;
|
|
231
|
+
info = { name: '', os: '', arch: '', version: '' };
|
|
232
|
+
/** The negotiated protocol of the current connection. */
|
|
233
|
+
protocol = '';
|
|
234
|
+
environmentId = '';
|
|
235
|
+
capabilities = {};
|
|
236
|
+
/** @internal */ sessionsById = new Map();
|
|
237
|
+
/** @internal */ leaseTimer;
|
|
238
|
+
/** @internal */ draining = false;
|
|
239
|
+
/** @internal */ gone = false;
|
|
240
|
+
/** @internal */ eventIds = new Map();
|
|
241
|
+
/** @internal */ dialBack;
|
|
242
|
+
constructor(harness, id) {
|
|
243
|
+
this.harness = harness;
|
|
244
|
+
this.id = id;
|
|
245
|
+
}
|
|
246
|
+
get connected() {
|
|
247
|
+
return this.conn !== undefined;
|
|
248
|
+
}
|
|
249
|
+
sessions() {
|
|
250
|
+
return [...this.sessionsById.values()];
|
|
251
|
+
}
|
|
252
|
+
/** @internal */
|
|
253
|
+
call(method, params) {
|
|
254
|
+
if (!this.conn)
|
|
255
|
+
return Promise.reject(new NotConnectedError());
|
|
256
|
+
return this.conn.peer.call(method, params);
|
|
257
|
+
}
|
|
258
|
+
/** Asks the executor to stop accepting sessions; heartbeats report drain=true from now on. */
|
|
259
|
+
drain(reason) {
|
|
260
|
+
this.draining = true;
|
|
261
|
+
return this.call('executor.drain', { reason });
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Binds a session and sends session.assign. work_id and the lease token are
|
|
265
|
+
* generated when empty. The session is bound before the request is sent, so
|
|
266
|
+
* session.ready and MCP channels racing the answer are accepted.
|
|
267
|
+
*/
|
|
268
|
+
async assignSession(params) {
|
|
269
|
+
if (!params.session_id?.trim())
|
|
270
|
+
throw new Error('session_id is required');
|
|
271
|
+
const full = {
|
|
272
|
+
...params,
|
|
273
|
+
work_id: params.work_id || `work_${randomId(8)}`,
|
|
274
|
+
environment_id: params.environment_id || this.environmentId,
|
|
275
|
+
lease: { token: params.lease?.token || `lease_${randomId(16)}`, fencing_token: params.lease?.fencing_token ?? 0 },
|
|
276
|
+
};
|
|
277
|
+
if (this.harness.sessionsById.has(full.session_id))
|
|
278
|
+
throw new Error(`session ${full.session_id} is already bound`);
|
|
279
|
+
if (this.gone)
|
|
280
|
+
throw new NotConnectedError();
|
|
281
|
+
const session = new HarnessSession(this.harness, this, full.session_id, full.work_id, full.lease.token);
|
|
282
|
+
this.harness.sessionsById.set(session.id, session);
|
|
283
|
+
this.sessionsById.set(session.id, session);
|
|
284
|
+
try {
|
|
285
|
+
const result = await this.call('session.assign', full);
|
|
286
|
+
if (!result.accepted)
|
|
287
|
+
throw new AssignRejectedError();
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
session.end('assign_failed', () => new SessionEndedError());
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
return session;
|
|
294
|
+
}
|
|
295
|
+
/** @internal Ends every session; pending tool calls fail with ToolOutcomeUnknownError. */
|
|
296
|
+
forget(reason) {
|
|
297
|
+
this.gone = true;
|
|
298
|
+
for (const session of this.sessions())
|
|
299
|
+
session.end(reason, () => new ToolOutcomeUnknownError());
|
|
300
|
+
this.harness.forgetExecutor(this);
|
|
301
|
+
}
|
|
302
|
+
// --- agent capability ---
|
|
303
|
+
/** The advertised agent capability, undefined when absent. */
|
|
304
|
+
get agentCapability() {
|
|
305
|
+
return this.capabilities.agent;
|
|
306
|
+
}
|
|
307
|
+
requireAgent() {
|
|
308
|
+
if (!this.agentCapability?.enabled) {
|
|
309
|
+
throw new RpcError(JsonRpcErrorCode.CapabilityUnavailable, 'executor does not advertise the agent capability');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** turn.start; safe to repeat with the same turn_id after a timeout or reconnect. */
|
|
313
|
+
async startTurn(params) {
|
|
314
|
+
this.requireAgent();
|
|
315
|
+
return this.call('turn.start', params);
|
|
316
|
+
}
|
|
317
|
+
/** turn.input; a conflict means the turn is over and the input belongs in a new turn.start. */
|
|
318
|
+
sendTurnInput(params) {
|
|
319
|
+
return this.call('turn.input', params);
|
|
320
|
+
}
|
|
321
|
+
/** turn.cancel; the turn's end is still reported by its session.status_idle event. */
|
|
322
|
+
cancelTurn(params) {
|
|
323
|
+
return this.call('turn.cancel', params);
|
|
324
|
+
}
|
|
325
|
+
closeAgentSession(params) {
|
|
326
|
+
return this.call('session.close', params);
|
|
327
|
+
}
|
|
328
|
+
/** Delivers the platform's answer; accepted=false means the executor no longer waits for it. */
|
|
329
|
+
respondInteraction(params) {
|
|
330
|
+
return this.call('interaction.response', params);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** One session bound to an executor. */
|
|
334
|
+
export class HarnessSession {
|
|
335
|
+
harness;
|
|
336
|
+
executor;
|
|
337
|
+
id;
|
|
338
|
+
workId;
|
|
339
|
+
leaseToken;
|
|
340
|
+
readyDeferred = deferred();
|
|
341
|
+
doneDeferred = deferred();
|
|
342
|
+
isReady = false;
|
|
343
|
+
/** Set once the session ended. */
|
|
344
|
+
endReason = '';
|
|
345
|
+
calls = new Map();
|
|
346
|
+
mcpChannels = new Map();
|
|
347
|
+
mcpChanged = deferred();
|
|
348
|
+
constructor(harness, executor, id, workId,
|
|
349
|
+
/** @internal */ leaseToken) {
|
|
350
|
+
this.harness = harness;
|
|
351
|
+
this.executor = executor;
|
|
352
|
+
this.id = id;
|
|
353
|
+
this.workId = workId;
|
|
354
|
+
this.leaseToken = leaseToken;
|
|
355
|
+
}
|
|
356
|
+
/** Resolves when the session ended. */
|
|
357
|
+
get done() {
|
|
358
|
+
return this.doneDeferred.promise;
|
|
359
|
+
}
|
|
360
|
+
/** Waits for session.ready. */
|
|
361
|
+
async ready(signal) {
|
|
362
|
+
if (this.isReady)
|
|
363
|
+
return;
|
|
364
|
+
if (this.endReason)
|
|
365
|
+
throw new SessionEndedError();
|
|
366
|
+
await this.race(Promise.race([this.readyDeferred.promise, this.done.then(() => Promise.reject(new SessionEndedError()))]), signal);
|
|
367
|
+
}
|
|
368
|
+
/** @internal */
|
|
369
|
+
markReady() {
|
|
370
|
+
this.isReady = true;
|
|
371
|
+
this.readyDeferred.resolve();
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Sends tool.call and waits for its tool.result. Idempotent by tool_call_id
|
|
375
|
+
* (generated when empty): a repeat joins the in-flight call or returns the
|
|
376
|
+
* recorded result without re-dispatching. Aborting signal stops waiting
|
|
377
|
+
* only; use cancelTool to abort the tool.
|
|
378
|
+
*/
|
|
379
|
+
async callTool(params, signal) {
|
|
380
|
+
const call = { ...params, session_id: this.id, tool_call_id: params.tool_call_id || `call_${randomId(8)}` };
|
|
381
|
+
let record = this.calls.get(call.tool_call_id);
|
|
382
|
+
if (!record) {
|
|
383
|
+
if (this.endReason)
|
|
384
|
+
throw new SessionEndedError();
|
|
385
|
+
const created = { done: deferred(), settled: false };
|
|
386
|
+
record = created;
|
|
387
|
+
this.calls.set(call.tool_call_id, created);
|
|
388
|
+
try {
|
|
389
|
+
const ack = await this.executor.call('tool.call', call);
|
|
390
|
+
if (!ack.accepted)
|
|
391
|
+
throw new Error('executor did not accept tool.call');
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
// Not dispatched: forget the call so the same id can be retried.
|
|
395
|
+
if (this.calls.get(call.tool_call_id) === created)
|
|
396
|
+
this.calls.delete(call.tool_call_id);
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const settled = record;
|
|
401
|
+
await this.race(settled.done.promise, signal);
|
|
402
|
+
if (settled.error !== undefined)
|
|
403
|
+
throw settled.error;
|
|
404
|
+
return settled.result;
|
|
405
|
+
}
|
|
406
|
+
/** Sends tool.cancel; the waiting callTool still receives the executor's tool.result. */
|
|
407
|
+
async cancelTool(toolCallId, reason) {
|
|
408
|
+
const result = await this.executor.call('tool.cancel', {
|
|
409
|
+
session_id: this.id,
|
|
410
|
+
tool_call_id: toolCallId,
|
|
411
|
+
reason,
|
|
412
|
+
});
|
|
413
|
+
return result.canceled;
|
|
414
|
+
}
|
|
415
|
+
/** Sends session.stop; the session ends locally on success. */
|
|
416
|
+
async stop(reason) {
|
|
417
|
+
await this.executor.call('session.stop', { session_id: this.id, reason });
|
|
418
|
+
this.end(reason, () => new ToolOutcomeUnknownError());
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Waits for the executor to open the MCP channel for serverKey (one of the
|
|
422
|
+
* session.assign mcp_servers). A newer channel replaces the old one.
|
|
423
|
+
*/
|
|
424
|
+
async mcp(serverKey, signal) {
|
|
425
|
+
for (;;) {
|
|
426
|
+
if (this.endReason)
|
|
427
|
+
throw new SessionEndedError();
|
|
428
|
+
const channel = this.mcpChannels.get(serverKey);
|
|
429
|
+
if (channel)
|
|
430
|
+
return channel;
|
|
431
|
+
const changed = this.mcpChanged.promise;
|
|
432
|
+
// The executor cannot dial a harness that dialed it; open the channel from here.
|
|
433
|
+
if (this.executor.dialBack)
|
|
434
|
+
await this.race(this.harness.dialMcp(this.executor, this.id, serverKey), signal);
|
|
435
|
+
await this.race(changed, signal);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/** @internal */
|
|
439
|
+
attachMcp(serverKey, channel) {
|
|
440
|
+
if (this.endReason) {
|
|
441
|
+
channel.close();
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const previous = this.mcpChannels.get(serverKey);
|
|
445
|
+
this.mcpChannels.set(serverKey, channel);
|
|
446
|
+
void channel.done.then(() => {
|
|
447
|
+
if (this.mcpChannels.get(serverKey) === channel)
|
|
448
|
+
this.mcpChannels.delete(serverKey);
|
|
449
|
+
});
|
|
450
|
+
this.notifyMcp();
|
|
451
|
+
previous?.close();
|
|
452
|
+
}
|
|
453
|
+
notifyMcp() {
|
|
454
|
+
const changed = this.mcpChanged;
|
|
455
|
+
this.mcpChanged = deferred();
|
|
456
|
+
changed.resolve();
|
|
457
|
+
}
|
|
458
|
+
/** @internal Records a tool.result; false when this harness never dispatched the call. */
|
|
459
|
+
complete(result) {
|
|
460
|
+
const record = this.calls.get(result.tool_call_id);
|
|
461
|
+
if (!record)
|
|
462
|
+
return false;
|
|
463
|
+
if (!record.settled) {
|
|
464
|
+
record.settled = true;
|
|
465
|
+
record.result = result;
|
|
466
|
+
record.done.resolve();
|
|
467
|
+
}
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
/** @internal Unbinds the session, fails waiting calls and closes MCP channels. Idempotent. */
|
|
471
|
+
end(reason, pendingError) {
|
|
472
|
+
if (this.endReason)
|
|
473
|
+
return;
|
|
474
|
+
this.endReason = reason || 'ended';
|
|
475
|
+
for (const record of this.calls.values()) {
|
|
476
|
+
if (record.settled)
|
|
477
|
+
continue;
|
|
478
|
+
record.settled = true;
|
|
479
|
+
record.error = pendingError();
|
|
480
|
+
record.done.resolve();
|
|
481
|
+
}
|
|
482
|
+
const channels = [...this.mcpChannels.values()];
|
|
483
|
+
this.mcpChannels.clear();
|
|
484
|
+
this.doneDeferred.resolve();
|
|
485
|
+
this.notifyMcp();
|
|
486
|
+
for (const channel of channels)
|
|
487
|
+
channel.close();
|
|
488
|
+
if (this.harness.sessionsById.get(this.id) === this)
|
|
489
|
+
this.harness.sessionsById.delete(this.id);
|
|
490
|
+
if (this.executor.sessionsById.get(this.id) === this)
|
|
491
|
+
this.executor.sessionsById.delete(this.id);
|
|
492
|
+
}
|
|
493
|
+
race(promise, signal) {
|
|
494
|
+
if (!signal)
|
|
495
|
+
return promise;
|
|
496
|
+
return Promise.race([
|
|
497
|
+
promise,
|
|
498
|
+
new Promise((_resolve, reject) => {
|
|
499
|
+
if (signal.aborted)
|
|
500
|
+
reject(signal.reason);
|
|
501
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
502
|
+
}),
|
|
503
|
+
]);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The harness end of one MCP channel, isolated by (session_id, server_key).
|
|
508
|
+
* Frames are raw MCP JSON-RPC: the harness is the MCP client. After a
|
|
509
|
+
* transport error, call session.mcp again and re-initialize.
|
|
510
|
+
*/
|
|
511
|
+
export class HarnessMcpChannel extends FrameChannel {
|
|
512
|
+
}
|
|
513
|
+
/** Serializes agent events per session and remembers the acknowledged sequence. */
|
|
514
|
+
class AgentLedger {
|
|
515
|
+
acked = new Map();
|
|
516
|
+
tails = new Map();
|
|
517
|
+
/** Runs fn after every earlier fn of the same session. */
|
|
518
|
+
serialize(sessionId, fn) {
|
|
519
|
+
const previous = this.tails.get(sessionId) ?? Promise.resolve();
|
|
520
|
+
const next = previous.then(fn, fn);
|
|
521
|
+
this.tails.set(sessionId, next.catch(() => { }));
|
|
522
|
+
return next;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const AGENT_EVENT_TYPES = new Set([
|
|
526
|
+
'agent.message',
|
|
527
|
+
'agent.thinking',
|
|
528
|
+
'agent.tool_use',
|
|
529
|
+
'agent.tool_result',
|
|
530
|
+
'agent.mcp_tool_use',
|
|
531
|
+
'agent.mcp_tool_result',
|
|
532
|
+
'session.status_running',
|
|
533
|
+
'session.status_idle',
|
|
534
|
+
'session.error',
|
|
535
|
+
]);
|
|
536
|
+
/** The harness side of one connection. */
|
|
537
|
+
class HarnessConn {
|
|
538
|
+
harness;
|
|
539
|
+
conn;
|
|
540
|
+
principal;
|
|
541
|
+
id;
|
|
542
|
+
dialBack;
|
|
543
|
+
peer;
|
|
544
|
+
executor;
|
|
545
|
+
constructor(harness, conn, principal, id, dialBack) {
|
|
546
|
+
this.harness = harness;
|
|
547
|
+
this.conn = conn;
|
|
548
|
+
this.principal = principal;
|
|
549
|
+
this.id = id;
|
|
550
|
+
this.dialBack = dialBack;
|
|
551
|
+
this.peer = new RpcPeer(conn, {
|
|
552
|
+
idPrefix: 'h_',
|
|
553
|
+
requestTimeoutMs: harness.options.requestTimeoutMs,
|
|
554
|
+
onRequest: (method, params, requestId) => this.onRequest(method, params, requestId),
|
|
555
|
+
onNotification: (method, params) => this.onNotification(method, params),
|
|
556
|
+
onClose: () => this.disconnected(),
|
|
557
|
+
log: harness.options.onLog,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
onRequest(method, params, requestId) {
|
|
561
|
+
// Synchronous: nothing else is meaningful before register.
|
|
562
|
+
if (method === 'executor.register')
|
|
563
|
+
return this.register(params);
|
|
564
|
+
const executor = this.executor;
|
|
565
|
+
if (!executor)
|
|
566
|
+
throw rpcError(JsonRpcErrorCode.InvalidRequest, 'executor.register must come first', false);
|
|
567
|
+
return this.dispatch(executor, method, params, requestId);
|
|
568
|
+
}
|
|
569
|
+
register(params) {
|
|
570
|
+
if (this.executor)
|
|
571
|
+
throw rpcError(JsonRpcErrorCode.InvalidRequest, 'already registered', false);
|
|
572
|
+
if (!params || typeof params !== 'object' || typeof params.protocol !== 'string') {
|
|
573
|
+
throw rpcError(JsonRpcErrorCode.InvalidParams, 'invalid executor.register params', false);
|
|
574
|
+
}
|
|
575
|
+
if (params.protocol !== this.conn.protocol) {
|
|
576
|
+
setImmediate(() => this.conn.close(4003, 'unsupported protocol'));
|
|
577
|
+
throw rpcError(JsonRpcErrorCode.UnsupportedProtocol, 'protocol must match the negotiated subprotocol', false);
|
|
578
|
+
}
|
|
579
|
+
const resumeId = params.resume?.executor_id ?? '';
|
|
580
|
+
let executorId = this.principal.executorId ?? '';
|
|
581
|
+
if (executorId && resumeId && resumeId !== executorId) {
|
|
582
|
+
throw rpcError(JsonRpcErrorCode.Forbidden, 'resume.executor_id does not match the authenticated executor', false);
|
|
583
|
+
}
|
|
584
|
+
if (!executorId)
|
|
585
|
+
executorId = resumeId || `exe_${randomId(8)}`;
|
|
586
|
+
const harness = this.harness;
|
|
587
|
+
const executor = harness.executorFor(executorId);
|
|
588
|
+
const previous = executor.conn;
|
|
589
|
+
executor.conn = this;
|
|
590
|
+
executor.info = params.executor;
|
|
591
|
+
executor.capabilities = params.capabilities ?? {};
|
|
592
|
+
executor.environmentId = params.environment_id;
|
|
593
|
+
executor.protocol = params.protocol;
|
|
594
|
+
executor.dialBack = this.dialBack;
|
|
595
|
+
clearTimeout(executor.leaseTimer);
|
|
596
|
+
executor.leaseTimer = undefined;
|
|
597
|
+
this.executor = executor;
|
|
598
|
+
// The new connection is authoritative (4004 replaced).
|
|
599
|
+
previous?.conn.close(4004, 'replaced by a newer connection');
|
|
600
|
+
// Sessions the executor no longer reports were lost with its old process.
|
|
601
|
+
const keep = new Set(params.resume?.active_sessions ?? []);
|
|
602
|
+
for (const session of executor.sessions()) {
|
|
603
|
+
if (!keep.has(session.id))
|
|
604
|
+
session.end('executor_lost', () => new ToolOutcomeUnknownError());
|
|
605
|
+
}
|
|
606
|
+
setImmediate(() => {
|
|
607
|
+
harness.signal();
|
|
608
|
+
harness.options.onExecutor?.(executor);
|
|
609
|
+
});
|
|
610
|
+
return {
|
|
611
|
+
executor_id: executorId,
|
|
612
|
+
connection_id: this.id,
|
|
613
|
+
server_time: new Date().toISOString(),
|
|
614
|
+
heartbeat_interval_ms: harness.options.heartbeatIntervalMs,
|
|
615
|
+
lease_ttl_ms: harness.options.leaseTtlMs,
|
|
616
|
+
max_control_frame_bytes: MAX_FRAME_BYTES,
|
|
617
|
+
max_mcp_frame_bytes: MAX_FRAME_BYTES,
|
|
618
|
+
drain: executor.draining,
|
|
619
|
+
protocol: params.protocol,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
disconnected() {
|
|
623
|
+
const executor = this.executor;
|
|
624
|
+
if (!executor || executor.conn !== this || executor.gone)
|
|
625
|
+
return;
|
|
626
|
+
executor.conn = undefined;
|
|
627
|
+
executor.leaseTimer = setTimeout(() => {
|
|
628
|
+
if (!executor.conn && !executor.gone)
|
|
629
|
+
executor.forget('lease_expired');
|
|
630
|
+
}, this.harness.options.leaseTtlMs);
|
|
631
|
+
// A lease alone does not keep the process alive; the listener does.
|
|
632
|
+
executor.leaseTimer.unref?.();
|
|
633
|
+
this.harness.signal();
|
|
634
|
+
}
|
|
635
|
+
dispatch(executor, method, params, requestId) {
|
|
636
|
+
const agentResult = this.dispatchAgent(executor, method, params);
|
|
637
|
+
if (agentResult !== undefined)
|
|
638
|
+
return agentResult;
|
|
639
|
+
switch (method) {
|
|
640
|
+
case 'executor.heartbeat': {
|
|
641
|
+
const result = {
|
|
642
|
+
server_time: new Date().toISOString(),
|
|
643
|
+
lease_ttl_ms: this.harness.options.leaseTtlMs,
|
|
644
|
+
drain: executor.draining,
|
|
645
|
+
};
|
|
646
|
+
return result;
|
|
647
|
+
}
|
|
648
|
+
case 'session.ready': {
|
|
649
|
+
const ready = params;
|
|
650
|
+
const session = executor.sessionsById.get(ready?.session_id);
|
|
651
|
+
if (!session)
|
|
652
|
+
throw rpcError(JsonRpcErrorCode.SessionNotActive, 'session is not active on this executor', false);
|
|
653
|
+
if (ready.lease_token && ready.lease_token !== session.leaseToken) {
|
|
654
|
+
throw rpcError(JsonRpcErrorCode.LeaseExpired, 'lease token does not match', false);
|
|
655
|
+
}
|
|
656
|
+
session.markReady();
|
|
657
|
+
return { accepted: true };
|
|
658
|
+
}
|
|
659
|
+
case 'session.ended': {
|
|
660
|
+
const ended = params;
|
|
661
|
+
executor.sessionsById.get(ended?.session_id)?.end(ended.reason, () => new ToolOutcomeUnknownError());
|
|
662
|
+
return { accepted: true };
|
|
663
|
+
}
|
|
664
|
+
case 'tool.result': {
|
|
665
|
+
const result = params;
|
|
666
|
+
const session = executor.sessionsById.get(result?.session_id);
|
|
667
|
+
if (!session)
|
|
668
|
+
throw rpcError(JsonRpcErrorCode.SessionNotActive, 'session is not active on this executor', false);
|
|
669
|
+
if (!session.complete(result)) {
|
|
670
|
+
throw rpcError(JsonRpcErrorCode.NotFound, 'tool_call_id was not dispatched by this harness', false);
|
|
671
|
+
}
|
|
672
|
+
return { accepted: true };
|
|
673
|
+
}
|
|
674
|
+
case 'session.event':
|
|
675
|
+
return this.sessionEvent(executor, params, requestId);
|
|
676
|
+
default:
|
|
677
|
+
throw rpcError(JsonRpcErrorCode.MethodNotFound, `unknown method ${method}`, false);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async sessionEvent(executor, params, requestId) {
|
|
681
|
+
if (!executor.sessionsById.has(params?.session_id)) {
|
|
682
|
+
throw rpcError(JsonRpcErrorCode.SessionNotActive, 'session is not active on this executor', false);
|
|
683
|
+
}
|
|
684
|
+
// A retried JSON-RPC id is answered with the first result.
|
|
685
|
+
const previous = executor.eventIds.get(requestId);
|
|
686
|
+
if (previous)
|
|
687
|
+
return previous;
|
|
688
|
+
let result = { accepted: true, event_id: `evt_${randomId(8)}` };
|
|
689
|
+
if (this.harness.options.onSessionEvent) {
|
|
690
|
+
try {
|
|
691
|
+
result = await this.harness.options.onSessionEvent(executor, params);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
throw rpcError(JsonRpcErrorCode.InternalError, error instanceof Error ? error.message : String(error), true);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (executor.eventIds.size >= MAX_REMEMBERED_EVENT_IDS)
|
|
698
|
+
executor.eventIds.clear();
|
|
699
|
+
executor.eventIds.set(requestId, result);
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
/** Agent methods; undefined for everything else, including session.event without event_id. */
|
|
703
|
+
dispatchAgent(executor, method, params) {
|
|
704
|
+
const platform = this.harness.options.agent;
|
|
705
|
+
if (!platform)
|
|
706
|
+
return undefined;
|
|
707
|
+
switch (method) {
|
|
708
|
+
case 'session.event': {
|
|
709
|
+
const event = params;
|
|
710
|
+
if (!event?.event_id)
|
|
711
|
+
return undefined;
|
|
712
|
+
return this.agentEvent(platform, executor, event);
|
|
713
|
+
}
|
|
714
|
+
case 'interaction.request': {
|
|
715
|
+
const request = params;
|
|
716
|
+
const valid = request?.session_id &&
|
|
717
|
+
request.interaction_id &&
|
|
718
|
+
((request.kind === 'permission' && request.tool_use_id) || (request.kind === 'question' && request.question));
|
|
719
|
+
if (!valid)
|
|
720
|
+
return Promise.reject(rpcError(JsonRpcErrorCode.InvalidParams, 'invalid interaction.request params', false));
|
|
721
|
+
return platform.interactionRequested(executor, request).then((id) => ({ accepted: true, platform_interaction_id: id }), (error) => Promise.reject(rpcError(JsonRpcErrorCode.InternalError, String(error), true)));
|
|
722
|
+
}
|
|
723
|
+
case 'interaction.cancel': {
|
|
724
|
+
const cancel = params;
|
|
725
|
+
if (!cancel?.session_id || !cancel.interaction_id) {
|
|
726
|
+
return Promise.reject(rpcError(JsonRpcErrorCode.InvalidParams, 'invalid interaction.cancel params', false));
|
|
727
|
+
}
|
|
728
|
+
return platform.interactionCanceled(executor, cancel).then(() => ({ accepted: true }), (error) => Promise.reject(rpcError(JsonRpcErrorCode.InternalError, String(error), true)));
|
|
729
|
+
}
|
|
730
|
+
default:
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
agentEvent(platform, executor, params) {
|
|
735
|
+
const event = params.event;
|
|
736
|
+
const type = typeof event?.type === 'string' ? event.type : '';
|
|
737
|
+
const sequence = params.sequence ?? 0;
|
|
738
|
+
if (!params.session_id || !params.turn_id || sequence < 1 || !AGENT_EVENT_TYPES.has(type)) {
|
|
739
|
+
return Promise.reject(rpcError(JsonRpcErrorCode.InvalidParams, 'invalid agent session.event', false));
|
|
740
|
+
}
|
|
741
|
+
const ledger = this.harness.ledger;
|
|
742
|
+
return ledger.serialize(params.session_id, async () => {
|
|
743
|
+
let acked = ledger.acked.get(params.session_id);
|
|
744
|
+
if (acked === undefined) {
|
|
745
|
+
try {
|
|
746
|
+
acked = await platform.ackedSequence(params.session_id);
|
|
747
|
+
}
|
|
748
|
+
catch (error) {
|
|
749
|
+
throw rpcError(JsonRpcErrorCode.InternalError, String(error), true);
|
|
750
|
+
}
|
|
751
|
+
ledger.acked.set(params.session_id, acked);
|
|
752
|
+
}
|
|
753
|
+
const result = { accepted: true, event_id: params.event_id, acked_sequence: acked };
|
|
754
|
+
if (sequence <= acked)
|
|
755
|
+
return result;
|
|
756
|
+
if (sequence !== acked + 1) {
|
|
757
|
+
this.harness.log('warn', `agent session.event sequence gap in ${params.session_id}`);
|
|
758
|
+
throw new RpcError(JsonRpcErrorCode.Conflict, 'conflict', { retryable: false, expected_sequence: acked + 1 });
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
await platform.agentEvent(executor, {
|
|
762
|
+
sessionId: params.session_id,
|
|
763
|
+
turnId: params.turn_id,
|
|
764
|
+
eventId: params.event_id,
|
|
765
|
+
sequence,
|
|
766
|
+
type,
|
|
767
|
+
event: event,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
catch (error) {
|
|
771
|
+
throw rpcError(JsonRpcErrorCode.InternalError, String(error), true);
|
|
772
|
+
}
|
|
773
|
+
ledger.acked.set(params.session_id, sequence);
|
|
774
|
+
return { ...result, acked_sequence: sequence };
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
onNotification(method, params) {
|
|
778
|
+
const executor = this.executor;
|
|
779
|
+
if (!executor)
|
|
780
|
+
return;
|
|
781
|
+
const options = this.harness.options;
|
|
782
|
+
switch (method) {
|
|
783
|
+
case 'session.event_delta': {
|
|
784
|
+
const delta = params;
|
|
785
|
+
if (options.agent && delta?.session_id && delta.event_id)
|
|
786
|
+
options.agent.agentDelta(executor, delta);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
case 'session.status':
|
|
790
|
+
options.onSessionStatus?.(executor, params);
|
|
791
|
+
return;
|
|
792
|
+
case 'tool.progress':
|
|
793
|
+
options.onToolProgress?.(executor, params);
|
|
794
|
+
return;
|
|
795
|
+
case 'executor.capabilities.update': {
|
|
796
|
+
const capabilities = params?.capabilities;
|
|
797
|
+
if (capabilities)
|
|
798
|
+
executor.capabilities = capabilities;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
case 'executor.close':
|
|
802
|
+
// A deliberate shutdown: no reconnect grace.
|
|
803
|
+
executor.forget('executor_closed');
|
|
804
|
+
this.conn.close(1000, 'executor.close');
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
//# sourceMappingURL=harness.js.map
|