@boardwalk-labs/runner 0.1.2
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/LICENSE +202 -0
- package/README.md +68 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +214 -0
- package/dist/contract.d.ts +154 -0
- package/dist/contract.js +196 -0
- package/dist/daemon/daemon.d.ts +45 -0
- package/dist/daemon/daemon.js +129 -0
- package/dist/daemon/identity.d.ts +15 -0
- package/dist/daemon/identity.js +44 -0
- package/dist/daemon/index.d.ts +3 -0
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/pool_client.d.ts +31 -0
- package/dist/daemon/pool_client.js +101 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/runtime/agent/budget.d.ts +123 -0
- package/dist/runtime/agent/budget.js +174 -0
- package/dist/runtime/agent/events.d.ts +46 -0
- package/dist/runtime/agent/events.js +8 -0
- package/dist/runtime/agent/model_rates.d.ts +7 -0
- package/dist/runtime/agent/model_rates.js +21 -0
- package/dist/runtime/agent/secret_redactor.d.ts +42 -0
- package/dist/runtime/agent/secret_redactor.js +98 -0
- package/dist/runtime/broker_artifact_store.d.ts +17 -0
- package/dist/runtime/broker_artifact_store.js +46 -0
- package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
- package/dist/runtime/broker_child_dispatcher.js +88 -0
- package/dist/runtime/broker_event_publisher.d.ts +25 -0
- package/dist/runtime/broker_event_publisher.js +69 -0
- package/dist/runtime/broker_tool_host.d.ts +49 -0
- package/dist/runtime/broker_tool_host.js +240 -0
- package/dist/runtime/cancel_watcher.d.ts +31 -0
- package/dist/runtime/cancel_watcher.js +77 -0
- package/dist/runtime/credit_watcher.d.ts +30 -0
- package/dist/runtime/credit_watcher.js +78 -0
- package/dist/runtime/direct_inference.d.ts +29 -0
- package/dist/runtime/direct_inference.js +78 -0
- package/dist/runtime/index.d.ts +85 -0
- package/dist/runtime/index.js +525 -0
- package/dist/runtime/inference_transport.d.ts +5 -0
- package/dist/runtime/inference_transport.js +9 -0
- package/dist/runtime/leaf_executor.d.ts +104 -0
- package/dist/runtime/leaf_executor.js +333 -0
- package/dist/runtime/lease_renewer.d.ts +31 -0
- package/dist/runtime/lease_renewer.js +80 -0
- package/dist/runtime/main.d.ts +1 -0
- package/dist/runtime/main.js +7 -0
- package/dist/runtime/phase_tracker.d.ts +26 -0
- package/dist/runtime/phase_tracker.js +45 -0
- package/dist/runtime/program_log_capture.d.ts +20 -0
- package/dist/runtime/program_log_capture.js +79 -0
- package/dist/runtime/program_runner.d.ts +82 -0
- package/dist/runtime/program_runner.js +116 -0
- package/dist/runtime/program_worker.d.ts +177 -0
- package/dist/runtime/program_worker.js +272 -0
- package/dist/runtime/recording_secret_resolver.d.ts +9 -0
- package/dist/runtime/recording_secret_resolver.js +22 -0
- package/dist/runtime/run_abort.d.ts +16 -0
- package/dist/runtime/run_abort.js +38 -0
- package/dist/runtime/run_event_emitter.d.ts +24 -0
- package/dist/runtime/run_event_emitter.js +63 -0
- package/dist/runtime/runner_control_client.d.ts +184 -0
- package/dist/runtime/runner_control_client.js +501 -0
- package/dist/runtime/runtime_flusher.d.ts +41 -0
- package/dist/runtime/runtime_flusher.js +90 -0
- package/dist/runtime/sandbox_config.d.ts +21 -0
- package/dist/runtime/sandbox_config.js +34 -0
- package/dist/runtime/support/index.d.ts +53 -0
- package/dist/runtime/support/index.js +114 -0
- package/dist/runtime/suspension.d.ts +141 -0
- package/dist/runtime/suspension.js +120 -0
- package/dist/runtime/testing_artifact_build.d.ts +27 -0
- package/dist/runtime/testing_artifact_build.js +96 -0
- package/dist/runtime/tools/artifacts.d.ts +146 -0
- package/dist/runtime/tools/artifacts.js +125 -0
- package/dist/runtime/tools/types.d.ts +114 -0
- package/dist/runtime/tools/types.js +61 -0
- package/dist/runtime/tools/web_search.d.ts +66 -0
- package/dist/runtime/tools/web_search.js +165 -0
- package/dist/runtime/wire/artifact_storage.d.ts +37 -0
- package/dist/runtime/wire/artifact_storage.js +93 -0
- package/dist/runtime/wire/artifact_verify.d.ts +1 -0
- package/dist/runtime/wire/artifact_verify.js +8 -0
- package/dist/runtime/wire/human_input.d.ts +62 -0
- package/dist/runtime/wire/human_input.js +156 -0
- package/dist/runtime/wire/inference_proxy.d.ts +70 -0
- package/dist/runtime/wire/inference_proxy.js +157 -0
- package/dist/runtime/wire/manifest.d.ts +16 -0
- package/dist/runtime/wire/manifest.js +18 -0
- package/dist/runtime/wire/run.d.ts +54 -0
- package/dist/runtime/wire/run.js +2 -0
- package/dist/runtime/workflow_host.d.ts +194 -0
- package/dist/runtime/workflow_host.js +382 -0
- package/dist/runtime/workspace_store.d.ts +73 -0
- package/dist/runtime/workspace_store.js +118 -0
- package/package.json +70 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// BrokerChildDispatcher — the broker-backed `workflows.call` (docs/RUNNER_BROKER.md). The host's
|
|
2
|
+
// ChildDispatcher under the broker model: instead of touching the DB/SQS directly (the
|
|
3
|
+
// WorkerChildDispatcher path), it asks the Runner Control API to create the child (resolve + the
|
|
4
|
+
// `callable_by` gate + idempotent re-attach happen server-side) and then HOLDS the parent task,
|
|
5
|
+
// polling the child to terminal over the run token. Same hold-and-poll shape as WorkerChildDispatcher
|
|
6
|
+
// — only the data source changes (broker HTTP, not the repos).
|
|
7
|
+
import { AppError, ErrorCode, createLogger } from "./support/index.js";
|
|
8
|
+
import { throwIfAborted } from "./run_abort.js";
|
|
9
|
+
const log = createLogger("BrokerChildDispatcher");
|
|
10
|
+
const TERMINAL_CHILD_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
11
|
+
const DEFAULT_POLL_INTERVAL_MS = 2_000;
|
|
12
|
+
export class BrokerChildDispatcher {
|
|
13
|
+
deps;
|
|
14
|
+
sleep;
|
|
15
|
+
pollIntervalMs;
|
|
16
|
+
constructor(deps) {
|
|
17
|
+
this.deps = deps;
|
|
18
|
+
this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
19
|
+
this.pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
20
|
+
}
|
|
21
|
+
/** Create (or re-attach to) the child, then hold + poll to terminal and return its output. An
|
|
22
|
+
* abort (`signal`) stops the hold within one poll interval and throws RunAbortedError. */
|
|
23
|
+
async call(slug, input, _opts, signal) {
|
|
24
|
+
throwIfAborted(signal);
|
|
25
|
+
const child = await this.deps.client.startChild(slug, input);
|
|
26
|
+
if (TERMINAL_CHILD_STATUSES.has(child.status)) {
|
|
27
|
+
return this.childOutput(slug, child.childRunId, child.status, child.output);
|
|
28
|
+
}
|
|
29
|
+
return this.pollToCompletion(slug, child.childRunId, signal);
|
|
30
|
+
}
|
|
31
|
+
/** Start (or idempotently re-attach to) the child and resolve its CURRENT state — no hold. The
|
|
32
|
+
* durable callWorkflow seam decides whether to suspend (non-terminal) or return (terminal). */
|
|
33
|
+
async start(slug, input, _opts, signal) {
|
|
34
|
+
throwIfAborted(signal);
|
|
35
|
+
const child = await this.deps.client.startChild(slug, input);
|
|
36
|
+
return { childRunId: child.childRunId, status: child.status, output: child.output };
|
|
37
|
+
}
|
|
38
|
+
/** Poll a child's current state by id (the resume path), or null when it isn't this run's child. */
|
|
39
|
+
async poll(childRunId) {
|
|
40
|
+
const child = await this.deps.client.getChild(childRunId);
|
|
41
|
+
return child === null
|
|
42
|
+
? null
|
|
43
|
+
: { childRunId: child.id, status: child.status, output: child.output };
|
|
44
|
+
}
|
|
45
|
+
/** Fire-and-forget: create (or re-attach to) the child and return its id without holding. */
|
|
46
|
+
async run(slug, input, _opts) {
|
|
47
|
+
const child = await this.deps.client.startChild(slug, input);
|
|
48
|
+
return child.childRunId;
|
|
49
|
+
}
|
|
50
|
+
/** Provision a durable schedule via the broker; returns the new schedule's id. A `Date` `at` is
|
|
51
|
+
* normalized to an ISO string (the broker spec carries string | number, not Date). */
|
|
52
|
+
async schedule(slug, input, opts) {
|
|
53
|
+
const spec = {};
|
|
54
|
+
if (opts.cron !== undefined)
|
|
55
|
+
spec.cron = opts.cron;
|
|
56
|
+
if (opts.rate !== undefined)
|
|
57
|
+
spec.rate = opts.rate;
|
|
58
|
+
if (opts.at !== undefined)
|
|
59
|
+
spec.at = opts.at instanceof Date ? opts.at.toISOString() : opts.at;
|
|
60
|
+
if (opts.timezone !== undefined)
|
|
61
|
+
spec.timezone = opts.timezone;
|
|
62
|
+
if (opts.idempotencyKey !== undefined)
|
|
63
|
+
spec.idempotencyKey = opts.idempotencyKey;
|
|
64
|
+
return await this.deps.client.scheduleWorkflow(slug, input, spec);
|
|
65
|
+
}
|
|
66
|
+
async pollToCompletion(slug, childRunId, signal) {
|
|
67
|
+
for (;;) {
|
|
68
|
+
throwIfAborted(signal);
|
|
69
|
+
const child = await this.deps.client.getChild(childRunId);
|
|
70
|
+
if (child === null) {
|
|
71
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow's child run ${childRunId} vanished`);
|
|
72
|
+
}
|
|
73
|
+
if (TERMINAL_CHILD_STATUSES.has(child.status)) {
|
|
74
|
+
return this.childOutput(slug, child.id, child.status, child.output);
|
|
75
|
+
}
|
|
76
|
+
await this.sleep(this.pollIntervalMs);
|
|
77
|
+
// Re-check after the wait so an abort during the inter-poll sleep stops within one interval.
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** A completed child returns its output; a failed/cancelled child rejects the parent's await. */
|
|
82
|
+
childOutput(slug, childRunId, status, output) {
|
|
83
|
+
if (status === "completed")
|
|
84
|
+
return output;
|
|
85
|
+
log.warn("child_workflow_nonterminal_output", { slug, childRunId, status });
|
|
86
|
+
throw new AppError(ErrorCode.INTERNAL_ERROR, `Called workflow "${slug}" ${status} (run ${childRunId})`, { childRunId, status });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RedisPublisher } from "./agent/events.js";
|
|
2
|
+
export interface BrokerEventPublisherOptions {
|
|
3
|
+
/** Ship a batch of frames to the broker (RunnerControlClient.publishTelemetry). */
|
|
4
|
+
send: (frames: string[]) => Promise<void>;
|
|
5
|
+
/** Flush once this many frames are buffered (default 16). */
|
|
6
|
+
maxBatch?: number;
|
|
7
|
+
/** Flush a partial batch after this long since the first buffered frame (default 200ms). */
|
|
8
|
+
maxDelayMs?: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class BrokerEventPublisher implements RedisPublisher {
|
|
11
|
+
private readonly send;
|
|
12
|
+
private readonly maxBatch;
|
|
13
|
+
private readonly maxDelayMs;
|
|
14
|
+
private buffer;
|
|
15
|
+
private timer;
|
|
16
|
+
constructor(opts: BrokerEventPublisherOptions);
|
|
17
|
+
/** RedisPublisher contract: buffer the frame; flush on size, else arm the delay timer. The
|
|
18
|
+
* `channel` is ignored — the broker derives it from the run token (the run's own channel). The
|
|
19
|
+
* return value (subscriber count) is unused by the run-event emitter, so we resolve 0. */
|
|
20
|
+
publish(_channel: string, message: string): Promise<number>;
|
|
21
|
+
/** Ship whatever is buffered now (and cancel any pending timer). Errors are logged, not thrown. */
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
/** Drain the final batch at run end (the worker calls this in cleanup). */
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// BrokerEventPublisher — a `RedisPublisher` that ships the agent event-stream through the broker
|
|
2
|
+
// instead of publishing to Redis directly (docs/RUNNER_BROKER.md §4 — Telemetry).
|
|
3
|
+
//
|
|
4
|
+
// The run-event live channel is a per-event `redis.publish(run:<id>, frame)`. Under the broker the
|
|
5
|
+
// runner holds no Redis credential, so this stand-in BUFFERS frames and POSTs them in batches to the
|
|
6
|
+
// Runner Control API's `/telemetry` endpoint; the broker publishes each to the run's Redis channel
|
|
7
|
+
// server-side. Batching keeps it off the per-token-delta HTTP-call hot path (a turn can emit one
|
|
8
|
+
// frame per token); the SSE consumer orders by the frame's `cursor`, so out-of-order batch delivery
|
|
9
|
+
// is harmless.
|
|
10
|
+
//
|
|
11
|
+
// Best-effort, exactly like the direct Redis publish it replaces: a failed flush is logged, never
|
|
12
|
+
// thrown — a telemetry hiccup must not kill the agent turn (the durable store is the durability
|
|
13
|
+
// guarantee, once it lands). The worker calls `close()` in its cleanup to drain the final batch.
|
|
14
|
+
import { createLogger } from "./support/index.js";
|
|
15
|
+
const log = createLogger("BrokerEventPublisher");
|
|
16
|
+
const DEFAULT_MAX_BATCH = 16;
|
|
17
|
+
const DEFAULT_MAX_DELAY_MS = 200;
|
|
18
|
+
export class BrokerEventPublisher {
|
|
19
|
+
send;
|
|
20
|
+
maxBatch;
|
|
21
|
+
maxDelayMs;
|
|
22
|
+
buffer = [];
|
|
23
|
+
timer = null;
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.send = opts.send;
|
|
26
|
+
this.maxBatch = opts.maxBatch ?? DEFAULT_MAX_BATCH;
|
|
27
|
+
this.maxDelayMs = opts.maxDelayMs ?? DEFAULT_MAX_DELAY_MS;
|
|
28
|
+
}
|
|
29
|
+
/** RedisPublisher contract: buffer the frame; flush on size, else arm the delay timer. The
|
|
30
|
+
* `channel` is ignored — the broker derives it from the run token (the run's own channel). The
|
|
31
|
+
* return value (subscriber count) is unused by the run-event emitter, so we resolve 0. */
|
|
32
|
+
publish(_channel, message) {
|
|
33
|
+
this.buffer.push(message);
|
|
34
|
+
if (this.buffer.length >= this.maxBatch) {
|
|
35
|
+
void this.flush();
|
|
36
|
+
}
|
|
37
|
+
else if (this.timer === null) {
|
|
38
|
+
this.timer = setTimeout(() => {
|
|
39
|
+
void this.flush();
|
|
40
|
+
}, this.maxDelayMs);
|
|
41
|
+
this.timer.unref();
|
|
42
|
+
}
|
|
43
|
+
return Promise.resolve(0);
|
|
44
|
+
}
|
|
45
|
+
/** Ship whatever is buffered now (and cancel any pending timer). Errors are logged, not thrown. */
|
|
46
|
+
async flush() {
|
|
47
|
+
if (this.timer !== null) {
|
|
48
|
+
clearTimeout(this.timer);
|
|
49
|
+
this.timer = null;
|
|
50
|
+
}
|
|
51
|
+
if (this.buffer.length === 0)
|
|
52
|
+
return;
|
|
53
|
+
const batch = this.buffer;
|
|
54
|
+
this.buffer = [];
|
|
55
|
+
try {
|
|
56
|
+
await this.send(batch);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
log.warn("telemetry_flush_failed", {
|
|
60
|
+
frames: batch.length,
|
|
61
|
+
error: err instanceof Error ? err.message : String(err),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Drain the final batch at run end (the worker calls this in cleanup). */
|
|
66
|
+
async close() {
|
|
67
|
+
await this.flush();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ToolHost } from "@boardwalk-labs/engine/core";
|
|
2
|
+
import type { WebSearchInput, WebSearchOutput } from "./tools/web_search.js";
|
|
3
|
+
import type { ArtifactStore } from "./tools/artifacts.js";
|
|
4
|
+
/** Socket-safety ceiling on the RAW bytes read off a `webfetch` response, so a hostile URL can't
|
|
5
|
+
* stream an unbounded body into the worker. This bounds MEMORY, not what the model sees (the
|
|
6
|
+
* model-facing content is extracted + capped separately — see DEFAULT_FETCH_CONTENT_CHARS). The
|
|
7
|
+
* engine's per-call `maxBytes` overrides this when set. */
|
|
8
|
+
export declare const DEFAULT_FETCH_MAX_BYTES: number;
|
|
9
|
+
/** Ceiling on the MODEL-FACING content of a `webfetch`, in chars, AFTER HTML→text extraction
|
|
10
|
+
* (~12K tokens). Raw HTML is mostly `<script>`/`<style>`/markup the model doesn't need and which
|
|
11
|
+
* then rides in context for the rest of the loop, so we extract the readable text and cap it. A
|
|
12
|
+
* whole 5 MB page used to land in context verbatim; this is the single biggest webfetch token sink. */
|
|
13
|
+
export declare const DEFAULT_FETCH_CONTENT_CHARS = 50000;
|
|
14
|
+
/** The broker surface the web_search + read-artifact paths need (RunnerControlClient satisfies it).
|
|
15
|
+
* `webSearch` proxies to Tavily server-side; `signArtifactUrl` + `downloadBytes` back `readArtifact`. */
|
|
16
|
+
export interface ToolHostBroker {
|
|
17
|
+
webSearch(input: WebSearchInput): Promise<WebSearchOutput>;
|
|
18
|
+
signArtifactUrl(artifactId: string, ttlSeconds: number): Promise<{
|
|
19
|
+
signedUrl: string;
|
|
20
|
+
}>;
|
|
21
|
+
downloadBytes(url: string): Promise<Uint8Array | null>;
|
|
22
|
+
}
|
|
23
|
+
export interface BrokerToolHostDeps {
|
|
24
|
+
/** Broker client for web_search + read-artifact signing/download. */
|
|
25
|
+
broker: ToolHostBroker;
|
|
26
|
+
/** The artifact store the `artifacts` write/list path already uses (reused verbatim). */
|
|
27
|
+
artifacts: ArtifactStore;
|
|
28
|
+
/** Injected fetch for `webfetch` (defaults to global fetch — the proxy is applied by the runtime,
|
|
29
|
+
* not here, so a plain global fetch already routes through the egress allowlist). */
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
/** Override the default `webfetch` RAW read cap, in bytes (tests). */
|
|
32
|
+
maxFetchBytes?: number;
|
|
33
|
+
/** Override the default `webfetch` model-facing content cap, in chars (tests). */
|
|
34
|
+
maxFetchContentChars?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the engine `ToolHost` for one run, backed by the broker. Returned as a plain object literal so
|
|
38
|
+
* each hook closes over the injected deps; the shape matches the engine's optional-method interface
|
|
39
|
+
* (omitting `lsp` leaves that tool unregistered).
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildBrokerToolHost(deps: BrokerToolHostDeps): ToolHost;
|
|
42
|
+
/**
|
|
43
|
+
* Reduce an HTML document to readable text: drop non-content elements (script/style/head/etc.),
|
|
44
|
+
* turn block boundaries into newlines, strip remaining tags, decode common entities, and collapse
|
|
45
|
+
* whitespace. A pragmatic, dependency-free extractor — NOT a full readability pass (it does not
|
|
46
|
+
* score main-content vs. nav/ads), but it removes the bulk of the markup/script tokens that would
|
|
47
|
+
* otherwise flood the model. A heavier readability library could improve precision later.
|
|
48
|
+
*/
|
|
49
|
+
export declare function htmlToText(html: string): string;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// BrokerToolHost — the engine's `ToolHost` (the infrastructure seam for the host-backed built-in
|
|
2
|
+
// coding tools `webfetch` / `http` / `web_search` / `artifacts`) wired over the Runner Credential Broker
|
|
3
|
+
// (docs/RUNNER_BROKER.md). `@boardwalk-labs/engine` registers those three tools ONLY when the leaf's
|
|
4
|
+
// `ToolSetContext.host` provides the backing hook; we supply one here so they light up on hosted
|
|
5
|
+
// runs. Each hook delegates the privileged capability to whoever holds the credential/network/storage
|
|
6
|
+
// — exactly as the broker model requires (security review #1): the untrusted worker holds no Tavily
|
|
7
|
+
// key, no S3 credential, and only reaches the public network through the egress proxy.
|
|
8
|
+
//
|
|
9
|
+
// web_search → broker /tools/web_search (the broker holds the Tavily key; the worker just forwards
|
|
10
|
+
// the query + maps the result rows into the engine's WebSearchResult shape).
|
|
11
|
+
// artifacts → the existing BrokerArtifactStore (presigned write for large bodies; list + signed
|
|
12
|
+
// URL + an in-process S3 GET for read).
|
|
13
|
+
// webfetch → an in-process fetch FROM the worker. The worker's egress is already gated by the
|
|
14
|
+
// Squid allowlist proxy (NODE_USE_ENV_PROXY / HTTP(S)_PROXY → egress-proxy boundary);
|
|
15
|
+
// we never bypass it. http(s)-only, response size-capped, HTML extracted to text.
|
|
16
|
+
// http → the same in-process, proxy-gated fetch but with any method/headers/body and the RAW
|
|
17
|
+
// response (no HTML extraction) — for calling APIs. http(s)-only, response size-capped.
|
|
18
|
+
//
|
|
19
|
+
// LSP is NOT a `ToolHost` hook — it's engine-native (`capabilities.lspService`, supplied per run by
|
|
20
|
+
// the worker's leaf wiring in index.ts), so it isn't this module's concern.
|
|
21
|
+
//
|
|
22
|
+
// Secrets-redaction invariant: every value a host hook returns flows back through the engine loop's
|
|
23
|
+
// Redactor before it can reach model context (the leaf seeds it from the run's recorded secrets), so
|
|
24
|
+
// nothing here can leak a secret. This module holds no provider key of its own.
|
|
25
|
+
/** Socket-safety ceiling on the RAW bytes read off a `webfetch` response, so a hostile URL can't
|
|
26
|
+
* stream an unbounded body into the worker. This bounds MEMORY, not what the model sees (the
|
|
27
|
+
* model-facing content is extracted + capped separately — see DEFAULT_FETCH_CONTENT_CHARS). The
|
|
28
|
+
* engine's per-call `maxBytes` overrides this when set. */
|
|
29
|
+
export const DEFAULT_FETCH_MAX_BYTES = 5 * 1024 * 1024;
|
|
30
|
+
/** Ceiling on the MODEL-FACING content of a `webfetch`, in chars, AFTER HTML→text extraction
|
|
31
|
+
* (~12K tokens). Raw HTML is mostly `<script>`/`<style>`/markup the model doesn't need and which
|
|
32
|
+
* then rides in context for the rest of the loop, so we extract the readable text and cap it. A
|
|
33
|
+
* whole 5 MB page used to land in context verbatim; this is the single biggest webfetch token sink. */
|
|
34
|
+
export const DEFAULT_FETCH_CONTENT_CHARS = 50_000;
|
|
35
|
+
/** TTL for the short-lived signed URL minted to read an artifact's bytes back (`readArtifact`). The
|
|
36
|
+
* download happens immediately in-process, so a minute is ample. */
|
|
37
|
+
const READ_ARTIFACT_SIGN_TTL_SECONDS = 60;
|
|
38
|
+
/**
|
|
39
|
+
* Build the engine `ToolHost` for one run, backed by the broker. Returned as a plain object literal so
|
|
40
|
+
* each hook closes over the injected deps; the shape matches the engine's optional-method interface
|
|
41
|
+
* (omitting `lsp` leaves that tool unregistered).
|
|
42
|
+
*/
|
|
43
|
+
export function buildBrokerToolHost(deps) {
|
|
44
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
45
|
+
const maxFetchBytes = deps.maxFetchBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
46
|
+
const maxFetchContentChars = deps.maxFetchContentChars ?? DEFAULT_FETCH_CONTENT_CHARS;
|
|
47
|
+
return {
|
|
48
|
+
// web_search → broker /tools/web_search. The engine hands a bare query + optional limit; we map
|
|
49
|
+
// `limit` onto the domain tool's `max_results` (the broker re-clamps to [1, 20] server-side) and
|
|
50
|
+
// project the rich WebSearchOutput rows down to the engine's title/url/snippet shape.
|
|
51
|
+
webSearch: async (query, opts) => {
|
|
52
|
+
const input = {
|
|
53
|
+
query,
|
|
54
|
+
...(opts?.limit !== undefined ? { max_results: opts.limit } : {}),
|
|
55
|
+
};
|
|
56
|
+
const out = await deps.broker.webSearch(input);
|
|
57
|
+
return out.data.results.map((r) => ({
|
|
58
|
+
title: r.title,
|
|
59
|
+
url: r.url,
|
|
60
|
+
// `snippet` is optional on the engine shape; the domain row always has one (possibly empty).
|
|
61
|
+
snippet: r.snippet,
|
|
62
|
+
}));
|
|
63
|
+
},
|
|
64
|
+
// artifacts.write → the existing BrokerArtifactStore (small bodies proxy inline; large ones take
|
|
65
|
+
// the presigned-PUT path). The engine's ArtifactWriteResult is { id, name, url }, so surface the
|
|
66
|
+
// store's signed download URL as `url`.
|
|
67
|
+
writeArtifact: async (name, contentType, body, metadata) => {
|
|
68
|
+
const result = await deps.artifacts.write({
|
|
69
|
+
name,
|
|
70
|
+
contentType,
|
|
71
|
+
// The engine always hands a UTF-8 string for these host-backed writes (it has no binary
|
|
72
|
+
// channel); the store decodes per `encoding`.
|
|
73
|
+
body,
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
76
|
+
});
|
|
77
|
+
return { id: result.id, name: result.name, url: result.signedUrl };
|
|
78
|
+
},
|
|
79
|
+
// artifacts read-back: the engine asks by NAME, so list this run's artifacts, find the most recent
|
|
80
|
+
// match, mint a short-lived signed URL through the broker, and fetch the bytes in-process. Fails
|
|
81
|
+
// loud when the name isn't found (so a typo surfaces, never a silent empty read).
|
|
82
|
+
readArtifact: async (name) => {
|
|
83
|
+
const artifacts = await deps.artifacts.list();
|
|
84
|
+
// Newest-first so a re-written name reads the latest version.
|
|
85
|
+
const match = artifacts
|
|
86
|
+
.filter((a) => a.name === name)
|
|
87
|
+
.sort((a, b) => b.createdAt - a.createdAt)[0];
|
|
88
|
+
if (match === undefined) {
|
|
89
|
+
throw new Error(`artifact "${name}" not found in this run`);
|
|
90
|
+
}
|
|
91
|
+
const { signedUrl } = await deps.broker.signArtifactUrl(match.id, READ_ARTIFACT_SIGN_TTL_SECONDS);
|
|
92
|
+
const bytes = await deps.broker.downloadBytes(signedUrl);
|
|
93
|
+
if (bytes === null) {
|
|
94
|
+
throw new Error(`artifact "${name}" could not be downloaded`);
|
|
95
|
+
}
|
|
96
|
+
return Buffer.from(bytes).toString("utf8");
|
|
97
|
+
},
|
|
98
|
+
// webfetch → an in-process fetch from the worker. Egress is gated by the Squid allowlist proxy
|
|
99
|
+
// (NODE_USE_ENV_PROXY in the worker), so this does NOT bypass it. http(s)-only; the body is read
|
|
100
|
+
// as a size-capped stream so a hostile URL can't exhaust memory.
|
|
101
|
+
fetchUrl: async (url, opts) => {
|
|
102
|
+
assertHttpUrl(url);
|
|
103
|
+
// maxBytes (when the caller sets it) bounds the RAW read; otherwise the socket-safety cap.
|
|
104
|
+
const rawCap = opts?.maxBytes ?? maxFetchBytes;
|
|
105
|
+
const res = await fetchImpl(url, { method: "GET", redirect: "follow" });
|
|
106
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
107
|
+
const { body: raw, truncated: rawTruncated } = await readCapped(res, rawCap);
|
|
108
|
+
// Extract readable text from HTML so the model isn't fed script/style/markup boilerplate, then
|
|
109
|
+
// cap the model-facing content (extraction already shrinks HTML a lot; this bounds the rest).
|
|
110
|
+
const extracted = isHtmlContentType(contentType) ? htmlToText(raw) : raw;
|
|
111
|
+
const body = extracted.length > maxFetchContentChars
|
|
112
|
+
? extracted.slice(0, maxFetchContentChars)
|
|
113
|
+
: extracted;
|
|
114
|
+
const truncated = rawTruncated || body.length < extracted.length;
|
|
115
|
+
return { status: res.status, contentType, body, truncated };
|
|
116
|
+
},
|
|
117
|
+
// http → an in-process fetch from the worker with the model's method/headers/body, egress-gated
|
|
118
|
+
// by the same Squid allowlist proxy as webfetch (NODE_USE_ENV_PROXY). UNLIKE webfetch it returns
|
|
119
|
+
// the RAW response (no HTML→text extraction) — `http` is for calling APIs — so it skips the
|
|
120
|
+
// content-chars cap and only bounds RAW bytes against worker memory.
|
|
121
|
+
httpRequest: async (req, opts) => {
|
|
122
|
+
assertHttpUrl(req.url, "http");
|
|
123
|
+
const rawCap = opts?.maxBytes ?? maxFetchBytes;
|
|
124
|
+
const res = await fetchImpl(req.url, {
|
|
125
|
+
method: req.method ?? "GET",
|
|
126
|
+
redirect: "follow",
|
|
127
|
+
...(req.headers !== undefined ? { headers: req.headers } : {}),
|
|
128
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
129
|
+
});
|
|
130
|
+
const contentType = res.headers.get("content-type") ?? undefined;
|
|
131
|
+
const { body, truncated } = await readCapped(res, rawCap);
|
|
132
|
+
return { status: res.status, contentType, body, truncated };
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Reject anything but http/https up front — `webfetch`/`http` must never reach `file:`, `data:`, or
|
|
137
|
+
* other schemes that could read local state or smuggle the proxy. */
|
|
138
|
+
function assertHttpUrl(url, tool = "webfetch") {
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = new URL(url);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new Error(`${tool}: invalid URL "${url}"`);
|
|
145
|
+
}
|
|
146
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
147
|
+
throw new Error(`${tool}: only http(s) URLs are allowed (got "${parsed.protocol}")`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Read a response body as UTF-8 text, stopping once `maxBytes` bytes have been consumed. Returns the
|
|
151
|
+
* decoded text and whether it was truncated, so a huge document can't exhaust worker memory. Falls
|
|
152
|
+
* back to a buffered read (still capped) when the body isn't a stream. */
|
|
153
|
+
async function readCapped(res, maxBytes) {
|
|
154
|
+
const stream = res.body;
|
|
155
|
+
if (stream === null) {
|
|
156
|
+
return { body: "", truncated: false };
|
|
157
|
+
}
|
|
158
|
+
const reader = stream.getReader();
|
|
159
|
+
const decoder = new TextDecoder();
|
|
160
|
+
const chunks = [];
|
|
161
|
+
let total = 0;
|
|
162
|
+
let truncated = false;
|
|
163
|
+
try {
|
|
164
|
+
for (;;) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done)
|
|
167
|
+
break;
|
|
168
|
+
const remaining = maxBytes - total;
|
|
169
|
+
if (value.length > remaining) {
|
|
170
|
+
chunks.push(value.subarray(0, remaining));
|
|
171
|
+
total = maxBytes;
|
|
172
|
+
truncated = true;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
chunks.push(value);
|
|
176
|
+
total += value.length;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await reader.cancel().catch(() => undefined);
|
|
181
|
+
}
|
|
182
|
+
const body = chunks.map((c) => decoder.decode(c, { stream: true })).join("") + decoder.decode();
|
|
183
|
+
return { body, truncated };
|
|
184
|
+
}
|
|
185
|
+
/** Whether a content-type names HTML (so we should extract readable text from it). */
|
|
186
|
+
function isHtmlContentType(contentType) {
|
|
187
|
+
if (contentType === undefined)
|
|
188
|
+
return false;
|
|
189
|
+
const ct = contentType.toLowerCase();
|
|
190
|
+
return ct.includes("text/html") || ct.includes("application/xhtml");
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Reduce an HTML document to readable text: drop non-content elements (script/style/head/etc.),
|
|
194
|
+
* turn block boundaries into newlines, strip remaining tags, decode common entities, and collapse
|
|
195
|
+
* whitespace. A pragmatic, dependency-free extractor — NOT a full readability pass (it does not
|
|
196
|
+
* score main-content vs. nav/ads), but it removes the bulk of the markup/script tokens that would
|
|
197
|
+
* otherwise flood the model. A heavier readability library could improve precision later.
|
|
198
|
+
*/
|
|
199
|
+
export function htmlToText(html) {
|
|
200
|
+
let s = html;
|
|
201
|
+
// Non-content elements, dropped whole (including their contents).
|
|
202
|
+
s = s.replace(/<(script|style|head|noscript|svg|template)\b[^>]*>[\s\S]*?<\/\1>/gi, " ");
|
|
203
|
+
s = s.replace(/<!--[\s\S]*?-->/g, " ");
|
|
204
|
+
// Block-level closers + line breaks become newlines so document structure partly survives.
|
|
205
|
+
s = s.replace(/<\/(p|div|section|article|header|footer|li|tr|h[1-6]|blockquote|pre)\s*>/gi, "\n");
|
|
206
|
+
s = s.replace(/<(br|hr)\s*\/?>/gi, "\n");
|
|
207
|
+
s = s.replace(/<[^>]+>/g, " "); // strip all remaining tags
|
|
208
|
+
s = decodeHtmlEntities(s);
|
|
209
|
+
// Collapse intra-line whitespace, trim around newlines, cap blank-line runs.
|
|
210
|
+
return s
|
|
211
|
+
.replace(/[ \t\f\v]+/g, " ")
|
|
212
|
+
.replace(/ *\n */g, "\n")
|
|
213
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
214
|
+
.trim();
|
|
215
|
+
}
|
|
216
|
+
const NAMED_ENTITIES = {
|
|
217
|
+
amp: "&",
|
|
218
|
+
lt: "<",
|
|
219
|
+
gt: ">",
|
|
220
|
+
quot: '"',
|
|
221
|
+
apos: "'",
|
|
222
|
+
nbsp: " ",
|
|
223
|
+
};
|
|
224
|
+
/** Decode the handful of HTML entities that show up in extracted prose, plus numeric refs. */
|
|
225
|
+
function decodeHtmlEntities(s) {
|
|
226
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi, (full, bodyRaw) => {
|
|
227
|
+
const body = bodyRaw.toLowerCase();
|
|
228
|
+
if (body.startsWith("#")) {
|
|
229
|
+
const codePoint = body.startsWith("#x")
|
|
230
|
+
? Number.parseInt(body.slice(2), 16)
|
|
231
|
+
: Number.parseInt(body.slice(1), 10);
|
|
232
|
+
return Number.isFinite(codePoint) && codePoint > 0 && codePoint <= 0x10ffff
|
|
233
|
+
? String.fromCodePoint(codePoint)
|
|
234
|
+
: full;
|
|
235
|
+
}
|
|
236
|
+
return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, body)
|
|
237
|
+
? (NAMED_ENTITIES[body] ?? full)
|
|
238
|
+
: full;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Default cancel-poll cadence. Snappier than the 60s credit poll so a user's cancel lands within a few
|
|
2
|
+
* seconds; each poll is a cheap brokered run-status read. */
|
|
3
|
+
export declare const DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5000;
|
|
4
|
+
export interface CancelWatcherDeps {
|
|
5
|
+
/** The run being watched (for correlation/logging). */
|
|
6
|
+
runId: string;
|
|
7
|
+
/** Resolves true once the run has been asked to cancel. Brokered
|
|
8
|
+
* (`RunnerControlClient.checkCancelled` → `GET /cancel`), so the runner never reads the DB. */
|
|
9
|
+
isCancelled: () => Promise<boolean>;
|
|
10
|
+
/** Fired exactly once the first time the run is seen to be cancelled. */
|
|
11
|
+
onCancelled: () => void;
|
|
12
|
+
intervalMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare class CancelWatcher {
|
|
15
|
+
private readonly deps;
|
|
16
|
+
private timer;
|
|
17
|
+
private firing;
|
|
18
|
+
private cancelled;
|
|
19
|
+
private stopped;
|
|
20
|
+
constructor(deps: CancelWatcherDeps);
|
|
21
|
+
/** Begin periodic cancel checks. */
|
|
22
|
+
start(): void;
|
|
23
|
+
/** Stop checking and drain any in-flight check. Idempotent. */
|
|
24
|
+
stop(): Promise<void>;
|
|
25
|
+
/** Whether a cancel was detected (the orchestrator may read this to shape the terminal result). */
|
|
26
|
+
wasCancelled(): boolean;
|
|
27
|
+
/** True once the watcher should no longer act. A method (not an inline field read) so it's
|
|
28
|
+
* re-evaluated after an `await` — `stop()` can flip `stopped` mid-check. */
|
|
29
|
+
private done;
|
|
30
|
+
private check;
|
|
31
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// CancelWatcher — stops a run when the user cancels it mid-flight (MASTER_SPEC §6 + docs/RUNNER_BROKER.md).
|
|
2
|
+
//
|
|
3
|
+
// The user-cancel counterpart to CreditWatcher. One per run session. On a timer it asks — through the
|
|
4
|
+
// broker (`GET /cancel`, since the runner holds no DB/Redis credential) — whether the run has been
|
|
5
|
+
// asked to cancel; the FIRST time it has, it fires `onCancelled` once (the orchestrator wires this to
|
|
6
|
+
// `AbortController.abort(new RunAbortedError("cancelled"))`, which the WorkflowHost honors cooperatively
|
|
7
|
+
// at the next hook boundary — a `sleep` hold wakes immediately, an `agent()`/`workflows.call` unwinds)
|
|
8
|
+
// and stops checking.
|
|
9
|
+
//
|
|
10
|
+
// Why brokered + polled (not Redis pub/sub): the brokered worker holds NO Redis client, so the
|
|
11
|
+
// api-server's `run-cancel:<id>` publish never reached it — a flipped-to-`cancelling` run just ran to
|
|
12
|
+
// completion. The broker exposes the run's status; the worker polls it, like funding.
|
|
13
|
+
import { createLogger } from "./support/index.js";
|
|
14
|
+
const log = createLogger("CancelWatcher");
|
|
15
|
+
/** Default cancel-poll cadence. Snappier than the 60s credit poll so a user's cancel lands within a few
|
|
16
|
+
* seconds; each poll is a cheap brokered run-status read. */
|
|
17
|
+
export const DEFAULT_CANCEL_CHECK_INTERVAL_MS = 5_000;
|
|
18
|
+
export class CancelWatcher {
|
|
19
|
+
deps;
|
|
20
|
+
timer = null;
|
|
21
|
+
firing = Promise.resolve();
|
|
22
|
+
cancelled = false;
|
|
23
|
+
stopped = false;
|
|
24
|
+
constructor(deps) {
|
|
25
|
+
this.deps = deps;
|
|
26
|
+
}
|
|
27
|
+
/** Begin periodic cancel checks. */
|
|
28
|
+
start() {
|
|
29
|
+
if (this.timer !== null)
|
|
30
|
+
return;
|
|
31
|
+
const interval = this.deps.intervalMs ?? DEFAULT_CANCEL_CHECK_INTERVAL_MS;
|
|
32
|
+
this.timer = setInterval(() => {
|
|
33
|
+
this.firing = this.firing.then(() => this.check());
|
|
34
|
+
}, interval);
|
|
35
|
+
// Don't keep the worker process alive solely for the cancel timer.
|
|
36
|
+
this.timer.unref();
|
|
37
|
+
}
|
|
38
|
+
/** Stop checking and drain any in-flight check. Idempotent. */
|
|
39
|
+
async stop() {
|
|
40
|
+
this.stopped = true;
|
|
41
|
+
if (this.timer !== null) {
|
|
42
|
+
clearInterval(this.timer);
|
|
43
|
+
this.timer = null;
|
|
44
|
+
}
|
|
45
|
+
await this.firing.catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
/** Whether a cancel was detected (the orchestrator may read this to shape the terminal result). */
|
|
48
|
+
wasCancelled() {
|
|
49
|
+
return this.cancelled;
|
|
50
|
+
}
|
|
51
|
+
/** True once the watcher should no longer act. A method (not an inline field read) so it's
|
|
52
|
+
* re-evaluated after an `await` — `stop()` can flip `stopped` mid-check. */
|
|
53
|
+
done() {
|
|
54
|
+
return this.stopped || this.cancelled;
|
|
55
|
+
}
|
|
56
|
+
async check() {
|
|
57
|
+
if (this.done())
|
|
58
|
+
return;
|
|
59
|
+
let cancelled;
|
|
60
|
+
try {
|
|
61
|
+
cancelled = await this.deps.isCancelled();
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
// A failed cancel check must NOT kill a healthy run — log and let the next tick retry.
|
|
65
|
+
log.warn("cancel_check_failed", {
|
|
66
|
+
runId: this.deps.runId,
|
|
67
|
+
error: err instanceof Error ? err.message : String(err),
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!cancelled || this.done())
|
|
72
|
+
return;
|
|
73
|
+
this.cancelled = true;
|
|
74
|
+
log.info("run_cancel_requested", { runId: this.deps.runId });
|
|
75
|
+
this.deps.onCancelled();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Default funding-check cadence. */
|
|
2
|
+
export declare const DEFAULT_CREDIT_CHECK_INTERVAL_MS = 60000;
|
|
3
|
+
export interface CreditWatcherDeps {
|
|
4
|
+
/** The run being watched (for correlation/logging). */
|
|
5
|
+
runId: string;
|
|
6
|
+
/** Resolves true while the org can keep spending; false once it's out of credit. Brokered
|
|
7
|
+
* (`RunnerControlClient.checkCredit` → `GET /credit`), so the runner never reads Stripe. */
|
|
8
|
+
isFunded: () => Promise<boolean>;
|
|
9
|
+
/** Fired exactly once when the org is first seen to be out of credit. */
|
|
10
|
+
onExhausted: () => void;
|
|
11
|
+
intervalMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare class CreditWatcher {
|
|
14
|
+
private readonly deps;
|
|
15
|
+
private timer;
|
|
16
|
+
private firing;
|
|
17
|
+
private exhausted;
|
|
18
|
+
private stopped;
|
|
19
|
+
constructor(deps: CreditWatcherDeps);
|
|
20
|
+
/** Begin periodic funding checks. */
|
|
21
|
+
start(): void;
|
|
22
|
+
/** Stop checking and drain any in-flight check. Idempotent. */
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
/** Whether exhaustion was detected (the orchestrator may read this to shape the terminal result). */
|
|
25
|
+
isExhausted(): boolean;
|
|
26
|
+
/** True once the watcher should no longer act. A method (not an inline field read) so it's
|
|
27
|
+
* re-evaluated after an `await` — `stop()` can flip `stopped` mid-check. */
|
|
28
|
+
private done;
|
|
29
|
+
private check;
|
|
30
|
+
}
|