@boardwalk-labs/runner 0.2.17 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/dist/runtime/agent/budget.d.ts +43 -24
  3. package/dist/runtime/agent/budget.js +108 -57
  4. package/dist/runtime/agent/events.d.ts +1 -1
  5. package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
  6. package/dist/runtime/broker_child_dispatcher.js +24 -6
  7. package/dist/runtime/budget_gate.d.ts +58 -21
  8. package/dist/runtime/budget_gate.js +227 -46
  9. package/dist/runtime/host_capabilities.d.ts +4 -0
  10. package/dist/runtime/host_capabilities.js +25 -0
  11. package/dist/runtime/host_server.d.ts +137 -0
  12. package/dist/runtime/host_server.js +562 -0
  13. package/dist/runtime/index.js +45 -12
  14. package/dist/runtime/leaf_executor.d.ts +7 -4
  15. package/dist/runtime/leaf_executor.js +11 -6
  16. package/dist/runtime/program_runner.d.ts +62 -42
  17. package/dist/runtime/program_runner.js +156 -101
  18. package/dist/runtime/program_worker.d.ts +22 -11
  19. package/dist/runtime/program_worker.js +26 -48
  20. package/dist/runtime/python_program.d.ts +68 -0
  21. package/dist/runtime/python_program.js +270 -0
  22. package/dist/runtime/run_context.d.ts +13 -0
  23. package/dist/runtime/run_context.js +77 -0
  24. package/dist/runtime/runner_control_client.d.ts +23 -0
  25. package/dist/runtime/runner_control_client.js +69 -147
  26. package/dist/runtime/shell_exec.d.ts +17 -0
  27. package/dist/runtime/shell_exec.js +144 -0
  28. package/dist/runtime/support/index.d.ts +6 -0
  29. package/dist/runtime/support/index.js +14 -0
  30. package/dist/runtime/wire/inference_proxy.js +1 -1
  31. package/dist/runtime/wire/run.d.ts +5 -2
  32. package/dist/runtime/workflow_host.d.ts +53 -7
  33. package/dist/runtime/workflow_host.js +82 -42
  34. package/package.json +4 -3
@@ -0,0 +1,562 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // WorkflowHostServer — the runner's reference server for the program↔host protocol (the
3
+ // workflow-format redesign, P0). JSON-RPC 2.0 over a local stream socket (a Unix domain socket,
4
+ // or a named pipe on win32), framed as newline-delimited JSON. **Runner = server, SDK = client**;
5
+ // the wire contract is the published SDK's `protocol.ts` (`clientToHostRequests`,
6
+ // `hostToClientRequests`, notifications) — params are validated with those exact schemas, so the
7
+ // server can never drift from what the client speaks.
8
+ //
9
+ // The protocol is FULL-DUPLEX:
10
+ // - client → host requests: `bootstrap` / `report_return` (loader-only) + one method per
11
+ // author capability, each dispatched onto the injected {@link HostCapabilities} seam.
12
+ // - host → client requests: `tool_invoke` — how an inline `agent()` tool declared in the
13
+ // program runs. The leaf loop stays host-side; the wire carries DECLARATIONS only, and this
14
+ // server turns each declaration into an engine `ToolDef` whose `execute()` round-trips the
15
+ // call to the program. A handler error (a JSON-RPC error response) becomes an ordinary
16
+ // thrown `Error` from `execute()`, which the engine feeds to the model as a tool-error
17
+ // result — NEVER run-fatal. Invocations multiplex by their own ids and dispatch
18
+ // concurrently; a late response to an abandoned invocation is discarded by id.
19
+ // - host → client notification: `cancel` — sent when the run's abort signal fires; the SDK
20
+ // aborts `context.signal`.
21
+ //
22
+ // Errors cross as `{code, message, data?}` with a STRING `code` from the engine taxonomy (the
23
+ // protocol's one deliberate deviation from base JSON-RPC): a thrown value's own SCREAMING_SNAKE
24
+ // `code` when it has one (AppError / EngineError / Node syscall codes all do), the error's class
25
+ // name otherwise, and a `RunAbortedError` maps to the run-fatal `CANCELLED` so `parallel()`
26
+ // re-throws it.
27
+ //
28
+ // `report_return` validates the program's return against the stored `output_schema` (Ajv,
29
+ // structural — `validateFormats` off, matching the revive pass's honesty about formats); a
30
+ // mismatch is a VALIDATION_FAILED error the loader turns into the run's curated failure.
31
+ import * as net from "node:net";
32
+ import { randomBytes } from "node:crypto";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+ import { rm } from "node:fs/promises";
36
+ import { Ajv } from "ajv";
37
+ import { clientToHostRequests, clientToHostNotifications, rpcFrameSchema, } from "@boardwalk-labs/workflow/runtime";
38
+ import { AppError, ErrorCode, createLogger, errorCodeOf } from "./support/index.js";
39
+ import { RunAbortedError } from "./run_abort.js";
40
+ /** The loader-side curation hint for a `report_return` output-schema mismatch. Shared by the
41
+ * in-process TS loader and the Python subprocess path so the author sees ONE message. */
42
+ export const OUTPUT_MISMATCH_HINT = "Return a value matching run()'s declared return type, or update the type and redeploy.";
43
+ const log = createLogger("HostServer");
44
+ /** Apply the wire's optional `since` filter (epoch-ms, inclusive) to timestamped entries. */
45
+ function filterSince(entries, since) {
46
+ return since === undefined ? entries : entries.filter((e) => e.timestamp >= since);
47
+ }
48
+ /** One connected protocol client (the program process; several may connect). */
49
+ class HostConnection {
50
+ socket;
51
+ onFrame;
52
+ buffer = "";
53
+ /** Ids of OUR outbound (host → client) requests awaiting a response. */
54
+ pendingInvokes = new Map();
55
+ constructor(socket, onFrame) {
56
+ this.socket = socket;
57
+ this.onFrame = onFrame;
58
+ socket.setEncoding("utf8");
59
+ socket.on("data", (chunk) => {
60
+ this.buffer += chunk;
61
+ let newline = this.buffer.indexOf("\n");
62
+ while (newline !== -1) {
63
+ const line = this.buffer.slice(0, newline).trim();
64
+ this.buffer = this.buffer.slice(newline + 1);
65
+ if (line !== "")
66
+ this.onLine(line);
67
+ newline = this.buffer.indexOf("\n");
68
+ }
69
+ });
70
+ }
71
+ onLine(line) {
72
+ let value;
73
+ try {
74
+ value = JSON.parse(line);
75
+ }
76
+ catch {
77
+ log.warn("host_server_non_json_line");
78
+ return;
79
+ }
80
+ this.onFrame(this, value);
81
+ }
82
+ send(frame) {
83
+ if (this.socket.destroyed)
84
+ return;
85
+ this.socket.write(JSON.stringify(frame) + "\n");
86
+ }
87
+ }
88
+ /**
89
+ * The protocol server for ONE run. `listen()` binds the socket (the runner then exports the
90
+ * path as `BOARDWALK_HOST_SOCK`); `close()` tears everything down. The validated return the
91
+ * program reported is read via {@link reportedReturn} after the loader completes.
92
+ */
93
+ export class WorkflowHostServer {
94
+ deps;
95
+ server;
96
+ connections = new Set();
97
+ /** sessionId → live handle, backing `computer.browser.*` and `agent({ session })`. */
98
+ browserSessions = new Map();
99
+ validateOutput;
100
+ nextInvokeId = 1;
101
+ sockPath = null;
102
+ returned = null;
103
+ reportFailure = null;
104
+ cancelled = false;
105
+ onAbort = () => {
106
+ this.notifyCancel();
107
+ };
108
+ constructor(deps) {
109
+ this.deps = deps;
110
+ this.server = net.createServer((socket) => {
111
+ const conn = new HostConnection(socket, (c, frame) => {
112
+ this.onFrame(c, frame);
113
+ });
114
+ this.connections.add(conn);
115
+ socket.on("close", () => {
116
+ this.connections.delete(conn);
117
+ const closed = new Error("the program connection closed before the tool responded");
118
+ for (const pending of conn.pendingInvokes.values())
119
+ pending.reject(closed);
120
+ conn.pendingInvokes.clear();
121
+ });
122
+ socket.on("error", () => {
123
+ socket.destroy();
124
+ });
125
+ // A client connecting after the cancel still learns of it (the notification is a level,
126
+ // not an edge, from the program's point of view).
127
+ if (this.cancelled)
128
+ conn.send(cancelFrame());
129
+ });
130
+ this.validateOutput = compileOutputValidator(deps.outputSchema);
131
+ if (deps.signal !== undefined) {
132
+ if (deps.signal.aborted)
133
+ this.cancelled = true;
134
+ else
135
+ deps.signal.addEventListener("abort", this.onAbort, { once: true });
136
+ }
137
+ }
138
+ /** Bind the socket and resolve its path (a Unix socket path; a named pipe on win32). */
139
+ async listen() {
140
+ const suffix = randomBytes(6).toString("hex");
141
+ const path = process.platform === "win32"
142
+ ? `\\\\.\\pipe\\bw-host-${suffix}`
143
+ : join(this.deps.sockDir ?? tmpdir(), `bw-host-${suffix}.sock`);
144
+ await new Promise((resolve, reject) => {
145
+ this.server.once("error", reject);
146
+ this.server.listen(path, () => {
147
+ this.server.off("error", reject);
148
+ resolve();
149
+ });
150
+ });
151
+ this.sockPath = path;
152
+ return path;
153
+ }
154
+ /** The bound socket path; null before `listen()`. */
155
+ get socketPath() {
156
+ return this.sockPath;
157
+ }
158
+ /** The validated value the program's loader reported via `report_return`, or null when no
159
+ * return was reported (the program never finished, or returned void ⇒ the client sent null). */
160
+ reportedReturn() {
161
+ return this.returned?.value ?? null;
162
+ }
163
+ /** Whether `report_return` was received at all (distinguishes "returned null" from "never
164
+ * reported" for callers that care). */
165
+ hasReturn() {
166
+ return this.returned !== null;
167
+ }
168
+ /** The error the most recent `report_return` attempt failed with (the output-schema mismatch),
169
+ * or null when none failed / a later report succeeded. An IN-PROCESS loader re-throws this
170
+ * error itself; a SUBPROCESS loader (Python) can only propagate it as a traceback + non-zero
171
+ * exit, so the runner reads it back here to curate the run's failure with the original
172
+ * code/message instead of the traceback's last line. */
173
+ reportReturnFailure() {
174
+ return this.returned !== null ? null : this.reportFailure;
175
+ }
176
+ /** Push the `cancel` notification to every connected client (idempotent). */
177
+ notifyCancel(reason) {
178
+ if (this.cancelled)
179
+ return;
180
+ this.cancelled = true;
181
+ for (const conn of this.connections)
182
+ conn.send(cancelFrame(reason));
183
+ }
184
+ /** Tear the server down: reject in-flight tool invokes, destroy connections, unlink the socket. */
185
+ async close() {
186
+ // Drain first: a fire-and-forget `phase` notification sent moments before a program throw is
187
+ // in flight on the loopback socket — give the event loop a couple of full turns (each with a
188
+ // poll phase) so already-sent frames dispatch before teardown. The fire-and-forget contract
189
+ // loses frames never SENT, not frames already on the wire.
190
+ for (let i = 0; i < 2; i++) {
191
+ await new Promise((resolve) => setTimeout(resolve, 0));
192
+ }
193
+ this.deps.signal?.removeEventListener("abort", this.onAbort);
194
+ for (const conn of this.connections) {
195
+ const closed = new Error("the host server is shutting down");
196
+ for (const pending of conn.pendingInvokes.values())
197
+ pending.reject(closed);
198
+ conn.pendingInvokes.clear();
199
+ conn.socket.destroy();
200
+ }
201
+ this.connections.clear();
202
+ await new Promise((resolve) => {
203
+ this.server.close(() => {
204
+ resolve();
205
+ });
206
+ });
207
+ if (this.sockPath !== null && process.platform !== "win32") {
208
+ await rm(this.sockPath, { force: true }).catch(() => undefined);
209
+ }
210
+ }
211
+ // -- frame routing ---------------------------------------------------------
212
+ onFrame(conn, raw) {
213
+ const parsed = rpcFrameSchema.safeParse(raw);
214
+ if (!parsed.success) {
215
+ // Malformed frame: answer with a null-id error when we can't even read an id (JSON-RPC 2.0).
216
+ conn.send({
217
+ jsonrpc: "2.0",
218
+ id: null,
219
+ error: { code: "PROTOCOL_ERROR", message: "malformed JSON-RPC frame" },
220
+ });
221
+ return;
222
+ }
223
+ const frame = parsed.data;
224
+ if ("method" in frame) {
225
+ if ("id" in frame) {
226
+ // Deliberately not awaited: requests dispatch CONCURRENTLY (a parked humanInput must not
227
+ // block a sibling agent call; parallel() multiplexes by JSON-RPC id).
228
+ void this.handleRequest(conn, frame.id, frame.method, frame.params);
229
+ }
230
+ else {
231
+ this.handleNotification(frame.method, frame.params);
232
+ }
233
+ return;
234
+ }
235
+ // A response frame — settle the matching outbound tool_invoke; unknown/late ids are discarded.
236
+ if ("error" in frame) {
237
+ if (frame.id !== null) {
238
+ this.settleInvoke(conn, frame.id, (pending) => {
239
+ pending.reject(new Error(frame.error.message));
240
+ });
241
+ }
242
+ return;
243
+ }
244
+ this.settleInvoke(conn, frame.id, (pending) => {
245
+ // The client's tool result is `{output}` per the wire contract; tolerate a malformed one
246
+ // by surfacing it as a tool error rather than crashing the leaf.
247
+ const result = frame.result;
248
+ if (result === null || result === undefined || !("output" in result)) {
249
+ pending.reject(new Error("tool_invoke response carried no output"));
250
+ return;
251
+ }
252
+ pending.resolve({ output: result.output ?? null });
253
+ });
254
+ }
255
+ settleInvoke(conn, id, apply) {
256
+ if (typeof id !== "number")
257
+ return;
258
+ const pending = conn.pendingInvokes.get(id);
259
+ if (pending === undefined)
260
+ return; // late response to an abandoned invocation — discarded
261
+ conn.pendingInvokes.delete(id);
262
+ apply(pending);
263
+ }
264
+ handleNotification(method, params) {
265
+ if (method !== "phase")
266
+ return; // unknown notifications are ignored (additive forward-compat)
267
+ const parsed = clientToHostNotifications.phase.params.safeParse(params);
268
+ if (!parsed.success) {
269
+ log.warn("host_server_bad_phase_params", { error: parsed.error.message });
270
+ return;
271
+ }
272
+ // Fire-and-forget contract: a phase failure is logged, never surfaced to the program.
273
+ try {
274
+ this.deps.capabilities.phase(parsed.data.name, pruneUndefined(parsed.data.opts));
275
+ }
276
+ catch (err) {
277
+ log.warn("host_server_phase_failed", {
278
+ error: err instanceof Error ? err.message : String(err),
279
+ });
280
+ }
281
+ }
282
+ async handleRequest(conn, id, method, params) {
283
+ if (!isHostMethod(method)) {
284
+ conn.send({
285
+ jsonrpc: "2.0",
286
+ id,
287
+ error: { code: "METHOD_NOT_FOUND", message: `unknown method "${method}"` },
288
+ });
289
+ return;
290
+ }
291
+ const parsed = clientToHostRequests[method].params.safeParse(params);
292
+ if (!parsed.success) {
293
+ conn.send({
294
+ jsonrpc: "2.0",
295
+ id,
296
+ error: {
297
+ code: "INVALID_PARAMS",
298
+ message: `malformed ${method} params: ${parsed.error.message}`,
299
+ },
300
+ });
301
+ return;
302
+ }
303
+ try {
304
+ // The schema that ran IS clientToHostRequests[method].params, so the params reaching the
305
+ // handler map are exact.
306
+ const result = await this.dispatch(method, parsed.data, { conn, id });
307
+ conn.send({ jsonrpc: "2.0", id, result });
308
+ }
309
+ catch (err) {
310
+ // Remember a report_return failure for {@link reportReturnFailure} — a subprocess loader
311
+ // cannot re-throw it to the runner the way the in-process loader does.
312
+ if (method === "report_return") {
313
+ this.reportFailure = err instanceof Error ? err : new Error(String(err));
314
+ }
315
+ conn.send({ jsonrpc: "2.0", id, error: protocolErrorOf(err) });
316
+ }
317
+ }
318
+ // -- method dispatch -------------------------------------------------------
319
+ dispatch(method, params, req) {
320
+ return this.handlers[method](params, req);
321
+ }
322
+ get caps() {
323
+ return this.deps.capabilities;
324
+ }
325
+ /**
326
+ * One handler per client→host method, each typed by ITS OWN `HostMethodParams`/`HostMethodResult`
327
+ * pair via the mapped type — params arrive already narrowed (no per-case casts) and a handler
328
+ * returning another method's result shape is a COMPILE error (a switch over the union couldn't
329
+ * catch that). Exhaustive by construction: a new wire method fails the build until a handler
330
+ * exists. `req` carries the originating connection + request id for `agent`, whose inline tools
331
+ * round-trip `tool_invoke` on that same connection, correlated by this request's id.
332
+ */
333
+ handlers = {
334
+ bootstrap: () => {
335
+ const b = this.deps.bootstrap;
336
+ return Promise.resolve({ input: b.input, input_schema: b.inputSchema, context: b.context });
337
+ },
338
+ report_return: ({ value }) => {
339
+ // A schema mismatch throws out of the sync body — dispatch's caller sees a rejection either
340
+ // way, since handleRequest awaits the handler inside its own try.
341
+ this.assertReturnMatchesSchema(value);
342
+ this.returned = { value };
343
+ return Promise.resolve({});
344
+ },
345
+ agent: async (p, req) => {
346
+ const opts = this.toAgentOptions(req.conn, req.id, p.opts);
347
+ const output = await this.caps.agent(p.prompt, opts);
348
+ return { output: asJsonValue(output) };
349
+ },
350
+ "workflows.call": async (p) => {
351
+ const result = await this.caps.callWorkflow(p.slug, p.input, pruneUndefined(p.opts));
352
+ return { output: asJsonValue(result.output), output_schema: result.outputSchema };
353
+ },
354
+ "workflows.run": async (p) => ({
355
+ runId: await this.caps.runWorkflow(p.slug, p.input, pruneUndefined(p.opts)),
356
+ }),
357
+ "workflows.schedule": async (p) => ({
358
+ scheduleId: await this.caps.scheduleWorkflow(p.slug, p.input, pruneUndefined(p.opts)),
359
+ }),
360
+ sleep: async (p) => {
361
+ await this.caps.sleep(p.arg);
362
+ return {};
363
+ },
364
+ // The wire schema mirrors HumanInputOptions field-for-field; pruning the zod
365
+ // explicit-undefined optionals makes the shapes exact.
366
+ humanInput: async (p) => ({
367
+ result: await this.caps.humanInput(pruneUndefined(p.opts)),
368
+ }),
369
+ "secrets.get": async (p) => ({ value: await this.caps.getSecret(p.name) }),
370
+ "artifacts.write": async (p) => {
371
+ const body = p.body.encoding === "utf8"
372
+ ? p.body.data
373
+ : new Uint8Array(Buffer.from(p.body.data, "base64"));
374
+ const ref = await this.caps.writeArtifact(p.name, p.contentType, body, p.metadata);
375
+ return { ref: { id: ref.id, name: ref.name, url: ref.url } };
376
+ },
377
+ "computer.openBrowser": async (p) => {
378
+ const session = await this.caps.openBrowser(pruneUndefined(p.opts));
379
+ this.browserSessions.set(session.id, session);
380
+ return { sessionId: session.id };
381
+ },
382
+ "computer.browser.navigate": async (p) => {
383
+ await this.session(p.sessionId).navigate(p.url);
384
+ return {};
385
+ },
386
+ "computer.browser.url": async (p) => ({ url: await this.session(p.sessionId).url() }),
387
+ "computer.browser.title": async (p) => ({ title: await this.session(p.sessionId).title() }),
388
+ "computer.browser.screenshot": async (p) => {
389
+ const ref = await this.session(p.sessionId).screenshot(p.fullPage !== undefined ? { fullPage: p.fullPage } : undefined);
390
+ return { ref: { id: ref.id, name: ref.name, url: ref.url } };
391
+ },
392
+ "computer.browser.console": async (p) => ({
393
+ entries: filterSince(await this.session(p.sessionId).console(), p.since),
394
+ }),
395
+ "computer.browser.network": async (p) => ({
396
+ entries: filterSince(await this.session(p.sessionId).network(), p.since),
397
+ }),
398
+ "computer.browser.eval": async (p) => ({
399
+ value: asJsonValue(await this.session(p.sessionId).eval(p.expression)),
400
+ }),
401
+ "computer.browser.close": async (p) => {
402
+ const session = this.browserSessions.get(p.sessionId);
403
+ this.browserSessions.delete(p.sessionId);
404
+ if (session !== undefined)
405
+ await session.close();
406
+ return {};
407
+ },
408
+ shell: async (p) => await this.caps.shell(p.cmd, pruneUndefined(p.opts)),
409
+ "auth.idToken": async (p) => ({ token: await this.caps.idToken(p.audience) }),
410
+ "auth.apiToken": async () => ({ token: await this.caps.apiToken() }),
411
+ "usage.get": async () => await this.caps.usage(),
412
+ };
413
+ /** A live browser session by id, or a clear VALIDATION error for a closed/foreign one. */
414
+ session(sessionId) {
415
+ const session = this.browserSessions.get(sessionId);
416
+ if (session === undefined) {
417
+ throw Object.assign(new Error(`no open browser session "${sessionId}" in this run`), {
418
+ code: "VALIDATION",
419
+ });
420
+ }
421
+ return session;
422
+ }
423
+ /** Validate the program's return against the declared output schema (P3.4): a mismatch fails
424
+ * the run — the loader's `reportReturn` rejects and the failure is curated. `null` (a void
425
+ * return) skips validation per the contract. */
426
+ assertReturnMatchesSchema(value) {
427
+ if (this.validateOutput === null || value === null)
428
+ return;
429
+ if (this.validateOutput(value))
430
+ return;
431
+ const detail = (this.validateOutput.errors ?? [])
432
+ .map((e) => `${e.instancePath === "" ? "(root)" : e.instancePath} ${e.message ?? "invalid"}`)
433
+ .join("; ");
434
+ throw new AppError(ErrorCode.VALIDATION_FAILED, `run() returned a value that does not match the workflow's declared output_schema: ${detail}`, { errors: this.validateOutput.errors ?? [] });
435
+ }
436
+ // -- inline agent() tools (the tool_invoke callback lane) ------------------
437
+ /** Wire tool declarations → engine `ToolDef`s whose `execute()` round-trips `tool_invoke` to
438
+ * the program, correlated by `call_id` = the originating agent request's own id (stringified).
439
+ * Also resolves a wire `sessionId` back to its live browser session. */
440
+ toAgentOptions(conn, agentRequestId, wire) {
441
+ if (wire === undefined)
442
+ return undefined;
443
+ const { tools, sessionId, ...rest } = wire;
444
+ const callId = String(agentRequestId);
445
+ return {
446
+ ...(pruneUndefined(rest) ?? {}),
447
+ ...(tools !== undefined && tools.length > 0
448
+ ? {
449
+ tools: tools.map((t) => ({
450
+ name: t.name,
451
+ description: t.description,
452
+ inputSchema: t.input_schema,
453
+ execute: async (input) => (await this.invokeTool(conn, callId, t.name, asJsonValue(input))).output,
454
+ })),
455
+ }
456
+ : {}),
457
+ ...(sessionId !== undefined ? { session: this.session(sessionId) } : {}),
458
+ };
459
+ }
460
+ /** One host → client `tool_invoke` round-trip. Concurrent invocations multiplex by this
461
+ * request's own JSON-RPC id; the optional host-side timeout abandons the call (its late
462
+ * response is discarded by id) and throws an ordinary Error — a tool-error result to the
463
+ * model, never run-fatal. */
464
+ invokeTool(conn, callId, tool, input) {
465
+ const id = this.nextInvokeId++;
466
+ const timeoutMs = this.deps.toolInvokeTimeoutMs;
467
+ return new Promise((resolve, reject) => {
468
+ let timer = null;
469
+ if (timeoutMs !== undefined) {
470
+ timer = setTimeout(() => {
471
+ conn.pendingInvokes.delete(id); // abandon: the late response will be discarded by id
472
+ reject(new Error(`inline tool "${tool}" timed out after ${String(timeoutMs)}ms`));
473
+ }, timeoutMs);
474
+ }
475
+ conn.pendingInvokes.set(id, {
476
+ resolve: (value) => {
477
+ if (timer !== null)
478
+ clearTimeout(timer);
479
+ resolve(value);
480
+ },
481
+ reject: (reason) => {
482
+ if (timer !== null)
483
+ clearTimeout(timer);
484
+ reject(reason);
485
+ },
486
+ });
487
+ conn.send({
488
+ jsonrpc: "2.0",
489
+ id,
490
+ method: "tool_invoke",
491
+ params: { call_id: callId, tool, input },
492
+ });
493
+ });
494
+ }
495
+ }
496
+ // -- helpers -----------------------------------------------------------------
497
+ function cancelFrame(reason) {
498
+ return {
499
+ jsonrpc: "2.0",
500
+ method: "cancel",
501
+ params: reason !== undefined ? { reason } : {},
502
+ };
503
+ }
504
+ function isHostMethod(method) {
505
+ return Object.prototype.hasOwnProperty.call(clientToHostRequests, method);
506
+ }
507
+ function pruneUndefined(value) {
508
+ if (value === undefined)
509
+ return undefined;
510
+ const out = {};
511
+ for (const [k, v] of Object.entries(value))
512
+ if (v !== undefined)
513
+ out[k] = v;
514
+ return out;
515
+ }
516
+ /** Boundary cast: capability results originate as JSON (broker HTTP bodies, model text, parsed
517
+ * model JSON), so they are wire-safe by construction; the type system just can't see it. */
518
+ function asJsonValue(value) {
519
+ return (value ?? null);
520
+ }
521
+ /** Map a thrown value to the wire's `{code, message, data?}` (string code, engine taxonomy).
522
+ * An engine-style `hint` (the one-line "what to do") rides `data.hint` so it SURVIVES the wire —
523
+ * the loader's failure curation reads it back into the run's `output.error.hint` (the
524
+ * hint-reaches-hosted-authors contract). */
525
+ export function protocolErrorOf(err) {
526
+ if (err instanceof RunAbortedError) {
527
+ // CANCELLED is the run-fatal code `isRunFatal`/`parallel()` branch on — a run-level abort
528
+ // (user cancel / credit stop) must abort the whole program, never be isolated.
529
+ return { code: "CANCELLED", message: err.message, data: { reason: err.reason } };
530
+ }
531
+ const message = err instanceof Error ? err.message : String(err);
532
+ const code = errorCodeOf(err) ??
533
+ (err instanceof Error && err.name !== ""
534
+ ? err.name
535
+ : // "INTERNAL" (the engine taxonomy's member) — the local engine's server uses the same
536
+ // fallback, so a program's `catch` sees one code regardless of engine.
537
+ "INTERNAL");
538
+ const rawHint = typeof err === "object" && err !== null ? err.hint : undefined;
539
+ const dataParts = {};
540
+ if (err instanceof AppError && err.detail !== undefined)
541
+ dataParts.detail = err.detail;
542
+ if (typeof rawHint === "string" && rawHint !== "")
543
+ dataParts.hint = rawHint;
544
+ return { code, message, ...(Object.keys(dataParts).length > 0 ? { data: dataParts } : {}) };
545
+ }
546
+ /** Compile the output-schema validator: structural (formats off — same honesty as the revive
547
+ * pass), lax about unknown keywords (`contentEncoding` etc.). A schema that will not compile is
548
+ * a platform bug in the deriver — warned and skipped (fail-soft), never a run failure. */
549
+ function compileOutputValidator(schema) {
550
+ if (schema === null)
551
+ return null;
552
+ try {
553
+ const ajv = new Ajv({ strict: false, validateFormats: false });
554
+ return ajv.compile(schema);
555
+ }
556
+ catch (err) {
557
+ log.warn("host_server_output_schema_uncompilable", {
558
+ error: err instanceof Error ? err.message : String(err),
559
+ });
560
+ return null;
561
+ }
562
+ }