@boardwalk-labs/runner 0.2.13 → 0.2.15
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/dist/runtime/direct_inference.d.ts +1 -1
- package/dist/runtime/direct_inference.js +2 -1
- package/dist/runtime/leaf_executor.js +6 -1
- package/dist/runtime/runner_control_client.d.ts +16 -0
- package/dist/runtime/runner_control_client.js +83 -10
- package/dist/runtime/wire/inference_proxy.d.ts +6 -0
- package/dist/runtime/wire/inference_proxy.js +12 -1
- package/package.json +2 -2
|
@@ -23,7 +23,7 @@ export interface DirectInferenceDeps {
|
|
|
23
23
|
}
|
|
24
24
|
/** One model turn, straight to the org's endpoint. Returns the ChatTurn + canonical model ref;
|
|
25
25
|
* BYO carries no platform cost (costMicros stays 0 — BYO is never metered). */
|
|
26
|
-
export declare function streamDirectTurn(deps: DirectInferenceDeps, entry: ByoInferenceProvider, req: DirectTurnRequest, onDelta: ((text: string) => void) | undefined,
|
|
26
|
+
export declare function streamDirectTurn(deps: DirectInferenceDeps, entry: ByoInferenceProvider, req: DirectTurnRequest, onDelta: ((text: string) => void) | undefined, onReasoningDelta: ((text: string) => void) | undefined,
|
|
27
27
|
/** Register the resolved key with the CURRENT leaf's engine redactor, before the model call.
|
|
28
28
|
* The key resolves mid-leaf (after the leaf's redactor snapshot is seeded), so without this a
|
|
29
29
|
* provider that echoes the Authorization header in an error body would leak it into that
|
|
@@ -53,7 +53,7 @@ export function directProviderFor(registry, provider) {
|
|
|
53
53
|
}
|
|
54
54
|
/** One model turn, straight to the org's endpoint. Returns the ChatTurn + canonical model ref;
|
|
55
55
|
* BYO carries no platform cost (costMicros stays 0 — BYO is never metered). */
|
|
56
|
-
export async function streamDirectTurn(deps, entry, req, onDelta,
|
|
56
|
+
export async function streamDirectTurn(deps, entry, req, onDelta, onReasoningDelta,
|
|
57
57
|
/** Register the resolved key with the CURRENT leaf's engine redactor, before the model call.
|
|
58
58
|
* The key resolves mid-leaf (after the leaf's redactor snapshot is seeded), so without this a
|
|
59
59
|
* provider that echoes the Authorization header in an error body would leak it into that
|
|
@@ -77,6 +77,7 @@ registerSecret) {
|
|
|
77
77
|
const io = {
|
|
78
78
|
...(deps.fetchImpl !== undefined ? { fetchImpl: deps.fetchImpl } : {}),
|
|
79
79
|
...(onDelta !== undefined ? { onDelta } : {}),
|
|
80
|
+
...(onReasoningDelta !== undefined ? { onReasoningDelta } : {}),
|
|
80
81
|
};
|
|
81
82
|
log.debug("direct_turn", { provider: entry.name, source: entry.source, model: req.model });
|
|
82
83
|
const turn = entry.source === "anthropic"
|
|
@@ -218,7 +218,7 @@ export class EngineLeafExecutor {
|
|
|
218
218
|
messages: req.messages,
|
|
219
219
|
tools: req.tools,
|
|
220
220
|
...(req.reasoning !== undefined ? { reasoning: req.reasoning } : {}),
|
|
221
|
-
}, providerIo.onDelta,
|
|
221
|
+
}, providerIo.onDelta, providerIo.onReasoningDelta,
|
|
222
222
|
// Register the org's key with THIS leaf's redactor before the model call, so an error
|
|
223
223
|
// body echoing it is scrubbed from the leaf's run events (not just the terminal error).
|
|
224
224
|
(value) => redactor.add("byo-key", value));
|
|
@@ -237,6 +237,9 @@ export class EngineLeafExecutor {
|
|
|
237
237
|
if (frame.kind === "delta") {
|
|
238
238
|
providerIo.onDelta?.(frame.text);
|
|
239
239
|
}
|
|
240
|
+
else if (frame.kind === "reasoning") {
|
|
241
|
+
providerIo.onReasoningDelta?.(frame.text);
|
|
242
|
+
}
|
|
240
243
|
else if (frame.kind === "result") {
|
|
241
244
|
// contextTokens (when the broker knows the served model's window) lets the engine's loop
|
|
242
245
|
// size compaction against the real window instead of a conservative default.
|
|
@@ -331,6 +334,8 @@ export function toRunEventBody(body, identity) {
|
|
|
331
334
|
return { kind: "text_delta", blockId: body.blockId, text: body.text };
|
|
332
335
|
case "text_end":
|
|
333
336
|
return { kind: "text_end", blockId: body.blockId };
|
|
337
|
+
case "reasoning_delta":
|
|
338
|
+
return { kind: "reasoning_delta", text: body.text };
|
|
334
339
|
case "tool_call_start":
|
|
335
340
|
return { kind: "tool_call_start", toolCallId: body.toolCallId, toolName: body.toolName };
|
|
336
341
|
case "tool_call_input_complete":
|
|
@@ -208,8 +208,24 @@ export declare class RunnerControlClient {
|
|
|
208
208
|
* Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to
|
|
209
209
|
* the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A
|
|
210
210
|
* non-200 (a failure BEFORE the stream began) throws the broker's already-classified message.
|
|
211
|
+
*
|
|
212
|
+
* Resilience (added because a bare drop here surfaced as a run-fatal `PROVIDER_ERROR: terminated`):
|
|
213
|
+
* a transient failure is retried WHILE it is still safe to re-POST — i.e. before any CONTENT frame
|
|
214
|
+
* has been relayed. Three cases:
|
|
215
|
+
* - the POST never returned a response (connection refused/reset during an api-server rollover),
|
|
216
|
+
* - a load-balancer {@link INFERENCE_RETRYABLE_STATUSES} (no healthy / gateway timeout) before the
|
|
217
|
+
* stream began — NOT 502, which the broker uses for a real upstream model error,
|
|
218
|
+
* - the body dropped MID-stream (undici `terminated` / socket close) but only `ping` heartbeats
|
|
219
|
+
* had been relayed so far (the observed failure: a huge-context turn streamed only pings for
|
|
220
|
+
* ~120s during time-to-first-token, then the connection dropped).
|
|
221
|
+
* Once a `delta`/`reasoning`/`result`/`error` frame has been yielded, the model has already
|
|
222
|
+
* produced output (or the turn finished), so a re-POST would duplicate it: the drop surfaces as
|
|
223
|
+
* before. Re-POST is billing-safe — the broker aborts the abandoned turn on our disconnect and
|
|
224
|
+
* returns before it meters (no `result` frame ⇒ no usage). The body is a reusable string.
|
|
211
225
|
*/
|
|
212
226
|
streamInference(req: InferenceProxyRequest): AsyncGenerator<InferenceFrame, void, undefined>;
|
|
227
|
+
/** Log + wait one backoff step before re-POSTing a transient inference failure (see streamInference). */
|
|
228
|
+
private backoffInferenceRetry;
|
|
213
229
|
private url;
|
|
214
230
|
private headers;
|
|
215
231
|
}
|
|
@@ -20,6 +20,14 @@ const log = createLogger("RunnerControlClient");
|
|
|
20
20
|
* handler error could duplicate a side effect for no healing value.
|
|
21
21
|
*/
|
|
22
22
|
const RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
|
23
|
+
/**
|
|
24
|
+
* Transient statuses worth retrying on the STREAMING inference POST — a narrower set than
|
|
25
|
+
* {@link RETRYABLE_STATUSES}. 502 is EXCLUDED: on `/inference` the broker uses 502 for a real
|
|
26
|
+
* upstream model error (a clean, customer-facing message), so retrying it just re-hits a guaranteed
|
|
27
|
+
* failure. 503/504 are only ever the load balancer during a rollover (no healthy / draining target,
|
|
28
|
+
* gateway timeout) — safe to re-POST since the stream hasn't begun. See {@link streamInference}.
|
|
29
|
+
*/
|
|
30
|
+
const INFERENCE_RETRYABLE_STATUSES = new Set([503, 504]);
|
|
23
31
|
/** Default backoff (ms) between attempts — ~17.5s of spread, sized to ride out the target-group
|
|
24
32
|
* rotation window of an api-server rolling deploy (the observed killer: guests died hard
|
|
25
33
|
* mid-suspend/finalize during TWO deploys, 2026-07-13, and only crash-reclaim saved the runs). */
|
|
@@ -473,20 +481,85 @@ export class RunnerControlClient {
|
|
|
473
481
|
* Backs {@link InferenceProxyTransport} (inference_transport.ts) — the model swap is invisible to
|
|
474
482
|
* the engine loop, which keeps the runner provider-agnostic (the broker owns model invocation). A
|
|
475
483
|
* non-200 (a failure BEFORE the stream began) throws the broker's already-classified message.
|
|
484
|
+
*
|
|
485
|
+
* Resilience (added because a bare drop here surfaced as a run-fatal `PROVIDER_ERROR: terminated`):
|
|
486
|
+
* a transient failure is retried WHILE it is still safe to re-POST — i.e. before any CONTENT frame
|
|
487
|
+
* has been relayed. Three cases:
|
|
488
|
+
* - the POST never returned a response (connection refused/reset during an api-server rollover),
|
|
489
|
+
* - a load-balancer {@link INFERENCE_RETRYABLE_STATUSES} (no healthy / gateway timeout) before the
|
|
490
|
+
* stream began — NOT 502, which the broker uses for a real upstream model error,
|
|
491
|
+
* - the body dropped MID-stream (undici `terminated` / socket close) but only `ping` heartbeats
|
|
492
|
+
* had been relayed so far (the observed failure: a huge-context turn streamed only pings for
|
|
493
|
+
* ~120s during time-to-first-token, then the connection dropped).
|
|
494
|
+
* Once a `delta`/`reasoning`/`result`/`error` frame has been yielded, the model has already
|
|
495
|
+
* produced output (or the turn finished), so a re-POST would duplicate it: the drop surfaces as
|
|
496
|
+
* before. Re-POST is billing-safe — the broker aborts the abandoned turn on our disconnect and
|
|
497
|
+
* returns before it meters (no `result` frame ⇒ no usage). The body is a reusable string.
|
|
476
498
|
*/
|
|
477
499
|
async *streamInference(req) {
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
500
|
+
const body = serializeInferenceRequest(req);
|
|
501
|
+
for (let attempt = 0;; attempt += 1) {
|
|
502
|
+
const last = attempt >= this.retryDelaysMs.length;
|
|
503
|
+
let res;
|
|
504
|
+
try {
|
|
505
|
+
res = await this.fetchImpl(this.url("inference"), {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: { ...this.headers(true), accept: INFERENCE_NDJSON_CONTENT_TYPE },
|
|
508
|
+
body,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
// No response at all — nothing streamed, so a re-POST is always safe.
|
|
513
|
+
if (last)
|
|
514
|
+
throw err;
|
|
515
|
+
await this.backoffInferenceRetry(attempt, "connect", err);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
if (res.status !== 200 || res.body === null) {
|
|
519
|
+
// A load-balancer transient (target draining / gateway timeout) before the stream began —
|
|
520
|
+
// safe to re-POST. Any other non-200 is the broker's real, already-classified answer
|
|
521
|
+
// (incl. a 502 upstream model error): surface it as the run's customer-facing error.
|
|
522
|
+
if (!last && res.body !== null && INFERENCE_RETRYABLE_STATUSES.has(res.status)) {
|
|
523
|
+
await res.body.cancel().catch(() => { });
|
|
524
|
+
await this.backoffInferenceRetry(attempt, "status", res.status);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
throw await inferenceHttpError(res);
|
|
528
|
+
}
|
|
529
|
+
// Relay the stream. A mid-stream transport drop throws out of readNdjsonLines; retry it only
|
|
530
|
+
// while nothing but heartbeats has gone out (see the doc-comment). `ping` frames carry no
|
|
531
|
+
// model output, so a drop during the pre-first-token wait stays safely retryable.
|
|
532
|
+
let sawContent = false;
|
|
533
|
+
try {
|
|
534
|
+
for await (const line of readNdjsonLines(res.body)) {
|
|
535
|
+
const frame = parseInferenceFrame(line);
|
|
536
|
+
if (frame.kind !== "ping")
|
|
537
|
+
sawContent = true;
|
|
538
|
+
yield frame;
|
|
539
|
+
}
|
|
540
|
+
return; // the stream ended cleanly
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
if (last || sawContent)
|
|
544
|
+
throw err;
|
|
545
|
+
await res.body.cancel().catch(() => { });
|
|
546
|
+
await this.backoffInferenceRetry(attempt, "stream", err);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
488
549
|
}
|
|
489
550
|
}
|
|
551
|
+
/** Log + wait one backoff step before re-POSTing a transient inference failure (see streamInference). */
|
|
552
|
+
async backoffInferenceRetry(attempt, reason, detail) {
|
|
553
|
+
log.warn("broker_inference_retry", {
|
|
554
|
+
reason,
|
|
555
|
+
...(reason === "status"
|
|
556
|
+
? { status: detail }
|
|
557
|
+
: { error: detail instanceof Error ? detail.name : "unknown" }),
|
|
558
|
+
attempt: attempt + 1,
|
|
559
|
+
delayMs: this.retryDelaysMs[attempt],
|
|
560
|
+
});
|
|
561
|
+
await sleep(this.retryDelaysMs[attempt] ?? 0);
|
|
562
|
+
}
|
|
490
563
|
url(suffix) {
|
|
491
564
|
return `${this.base}/runner/v1/runs/${encodeURIComponent(this.cfg.runId)}/${suffix}`;
|
|
492
565
|
}
|
|
@@ -36,6 +36,9 @@ export interface ProxyError {
|
|
|
36
36
|
export type InferenceFrame = {
|
|
37
37
|
kind: "delta";
|
|
38
38
|
text: string;
|
|
39
|
+
} | {
|
|
40
|
+
kind: "reasoning";
|
|
41
|
+
text: string;
|
|
39
42
|
} | {
|
|
40
43
|
kind: "result";
|
|
41
44
|
turn: ChatTurn;
|
|
@@ -58,6 +61,9 @@ export declare function serializeInferenceRequest(req: InferenceProxyRequest): s
|
|
|
58
61
|
export declare function parseInferenceRequest(body: string): InferenceProxyRequest;
|
|
59
62
|
/** Serialize one streamed text delta as a single NDJSON line (trailing "\n" included). */
|
|
60
63
|
export declare function serializeDeltaFrame(text: string): string;
|
|
64
|
+
/** Serialize one streamed reasoning/thinking delta as a single NDJSON line. Separate from a `delta`
|
|
65
|
+
* frame so the worker routes it to the thinking trace, never into the assistant answer. */
|
|
66
|
+
export declare function serializeReasoningFrame(text: string): string;
|
|
61
67
|
/** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
|
|
62
68
|
* upstream cost (the managed provider's per-request cost × 1e6) on the managed lane — 0 for BYO or when unavailable.
|
|
63
69
|
* The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
|
|
@@ -78,6 +78,11 @@ export function parseInferenceRequest(body) {
|
|
|
78
78
|
export function serializeDeltaFrame(text) {
|
|
79
79
|
return `${JSON.stringify({ t: "delta", text })}\n`;
|
|
80
80
|
}
|
|
81
|
+
/** Serialize one streamed reasoning/thinking delta as a single NDJSON line. Separate from a `delta`
|
|
82
|
+
* frame so the worker routes it to the thinking trace, never into the assistant answer. */
|
|
83
|
+
export function serializeReasoningFrame(text) {
|
|
84
|
+
return `${JSON.stringify({ t: "reasoning", text })}\n`;
|
|
85
|
+
}
|
|
81
86
|
/** Serialize the single terminal turn result as one NDJSON line. `costMicros` is the turn's EXACT
|
|
82
87
|
* upstream cost (the managed provider's per-request cost × 1e6) on the managed lane — 0 for BYO or when unavailable.
|
|
83
88
|
* The worker feeds it to the budget guardrail so `max_usd` tracks real spend, not a token estimate. */
|
|
@@ -110,6 +115,8 @@ export function parseInferenceFrame(line) {
|
|
|
110
115
|
switch (rec.t) {
|
|
111
116
|
case "delta":
|
|
112
117
|
return { kind: "delta", text: typeof rec.text === "string" ? rec.text : "" };
|
|
118
|
+
case "reasoning":
|
|
119
|
+
return { kind: "reasoning", text: typeof rec.text === "string" ? rec.text : "" };
|
|
113
120
|
case "result":
|
|
114
121
|
return {
|
|
115
122
|
kind: "result",
|
|
@@ -129,7 +136,11 @@ export function parseInferenceFrame(line) {
|
|
|
129
136
|
case "ping":
|
|
130
137
|
return { kind: "ping" };
|
|
131
138
|
default:
|
|
132
|
-
|
|
139
|
+
// Lenient consumer: a well-formed frame whose kind this runner predates must not crash the
|
|
140
|
+
// stream — a newer broker may emit frames an older runner can't name (this is how `reasoning`
|
|
141
|
+
// itself reaches a fleet mid-rollout). Treat it as a no-op heartbeat, which the leaf loop
|
|
142
|
+
// already ignores. Only a genuinely malformed line (caught above) is fatal.
|
|
143
|
+
return { kind: "ping" };
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
/** Narrow a parsed `result` frame's turn back to the engine's `ChatTurn`, failing closed on shape —
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boardwalk-labs/runner",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "Boardwalk self-hosted runner: execute cloud-scheduled runs on your own machines. Currently: the canonical runner contract types.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"prebuild": "prebuildify --napi --strip -t 24.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@boardwalk-labs/engine": "0.2.
|
|
56
|
+
"@boardwalk-labs/engine": "0.2.12",
|
|
57
57
|
"@boardwalk-labs/workflow": "0.2.5",
|
|
58
58
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
59
59
|
"node-gyp-build": "^4.8.4",
|