@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.
- package/README.md +1 -1
- package/dist/runtime/agent/budget.d.ts +43 -24
- package/dist/runtime/agent/budget.js +108 -57
- package/dist/runtime/agent/events.d.ts +1 -1
- package/dist/runtime/broker_child_dispatcher.d.ts +5 -2
- package/dist/runtime/broker_child_dispatcher.js +24 -6
- package/dist/runtime/budget_gate.d.ts +58 -21
- package/dist/runtime/budget_gate.js +227 -46
- package/dist/runtime/host_capabilities.d.ts +4 -0
- package/dist/runtime/host_capabilities.js +25 -0
- package/dist/runtime/host_server.d.ts +137 -0
- package/dist/runtime/host_server.js +562 -0
- package/dist/runtime/index.js +45 -12
- package/dist/runtime/leaf_executor.d.ts +7 -4
- package/dist/runtime/leaf_executor.js +11 -6
- package/dist/runtime/program_runner.d.ts +62 -42
- package/dist/runtime/program_runner.js +156 -101
- package/dist/runtime/program_worker.d.ts +22 -11
- package/dist/runtime/program_worker.js +26 -48
- package/dist/runtime/python_program.d.ts +68 -0
- package/dist/runtime/python_program.js +270 -0
- package/dist/runtime/run_context.d.ts +13 -0
- package/dist/runtime/run_context.js +77 -0
- package/dist/runtime/runner_control_client.d.ts +23 -0
- package/dist/runtime/runner_control_client.js +69 -147
- package/dist/runtime/shell_exec.d.ts +17 -0
- package/dist/runtime/shell_exec.js +144 -0
- package/dist/runtime/support/index.d.ts +6 -0
- package/dist/runtime/support/index.js +14 -0
- package/dist/runtime/wire/inference_proxy.js +1 -1
- package/dist/runtime/wire/run.d.ts +5 -2
- package/dist/runtime/workflow_host.d.ts +53 -7
- package/dist/runtime/workflow_host.js +82 -42
- 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
|
+
}
|