@boardwalk-labs/runner 0.2.17 → 0.3.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.
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
@@ -1,10 +1,70 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ // The budget gate (docs/SUSPEND_POLICY.md Decision 3): when a run hits ANY of its budget caps —
3
+ // `max_usd`, `max_tokens`, or `max_compute_seconds` — PARK and ask a person instead of failing the
4
+ // run.
5
+ //
6
+ // Why park rather than throw: on the snapshot fleet a park costs a memory snapshot and releases the
7
+ // host, so "wait for a human" is effectively free — while a hard failure discards everything the run
8
+ // has done (a real run lost ~40 minutes of paid agent work to a cap breach with no warning). The cap
9
+ // stops being a cliff and becomes a checkpoint.
10
+ //
11
+ // This module is the POLICY (when to park, what to ask, what an answer means). The MECHANISM is the
12
+ // host's `budgetClearance` (register-without-release + freeze), which is the same machinery any
13
+ // `humanInput()` gate uses.
14
+ //
15
+ // ## Park points (where `clear()` is awaited)
16
+ //
17
+ // - the leaf executor's `streamModel` seam, before EVERY model call — the only place `usd` and
18
+ // `tokens` can move, so they always park here within one in-flight turn of the breach;
19
+ // - the workflow host's blocking/spending capability seams (`sleep`, `shell`, `workflows.call`) —
20
+ // `compute` burns continuously, so a breach between model calls parks at whichever capability
21
+ // the program touches next;
22
+ // - between seams, {@link ComputeBreachWatcher} detects a compute breach on a timer. It cannot
23
+ // itself freeze the VM (see its doc block — true park-anywhere needs fleet-host coordination),
24
+ // so its job is prompt detection + logging; the park still lands at the next seam above.
25
+ //
26
+ // ## The answer wire format (what backend/web/CLI must send back)
27
+ //
28
+ // The gate registers as an ordinary human-input row, key `budget`, with a `choice` input spec:
29
+ //
30
+ // { kind: "choice", options: [<dimension presets>, "cancel"], other: true }
31
+ //
32
+ // The presets are dimension-native (render them as the row's buttons verbatim):
33
+ //
34
+ // usd "+$10" | "+$25" | "+$100"
35
+ // tokens "+100k" | "+1M" | "+10M"
36
+ // compute "+15min" | "+1h" | "+4h"
37
+ //
38
+ // The ANSWER is a single string (a chosen option, or a free-form value via the choice's `other`),
39
+ // interpreted against the dimension the gate asked about (the runner knows which dimension parked;
40
+ // the answer does not repeat it). Grammar, per dimension — `+` prefix = INCREMENT on the current
41
+ // cap, no prefix = ABSOLUTE new cap:
42
+ //
43
+ // usd "+$25", "+25" → cap + 25 dollars; "50", "$50" → cap = $50
44
+ // tokens "+100k", "+1M", "+2500000" → cap + that many; "5M", "750k", "2000000" → cap = that
45
+ // (k = thousand, M = million, case-insensitive, fractions allowed: "+2.5M")
46
+ // compute "+15min", "+1h", "+90s", "+600" → cap + that; "2h", "90min", "5400" → cap = that
47
+ // (bare number = seconds; s / min / m = minutes / h accepted, fractions allowed: "1.5h")
48
+ //
49
+ // "cancel" (any case), an empty answer, an unparseable answer, or an absolute value at or below the
50
+ // current cap (it would re-park instantly) all CANCEL the run — never guess at a misread answer,
51
+ // resuming spends real money. An approved increment too small to clear the breach re-asks (the
52
+ // `clear()` loop), so a responder can never resume a run into an instant re-park.
53
+ //
54
+ // NOTE (inherited from the usd-only gate): concurrent leaves that breach together each register
55
+ // their own `budget` gate row; answering each with an increment applies each increment. Approvals
56
+ // are explicit per-row human actions, so this compounds by design.
57
+ import { createLogger } from "./support/index.js";
58
+ const log = createLogger("BudgetGate");
2
59
  /** The stable key a responder answers a budget gate by (`boardwalk respond <runId> budget …`). */
3
60
  export const BUDGET_GATE_KEY = "budget";
4
- /** Preset approvals, plus the two open-ended answers. Kept small: the point is a fast decision. */
5
- export const BUDGET_CHOICE_ADD_10 = "+$10";
6
- export const BUDGET_CHOICE_ADD_25 = "+$25";
7
- export const BUDGET_CHOICE_ADD_100 = "+$100";
61
+ /** Preset approvals per dimension, plus the open-ended `other` answer. Kept small: the point is a
62
+ * fast decision. These strings ARE the wire options — backend/web render them verbatim. */
63
+ export const BUDGET_PRESETS = {
64
+ usd: ["+$10", "+$25", "+$100"],
65
+ tokens: ["+100k", "+1M", "+10M"],
66
+ compute: ["+15min", "+1h", "+4h"],
67
+ };
8
68
  export const BUDGET_CHOICE_CANCEL = "cancel";
9
69
  /** Raised when a responder answers `cancel`: the run stops, deliberately, at the user's word. */
10
70
  export class BudgetGateCancelled extends Error {
@@ -17,51 +77,95 @@ export class BudgetGateCancelled extends Error {
17
77
  function usd(n) {
18
78
  return `$${n.toFixed(2)}`;
19
79
  }
80
+ /** Token counts in a prompt: grouped digits ("1,200,000") — exact beats approximate at a gate. */
81
+ function tokens(n) {
82
+ return Math.round(n).toLocaleString("en-US");
83
+ }
84
+ /** Compute time in a prompt: "1h 15m", "45m", "30s" — seconds only below a minute. */
85
+ function computeTime(totalSeconds) {
86
+ const s = Math.max(0, Math.round(totalSeconds));
87
+ const h = Math.floor(s / 3600);
88
+ const m = Math.floor((s % 3600) / 60);
89
+ const sec = s % 60;
90
+ const parts = [];
91
+ if (h > 0)
92
+ parts.push(`${String(h)}h`);
93
+ if (m > 0)
94
+ parts.push(`${String(m)}m`);
95
+ if (parts.length === 0 || (h === 0 && sec > 0))
96
+ parts.push(`${String(sec)}s`);
97
+ return parts.join(" ");
98
+ }
20
99
  /**
21
100
  * The gate's question. It states the two numbers that matter (spent vs. cap) so the responder can
22
- * decide without opening the dashboard, and names the run's own `max_usd` so it's obvious WHICH cap
23
- * was hit (not the org's credit balance — a different failure with a different fix).
101
+ * decide without opening the dashboard, and names the run's own manifest cap field so it's obvious
102
+ * WHICH cap was hit (not the org's credit balance — a different failure with a different fix).
24
103
  */
25
- export function budgetGatePrompt(spentUsd, capUsd) {
26
- return (`Budget cap reached: this run has spent ${usd(spentUsd)} of its ${usd(capUsd)} ` +
27
- `max_usd cap. Approve more spend to continue, or cancel the run.`);
104
+ export function budgetGatePrompt(dimension, spent, cap) {
105
+ if (dimension === "usd") {
106
+ return (`Budget cap reached: this run has spent ${usd(spent)} of its ${usd(cap)} ` +
107
+ `max_usd cap. Approve more spend to continue, or cancel the run.`);
108
+ }
109
+ if (dimension === "tokens") {
110
+ return (`Budget cap reached: this run has used ${tokens(spent)} tokens of its ${tokens(cap)}-token ` +
111
+ `max_tokens cap. Approve more tokens to continue, or cancel the run.`);
112
+ }
113
+ return (`Budget cap reached: this run has used ${computeTime(spent)} of its ${computeTime(cap)} ` +
114
+ `max_compute_seconds cap. Approve more compute time to continue, or cancel the run.`);
28
115
  }
29
116
  /** The gate's response form — a choice, so the common answers are one click / one word. */
30
- export function budgetGateInputSpec() {
117
+ export function budgetGateInputSpec(dimension) {
31
118
  return {
32
119
  kind: "choice",
33
- options: [
34
- BUDGET_CHOICE_ADD_10,
35
- BUDGET_CHOICE_ADD_25,
36
- BUDGET_CHOICE_ADD_100,
37
- BUDGET_CHOICE_CANCEL,
38
- ],
120
+ options: [...BUDGET_PRESETS[dimension], BUDGET_CHOICE_CANCEL],
39
121
  // `other` lets a responder type an absolute cap ("50") instead of taking a preset increment.
40
122
  other: true,
41
123
  };
42
124
  }
125
+ /** Parse one numeric budget answer (`"25"`, `"2.5M"`, `"90min"`) into the dimension's native unit
126
+ * (dollars / tokens / seconds), or null when unreadable. The `+` increment prefix is handled by
127
+ * the caller — this reads only the magnitude. */
128
+ function parseAmount(dimension, raw) {
129
+ if (dimension === "usd") {
130
+ const m = /^\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
131
+ return m?.[1] === undefined ? null : Number(m[1]);
132
+ }
133
+ if (dimension === "tokens") {
134
+ const m = /^(\d+(?:\.\d+)?)\s*([km])?$/i.exec(raw);
135
+ if (m?.[1] === undefined)
136
+ return null;
137
+ const mult = m[2]?.toLowerCase() === "k" ? 1_000 : m[2]?.toLowerCase() === "m" ? 1_000_000 : 1;
138
+ return Math.round(Number(m[1]) * mult);
139
+ }
140
+ const m = /^(\d+(?:\.\d+)?)\s*(s|sec|secs|m|min|mins|h|hr|hrs)?$/i.exec(raw);
141
+ if (m?.[1] === undefined)
142
+ return null;
143
+ const unit = m[2]?.toLowerCase() ?? "s";
144
+ const mult = unit.startsWith("h") ? 3600 : unit.startsWith("m") ? 60 : 1;
145
+ return Math.round(Number(m[1]) * mult);
146
+ }
43
147
  /**
44
- * Interpret an answer as the NEW absolute `max_usd`, or null to cancel the run.
148
+ * Interpret an answer as the NEW absolute cap for `dimension`, or null to cancel the run.
45
149
  *
46
- * `+$N` is an INCREMENT on the current cap (the presets); a bare number is an ABSOLUTE new cap (the
47
- * "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is treated as
48
- * cancel rather than guessed at — silently resuming a run on a misread answer spends real money.
150
+ * `+<amount>` is an INCREMENT on the current cap (the presets); a bare amount is an ABSOLUTE new
151
+ * cap (the "set a cap" escape hatch, via the choice's `other` entry). Anything unrecognized is
152
+ * treated as cancel rather than guessed at — silently resuming a run on a misread answer spends
153
+ * real money. See the module header for the full per-dimension grammar.
49
154
  */
50
- export function resolveBudgetAnswer(answer, currentCapUsd) {
155
+ export function resolveBudgetAnswer(dimension, answer, currentCap) {
51
156
  const raw = answer.trim();
52
157
  if (raw === "" || raw.toLowerCase() === BUDGET_CHOICE_CANCEL)
53
158
  return null;
54
- const increment = /^\+\s*\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
55
- if (increment?.[1] !== undefined)
56
- return currentCapUsd + Number(increment[1]);
57
- const absolute = /^\$?\s*(\d+(?:\.\d+)?)$/.exec(raw);
58
- if (absolute?.[1] !== undefined) {
59
- const value = Number(absolute[1]);
60
- // An "absolute" cap at or below what's already spent would re-park instantly — read it as a
61
- // refusal to fund more, which is a cancel.
62
- return value > currentCapUsd ? value : null;
159
+ if (raw.startsWith("+")) {
160
+ const amount = parseAmount(dimension, raw.slice(1).trim());
161
+ return amount === null ? null : currentCap + amount;
63
162
  }
64
- return null;
163
+ const absolute = parseAmount(dimension, raw);
164
+ if (absolute === null)
165
+ return null;
166
+ // An "absolute" cap at or below the current one would re-park instantly — read it as a refusal
167
+ // to fund more, which is a cancel.
168
+ return absolute > currentCap ? absolute : null;
65
169
  }
66
170
  /** Pull the answer's text out of the SDK's result union (text | choice | multiselect). */
67
171
  function answerText(result) {
@@ -75,40 +179,117 @@ function answerText(result) {
75
179
  return "";
76
180
  }
77
181
  /**
78
- * Budget clearance, awaited before every model call. Fast path: no breach ⇒ resolve immediately (the
79
- * overwhelming majority of calls). On a `usd` breach: park at a gate, then apply the answer to the
80
- * live meter and let the call proceed.
182
+ * Budget clearance, awaited at every park point (see the module header). Fast path: no breach ⇒
183
+ * resolve immediately (the overwhelming majority of calls). On a breach of ANY dimension: park at
184
+ * a gate, then apply the answer to the live meter and let the call proceed.
81
185
  *
82
- * Scope (slice 1): ONLY the `usd` cap parks. A `tokens` / `duration` / `deadline` breach still fails
83
- * the run through the leaf executor's existing throw those dimensions have no "approve more" story
84
- * yet, and `deadline` is wall-clock, which a park cannot pause by definition.
186
+ * The park's own wall-clock is EXCLUDED from the compute cap (`meter.excludeIdle`): parked time is
187
+ * not compute (SUSPEND_POLICY Decision 3.4), and on the snapshot fleet the guest clock resyncs
188
+ * across the frozen window without the exclusion a compute park would re-breach the instant it
189
+ * woke, forever. `usage.get()` polled DURING a park may transiently show the parked wall-clock as
190
+ * compute spend; the exclusion lands when the park resolves.
85
191
  */
86
192
  export class BudgetGate {
87
193
  meter;
88
194
  host;
89
- constructor(meter, host) {
195
+ now;
196
+ constructor(meter, host, now = Date.now) {
90
197
  this.meter = meter;
91
198
  this.host = host;
199
+ this.now = now;
92
200
  }
93
201
  async clear() {
94
202
  // Loop, don't `if`: a responder can approve an increment too small to clear the breach (spend
95
- // already exceeds it), in which case we ask again rather than resume into an instant re-park.
96
- while (this.meter.capBreachReason() === "usd") {
97
- const cap = this.meter.usdCap();
203
+ // already exceeds it) ask again rather than resume into an instant re-park — and clearing
204
+ // one dimension can reveal a second breached one, which parks next.
205
+ for (;;) {
206
+ const dimension = this.meter.capBreachReason();
207
+ if (dimension === null)
208
+ return;
209
+ const cap = this.meter.cap(dimension);
98
210
  if (cap === null)
99
211
  return; // no cap ⇒ nothing to breach (defensive; capBreachReason implies one)
100
- const spent = this.meter.snapshot().totalUsd;
212
+ const spent = this.meter.spent(dimension);
101
213
  // The park needs no bespoke event: registering the gate moves the run to `awaiting_input` and
102
214
  // the prompt below rides the gate row, so the live tail + `boardwalk inputs` explain the pause
103
215
  // through exactly the same path a humanInput() gate uses.
216
+ const parkedAt = this.now();
104
217
  const answer = await this.host.budgetClearance({
105
- prompt: budgetGatePrompt(spent, cap),
106
- inputSpec: budgetGateInputSpec(),
218
+ prompt: budgetGatePrompt(dimension, spent, cap),
219
+ inputSpec: budgetGateInputSpec(dimension),
107
220
  });
108
- const newCap = resolveBudgetAnswer(answerText(answer), cap);
221
+ // Parked time is not compute — exclude it BEFORE re-checking the breach, or a compute park
222
+ // could never clear (the frozen/held wait would count as fresh compute spend).
223
+ this.meter.excludeIdle(this.now() - parkedAt);
224
+ const newCap = resolveBudgetAnswer(dimension, answerText(answer), cap);
109
225
  if (newCap === null)
110
226
  throw new BudgetGateCancelled();
111
- this.meter.raiseUsdCap(newCap);
227
+ this.meter.raiseCap(dimension, newCap);
228
+ }
229
+ }
230
+ }
231
+ /** How often the watcher samples the meter. Coarse is fine: compute presets are minutes. */
232
+ export const DEFAULT_COMPUTE_WATCH_INTERVAL_MS = 15_000;
233
+ /**
234
+ * Detects a `max_compute_seconds` breach BETWEEN park points. `usd`/`tokens` only move at the
235
+ * `streamModel` seam, but compute burns continuously — a breach can land mid-shell, mid-turn, or
236
+ * mid-program-compute, far from any model call.
237
+ *
238
+ * WHY THIS ONLY DETECTS AND DOES NOT PARK — true park-anywhere needs fleet-host work. The freeze
239
+ * machinery is quiescence-gated: `budgetClearance` may only be entered from a work-tracked seam
240
+ * (its `freezeWait` steps the CALLER out of the work count; from a timer context that corrupts the
241
+ * count and could freeze around live in-flight work, which SUSPEND_POLICY Decision 1 forbids). A
242
+ * timer-initiated park would need (a) a host-agent-initiated out-of-band VM pause that does not
243
+ * require runner quiescence, plus a wake path that re-arms the runner-side gate and applies the
244
+ * answer to the meter, and (b) on hold-only substrates a program-process stop (SIGSTOP) in the
245
+ * program runner. Until that lands, the honest contract is: the watcher logs the breach the moment
246
+ * it happens, and the run PARKS AT ITS NEXT SEAM (`streamModel`, `sleep`, `shell`,
247
+ * `workflows.call`) via the same {@link BudgetGate.clear} every park point awaits.
248
+ */
249
+ export class ComputeBreachWatcher {
250
+ meter;
251
+ opts;
252
+ timer = null;
253
+ announced = false;
254
+ constructor(meter, opts = { runId: "" }) {
255
+ this.meter = meter;
256
+ this.opts = opts;
257
+ }
258
+ /** True while a compute breach is standing (sampled; resets when an approval clears it). */
259
+ get breachDetected() {
260
+ return this.announced;
261
+ }
262
+ start() {
263
+ if (this.timer !== null)
264
+ return;
265
+ this.timer = setInterval(() => {
266
+ this.sample();
267
+ }, this.opts.intervalMs ?? DEFAULT_COMPUTE_WATCH_INTERVAL_MS);
268
+ // Never keep the worker process alive solely for this watcher.
269
+ this.timer.unref();
270
+ }
271
+ stop() {
272
+ if (this.timer !== null) {
273
+ clearInterval(this.timer);
274
+ this.timer = null;
275
+ }
276
+ }
277
+ /** One sample tick (exposed for tests — real ticks come from the interval). */
278
+ sample() {
279
+ const cap = this.meter.cap("compute");
280
+ const breached = cap !== null && this.meter.spent("compute") > cap;
281
+ if (breached && !this.announced) {
282
+ this.announced = true;
283
+ log.warn("budget_compute_breach_between_seams", {
284
+ runId: this.opts.runId,
285
+ spentSeconds: this.meter.spent("compute"),
286
+ capSeconds: cap,
287
+ note: "run parks at its next capability seam (streamModel / sleep / shell / workflows.call)",
288
+ });
289
+ }
290
+ else if (!breached && this.announced) {
291
+ // The cap was raised at a gate — re-arm so a later breach of the NEW cap logs again.
292
+ this.announced = false;
112
293
  }
113
294
  }
114
295
  }
@@ -0,0 +1,4 @@
1
+ import type { HostCapabilities } from "./host_server.js";
2
+ import type { WorkerWorkflowHost } from "./workflow_host.js";
3
+ /** Build the protocol server's capability seam over the run's {@link WorkerWorkflowHost}. */
4
+ export declare function buildHostCapabilities(host: WorkerWorkflowHost): HostCapabilities;
@@ -0,0 +1,25 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /** Build the protocol server's capability seam over the run's {@link WorkerWorkflowHost}. */
3
+ export function buildHostCapabilities(host) {
4
+ return {
5
+ agent: (prompt, opts) => host.agent(prompt, opts),
6
+ callWorkflow: async (slug, input, opts) => {
7
+ const { output, outputSchema } = await host.callWorkflow(slug, input, opts);
8
+ return { output, outputSchema };
9
+ },
10
+ runWorkflow: (slug, input, opts) => host.runWorkflow(slug, input, opts),
11
+ scheduleWorkflow: (slug, input, opts) => host.scheduleWorkflow(slug, input, opts),
12
+ sleep: (arg) => host.sleep(arg),
13
+ humanInput: (opts) => host.humanInput(opts),
14
+ getSecret: (name) => host.getSecret(name),
15
+ writeArtifact: (name, contentType, body, metadata) => host.writeArtifact(name, contentType, body, metadata),
16
+ openBrowser: (opts) => host.openBrowserSession(opts),
17
+ shell: (cmd, opts) => host.shell(cmd, opts),
18
+ phase: (name, opts) => {
19
+ host.setPhase(name, opts);
20
+ },
21
+ idToken: (audience) => host.idToken(audience),
22
+ apiToken: () => host.apiToken(),
23
+ usage: () => host.usage(),
24
+ };
25
+ }
@@ -0,0 +1,137 @@
1
+ import { type ContextData, type JsonValue, type ShellResult, type UsageSnapshot, type AgentOptions, type ArtifactBody, type ArtifactRef, type BrowserSession, type BrowserSessionOptions, type CallOptions, type HumanInputOptions, type HumanInputResult, type PhaseOptions, type ScheduleOptions, type SleepArg } from "@boardwalk-labs/workflow/runtime";
2
+ import type { ShellOptions } from "@boardwalk-labs/workflow";
3
+ /** The loader-side curation hint for a `report_return` output-schema mismatch. Shared by the
4
+ * in-process TS loader and the Python subprocess path so the author sees ONE message. */
5
+ export declare const OUTPUT_MISMATCH_HINT = "Return a value matching run()'s declared return type, or update the type and redeploy.";
6
+ /** What `workflows.call` resolves at the capability seam: the child's output plus the CALLEE's
7
+ * declared output schema (`null` for an untyped callee — the client passes the JSON through). */
8
+ export interface CapabilityCallResult {
9
+ output: unknown;
10
+ outputSchema: Record<string, unknown> | null;
11
+ }
12
+ /**
13
+ * The typed seam the protocol server dispatches onto — the runner's existing machinery, one
14
+ * member per capability. Mirrors the SDK client's `HostInterface` so the two ends of the wire
15
+ * stay symmetric. `agent` receives NATIVE `AgentOptions`: the server has already turned wire
16
+ * tool declarations into executable `ToolDef`s (round-tripping `tool_invoke`) and resolved a
17
+ * wire `sessionId` to its live {@link BrowserSession}.
18
+ */
19
+ export interface HostCapabilities {
20
+ agent(prompt: string, opts: AgentOptions | undefined): Promise<unknown>;
21
+ callWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<CapabilityCallResult>;
22
+ runWorkflow(slug: string, input: unknown, opts: CallOptions | undefined): Promise<string>;
23
+ scheduleWorkflow(slug: string, input: unknown, opts: ScheduleOptions): Promise<string>;
24
+ sleep(arg: SleepArg): Promise<void>;
25
+ humanInput(opts: HumanInputOptions): Promise<HumanInputResult>;
26
+ getSecret(name: string): Promise<string>;
27
+ writeArtifact(name: string, contentType: string, body: ArtifactBody, metadata: Record<string, unknown> | undefined): Promise<ArtifactRef>;
28
+ openBrowser(opts: BrowserSessionOptions | undefined): Promise<BrowserSession>;
29
+ shell(cmd: string, opts: ShellOptions | undefined): Promise<ShellResult>;
30
+ phase(name: string, opts: PhaseOptions | undefined): void;
31
+ idToken(audience: string): Promise<string>;
32
+ apiToken(): Promise<string>;
33
+ usage(): Promise<UsageSnapshot>;
34
+ }
35
+ /** The `bootstrap` payload: the RAW JSON input + the stored input schema (`null` when untyped —
36
+ * the CLIENT applies the schema-guided revival pass) + the context DATA (never `signal`). */
37
+ export interface BootstrapData {
38
+ input: JsonValue;
39
+ inputSchema: Record<string, unknown> | null;
40
+ context: ContextData;
41
+ }
42
+ export interface WorkflowHostServerDeps {
43
+ capabilities: HostCapabilities;
44
+ bootstrap: BootstrapData;
45
+ /** The workflow's declared output schema; `null` ⇒ the return persists unvalidated. */
46
+ outputSchema: Record<string, unknown> | null;
47
+ /** The run's cooperative-cancellation signal: on abort, every connected client is sent the
48
+ * `cancel` notification (the SDK aborts `context.signal`). */
49
+ signal?: AbortSignal | undefined;
50
+ /** Directory the Unix socket file is created in. Default `os.tmpdir()` — deliberately short:
51
+ * `sun_path` caps a socket path at ~104 bytes on darwin. Ignored on win32 (named pipe). */
52
+ sockDir?: string | undefined;
53
+ /** Host-side ceiling on ONE `tool_invoke` round-trip. Default: none — parity with the engine,
54
+ * which awaits an inline tool's `execute()` without a timeout. When set, expiry throws an
55
+ * ordinary Error from `execute()` (a tool-error result to the model, never run-fatal) and the
56
+ * late response is discarded by id. */
57
+ toolInvokeTimeoutMs?: number | undefined;
58
+ }
59
+ /**
60
+ * The protocol server for ONE run. `listen()` binds the socket (the runner then exports the
61
+ * path as `BOARDWALK_HOST_SOCK`); `close()` tears everything down. The validated return the
62
+ * program reported is read via {@link reportedReturn} after the loader completes.
63
+ */
64
+ export declare class WorkflowHostServer {
65
+ private readonly deps;
66
+ private readonly server;
67
+ private readonly connections;
68
+ /** sessionId → live handle, backing `computer.browser.*` and `agent({ session })`. */
69
+ private readonly browserSessions;
70
+ private readonly validateOutput;
71
+ private nextInvokeId;
72
+ private sockPath;
73
+ private returned;
74
+ private reportFailure;
75
+ private cancelled;
76
+ private readonly onAbort;
77
+ constructor(deps: WorkflowHostServerDeps);
78
+ /** Bind the socket and resolve its path (a Unix socket path; a named pipe on win32). */
79
+ listen(): Promise<string>;
80
+ /** The bound socket path; null before `listen()`. */
81
+ get socketPath(): string | null;
82
+ /** The validated value the program's loader reported via `report_return`, or null when no
83
+ * return was reported (the program never finished, or returned void ⇒ the client sent null). */
84
+ reportedReturn(): JsonValue | null;
85
+ /** Whether `report_return` was received at all (distinguishes "returned null" from "never
86
+ * reported" for callers that care). */
87
+ hasReturn(): boolean;
88
+ /** The error the most recent `report_return` attempt failed with (the output-schema mismatch),
89
+ * or null when none failed / a later report succeeded. An IN-PROCESS loader re-throws this
90
+ * error itself; a SUBPROCESS loader (Python) can only propagate it as a traceback + non-zero
91
+ * exit, so the runner reads it back here to curate the run's failure with the original
92
+ * code/message instead of the traceback's last line. */
93
+ reportReturnFailure(): Error | null;
94
+ /** Push the `cancel` notification to every connected client (idempotent). */
95
+ notifyCancel(reason?: string): void;
96
+ /** Tear the server down: reject in-flight tool invokes, destroy connections, unlink the socket. */
97
+ close(): Promise<void>;
98
+ private onFrame;
99
+ private settleInvoke;
100
+ private handleNotification;
101
+ private handleRequest;
102
+ private dispatch;
103
+ private get caps();
104
+ /**
105
+ * One handler per client→host method, each typed by ITS OWN `HostMethodParams`/`HostMethodResult`
106
+ * pair via the mapped type — params arrive already narrowed (no per-case casts) and a handler
107
+ * returning another method's result shape is a COMPILE error (a switch over the union couldn't
108
+ * catch that). Exhaustive by construction: a new wire method fails the build until a handler
109
+ * exists. `req` carries the originating connection + request id for `agent`, whose inline tools
110
+ * round-trip `tool_invoke` on that same connection, correlated by this request's id.
111
+ */
112
+ private readonly handlers;
113
+ /** A live browser session by id, or a clear VALIDATION error for a closed/foreign one. */
114
+ private session;
115
+ /** Validate the program's return against the declared output schema (P3.4): a mismatch fails
116
+ * the run — the loader's `reportReturn` rejects and the failure is curated. `null` (a void
117
+ * return) skips validation per the contract. */
118
+ private assertReturnMatchesSchema;
119
+ /** Wire tool declarations → engine `ToolDef`s whose `execute()` round-trips `tool_invoke` to
120
+ * the program, correlated by `call_id` = the originating agent request's own id (stringified).
121
+ * Also resolves a wire `sessionId` back to its live browser session. */
122
+ private toAgentOptions;
123
+ /** One host → client `tool_invoke` round-trip. Concurrent invocations multiplex by this
124
+ * request's own JSON-RPC id; the optional host-side timeout abandons the call (its late
125
+ * response is discarded by id) and throws an ordinary Error — a tool-error result to the
126
+ * model, never run-fatal. */
127
+ private invokeTool;
128
+ }
129
+ /** Map a thrown value to the wire's `{code, message, data?}` (string code, engine taxonomy).
130
+ * An engine-style `hint` (the one-line "what to do") rides `data.hint` so it SURVIVES the wire —
131
+ * the loader's failure curation reads it back into the run's `output.error.hint` (the
132
+ * hint-reaches-hosted-authors contract). */
133
+ export declare function protocolErrorOf(err: unknown): {
134
+ code: string;
135
+ message: string;
136
+ data?: unknown;
137
+ };