@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,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { BoardwalkTool } from "./types.js";
|
|
3
|
+
declare const artifactsInput: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
4
|
+
op: z.ZodLiteral<"write">;
|
|
5
|
+
name: z.ZodString;
|
|
6
|
+
content_type: z.ZodString;
|
|
7
|
+
body: z.ZodString;
|
|
8
|
+
encoding: z.ZodOptional<z.ZodEnum<{
|
|
9
|
+
utf8: "utf8";
|
|
10
|
+
base64: "base64";
|
|
11
|
+
}>>;
|
|
12
|
+
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
13
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
14
|
+
op: z.ZodLiteral<"list">;
|
|
15
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
16
|
+
op: z.ZodLiteral<"signed_url">;
|
|
17
|
+
artifact_id: z.ZodString;
|
|
18
|
+
ttl_seconds: z.ZodOptional<z.ZodNumber>;
|
|
19
|
+
}, z.core.$strip>], "op">;
|
|
20
|
+
type ArtifactsInput = z.infer<typeof artifactsInput>;
|
|
21
|
+
declare const artifactSummary: z.ZodObject<{
|
|
22
|
+
id: z.ZodString;
|
|
23
|
+
name: z.ZodString;
|
|
24
|
+
contentType: z.ZodString;
|
|
25
|
+
sizeBytes: z.ZodNumber;
|
|
26
|
+
createdAt: z.ZodNumber;
|
|
27
|
+
}, z.core.$strip>;
|
|
28
|
+
export type ArtifactSummary = z.infer<typeof artifactSummary>;
|
|
29
|
+
declare const artifactsData: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
30
|
+
op: z.ZodLiteral<"write">;
|
|
31
|
+
id: z.ZodString;
|
|
32
|
+
name: z.ZodString;
|
|
33
|
+
sizeBytes: z.ZodNumber;
|
|
34
|
+
signedUrl: z.ZodString;
|
|
35
|
+
expiresAt: z.ZodNumber;
|
|
36
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
37
|
+
op: z.ZodLiteral<"list">;
|
|
38
|
+
artifacts: z.ZodArray<z.ZodObject<{
|
|
39
|
+
id: z.ZodString;
|
|
40
|
+
name: z.ZodString;
|
|
41
|
+
contentType: z.ZodString;
|
|
42
|
+
sizeBytes: z.ZodNumber;
|
|
43
|
+
createdAt: z.ZodNumber;
|
|
44
|
+
}, z.core.$strip>>;
|
|
45
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
46
|
+
op: z.ZodLiteral<"signed_url">;
|
|
47
|
+
id: z.ZodString;
|
|
48
|
+
signedUrl: z.ZodString;
|
|
49
|
+
expiresAt: z.ZodNumber;
|
|
50
|
+
}, z.core.$strip>], "op">;
|
|
51
|
+
export type ArtifactsData = z.infer<typeof artifactsData>;
|
|
52
|
+
declare const artifactsOutput: z.ZodObject<{
|
|
53
|
+
kind: z.ZodLiteral<"artifacts">;
|
|
54
|
+
humanSummary: z.ZodString;
|
|
55
|
+
data: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
56
|
+
op: z.ZodLiteral<"write">;
|
|
57
|
+
id: z.ZodString;
|
|
58
|
+
name: z.ZodString;
|
|
59
|
+
sizeBytes: z.ZodNumber;
|
|
60
|
+
signedUrl: z.ZodString;
|
|
61
|
+
expiresAt: z.ZodNumber;
|
|
62
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
63
|
+
op: z.ZodLiteral<"list">;
|
|
64
|
+
artifacts: z.ZodArray<z.ZodObject<{
|
|
65
|
+
id: z.ZodString;
|
|
66
|
+
name: z.ZodString;
|
|
67
|
+
contentType: z.ZodString;
|
|
68
|
+
sizeBytes: z.ZodNumber;
|
|
69
|
+
createdAt: z.ZodNumber;
|
|
70
|
+
}, z.core.$strip>>;
|
|
71
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
72
|
+
op: z.ZodLiteral<"signed_url">;
|
|
73
|
+
id: z.ZodString;
|
|
74
|
+
signedUrl: z.ZodString;
|
|
75
|
+
expiresAt: z.ZodNumber;
|
|
76
|
+
}, z.core.$strip>], "op">;
|
|
77
|
+
}, z.core.$strip>;
|
|
78
|
+
export type ArtifactsOutput = z.infer<typeof artifactsOutput>;
|
|
79
|
+
/** What the tool asks the store to persist. The store owns the S3 key, content-type neutralization,
|
|
80
|
+
* the actual write, and the catalog row — none of which the (untrusted) runner controls. */
|
|
81
|
+
export interface ArtifactWriteInput {
|
|
82
|
+
name: string;
|
|
83
|
+
contentType: string;
|
|
84
|
+
/** The body as a string; `encoding` says how to decode it. */
|
|
85
|
+
body: string;
|
|
86
|
+
encoding?: "utf8" | "base64";
|
|
87
|
+
metadata?: Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
export interface ArtifactWriteResult {
|
|
90
|
+
id: string;
|
|
91
|
+
name: string;
|
|
92
|
+
sizeBytes: number;
|
|
93
|
+
signedUrl: string;
|
|
94
|
+
/** Absolute expiry (ms since epoch) of the returned signed URL. */
|
|
95
|
+
expiresAt: number;
|
|
96
|
+
}
|
|
97
|
+
export interface ArtifactSignResult {
|
|
98
|
+
signedUrl: string;
|
|
99
|
+
expiresAt: number;
|
|
100
|
+
}
|
|
101
|
+
/** Phase 1 of the large-artifact path: what the worker asks the broker to presign. The bytes then go
|
|
102
|
+
* straight to S3, never through the broker. `sizeBytes` is the decoded body length the runner reports
|
|
103
|
+
* (the broker re-validates it against the hard ceiling and rejects an over-cap artifact BEFORE the
|
|
104
|
+
* runner uploads). No catalog row is written yet — that happens at commit. */
|
|
105
|
+
export interface ArtifactPresignInput {
|
|
106
|
+
name: string;
|
|
107
|
+
contentType: string;
|
|
108
|
+
sizeBytes: number;
|
|
109
|
+
}
|
|
110
|
+
/** The broker's presign response: where + how to PUT the bytes, and the chosen `s3Key` the worker
|
|
111
|
+
* echoes back at commit. No catalog id / download URL yet — the row doesn't exist until the upload
|
|
112
|
+
* succeeds and the worker commits. */
|
|
113
|
+
export interface ArtifactPresignResult {
|
|
114
|
+
/** The S3 key the broker derived (under the run's prefix); echoed back to {@link commitArtifact}. */
|
|
115
|
+
s3Key: string;
|
|
116
|
+
/** Presigned S3 PUT URL — the runner uploads the bytes here directly (no broker body cap). */
|
|
117
|
+
uploadUrl: string;
|
|
118
|
+
/** Headers the PUT MUST send: the content type is pinned into the presigned signature, so a
|
|
119
|
+
* mismatched type is rejected by S3 (the runner can't store an active/XSS-able served type). */
|
|
120
|
+
uploadHeaders: Record<string, string>;
|
|
121
|
+
expiresAt: number;
|
|
122
|
+
}
|
|
123
|
+
/** Phase 2 of the large-artifact path: register the catalog row AFTER the bytes have landed in S3.
|
|
124
|
+
* The worker echoes the presign's `s3Key`; the broker re-validates the run prefix + re-neutralizes
|
|
125
|
+
* the content type server-side. Returns an {@link ArtifactWriteResult} (id + signed download URL). */
|
|
126
|
+
export interface ArtifactCommitInput {
|
|
127
|
+
s3Key: string;
|
|
128
|
+
name: string;
|
|
129
|
+
contentType: string;
|
|
130
|
+
sizeBytes: number;
|
|
131
|
+
metadata?: Record<string, unknown>;
|
|
132
|
+
}
|
|
133
|
+
/** The storage backend the tool delegates to. The run is implied (the store is per-run bound —
|
|
134
|
+
* brokered: the run token; local: the run's context). */
|
|
135
|
+
export interface ArtifactStore {
|
|
136
|
+
write(input: ArtifactWriteInput): Promise<ArtifactWriteResult>;
|
|
137
|
+
list(): Promise<ArtifactSummary[]>;
|
|
138
|
+
signedUrl(artifactId: string, ttlSeconds: number): Promise<ArtifactSignResult>;
|
|
139
|
+
}
|
|
140
|
+
export interface ArtifactsDeps {
|
|
141
|
+
store: ArtifactStore;
|
|
142
|
+
/** Default TTL for the `signed_url` op when the caller doesn't request one. */
|
|
143
|
+
defaultTtlSeconds?: number;
|
|
144
|
+
}
|
|
145
|
+
export declare function makeArtifactsTool(deps: ArtifactsDeps): BoardwalkTool<ArtifactsInput, ArtifactsOutput>;
|
|
146
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// artifacts — persist + reference run outputs (MASTER_SPEC §9.1 + §13.6). Available to any agent
|
|
2
|
+
// (no sandbox required). Three operations:
|
|
3
|
+
// * write(name, content_type, body) → store a file under the run's prefix; returns id + a signed URL.
|
|
4
|
+
// * list() → artifacts produced in THIS run.
|
|
5
|
+
// * signed_url(artifact_id, ttl?) → mint a fresh signed download URL.
|
|
6
|
+
//
|
|
7
|
+
// This tool is a THIN agent-facing surface: it validates input + formats output and delegates the
|
|
8
|
+
// actual storage to an injected `ArtifactStore`. Under the Runner Credential Broker
|
|
9
|
+
// (docs/RUNNER_BROKER.md) the store is broker-backed — the broker computes the S3 key, neutralizes
|
|
10
|
+
// the content type (review #32), PUTs with its own creds, and records the catalog row, so the
|
|
11
|
+
// untrusted runner holds no S3 credential and can't bypass those server-side rules.
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { ARTIFACT_MAX_BYTES } from "../wire/artifact_storage.js";
|
|
14
|
+
// Body cap: a small write travels through the broker's buffered Runner Control endpoint (≤5 MiB
|
|
15
|
+
// wire); a large one takes the presigned-PUT path (docs/RUNNER_BROKER.md §7 step 4) that streams the
|
|
16
|
+
// body straight to S3. Either way the artifact is bounded by ARTIFACT_MAX_BYTES — the cap here is on
|
|
17
|
+
// the body STRING, sized for a full-size binary artifact carried as base64 (~4/3 inflation). The
|
|
18
|
+
// broker re-validates the true byte size server-side (the authoritative check).
|
|
19
|
+
const MAX_BODY_CHARS = Math.ceil(ARTIFACT_MAX_BYTES / 3) * 4;
|
|
20
|
+
const MAX_TTL_SECONDS = 7 * 24 * 60 * 60;
|
|
21
|
+
const DEFAULT_TTL_SECONDS = 3600;
|
|
22
|
+
const artifactsInput = z.discriminatedUnion("op", [
|
|
23
|
+
z.object({
|
|
24
|
+
op: z.literal("write"),
|
|
25
|
+
name: z.string().min(1).max(255),
|
|
26
|
+
content_type: z.string().min(1).max(200),
|
|
27
|
+
body: z.string().max(MAX_BODY_CHARS),
|
|
28
|
+
/** How `body` is encoded. `base64` lets agents persist binary content. */
|
|
29
|
+
encoding: z.enum(["utf8", "base64"]).optional(),
|
|
30
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
31
|
+
}),
|
|
32
|
+
z.object({ op: z.literal("list") }),
|
|
33
|
+
z.object({
|
|
34
|
+
op: z.literal("signed_url"),
|
|
35
|
+
artifact_id: z.string().min(1).max(64),
|
|
36
|
+
ttl_seconds: z.number().int().positive().max(MAX_TTL_SECONDS).optional(),
|
|
37
|
+
}),
|
|
38
|
+
]);
|
|
39
|
+
const artifactSummary = z.object({
|
|
40
|
+
id: z.string(),
|
|
41
|
+
name: z.string(),
|
|
42
|
+
contentType: z.string(),
|
|
43
|
+
sizeBytes: z.number(),
|
|
44
|
+
createdAt: z.number(),
|
|
45
|
+
});
|
|
46
|
+
const artifactsData = z.discriminatedUnion("op", [
|
|
47
|
+
z.object({
|
|
48
|
+
op: z.literal("write"),
|
|
49
|
+
id: z.string(),
|
|
50
|
+
name: z.string(),
|
|
51
|
+
sizeBytes: z.number(),
|
|
52
|
+
signedUrl: z.string(),
|
|
53
|
+
expiresAt: z.number(),
|
|
54
|
+
}),
|
|
55
|
+
z.object({ op: z.literal("list"), artifacts: z.array(artifactSummary) }),
|
|
56
|
+
z.object({
|
|
57
|
+
op: z.literal("signed_url"),
|
|
58
|
+
id: z.string(),
|
|
59
|
+
signedUrl: z.string(),
|
|
60
|
+
expiresAt: z.number(),
|
|
61
|
+
}),
|
|
62
|
+
]);
|
|
63
|
+
const artifactsOutput = z.object({
|
|
64
|
+
kind: z.literal("artifacts"),
|
|
65
|
+
humanSummary: z.string(),
|
|
66
|
+
data: artifactsData,
|
|
67
|
+
});
|
|
68
|
+
export function makeArtifactsTool(deps) {
|
|
69
|
+
const defaultTtl = deps.defaultTtlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
70
|
+
return {
|
|
71
|
+
name: "artifacts",
|
|
72
|
+
description: "Persist and reference run outputs. write({name, content_type, body}) stores a file; list() shows this run's artifacts; signed_url({artifact_id}) mints a download link.",
|
|
73
|
+
inputSchema: artifactsInput,
|
|
74
|
+
outputSchema: artifactsOutput,
|
|
75
|
+
secretsRequired: [],
|
|
76
|
+
async invoke(input, _ctx) {
|
|
77
|
+
switch (input.op) {
|
|
78
|
+
case "write": {
|
|
79
|
+
const writeInput = {
|
|
80
|
+
name: input.name,
|
|
81
|
+
contentType: input.content_type,
|
|
82
|
+
body: input.body,
|
|
83
|
+
...(input.encoding !== undefined ? { encoding: input.encoding } : {}),
|
|
84
|
+
...(input.metadata !== undefined ? { metadata: input.metadata } : {}),
|
|
85
|
+
};
|
|
86
|
+
const r = await deps.store.write(writeInput);
|
|
87
|
+
return {
|
|
88
|
+
kind: "artifacts",
|
|
89
|
+
humanSummary: `Wrote artifact ${r.name} (${r.sizeBytes.toString()} bytes)`,
|
|
90
|
+
data: {
|
|
91
|
+
op: "write",
|
|
92
|
+
id: r.id,
|
|
93
|
+
name: r.name,
|
|
94
|
+
sizeBytes: r.sizeBytes,
|
|
95
|
+
signedUrl: r.signedUrl,
|
|
96
|
+
expiresAt: r.expiresAt,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
case "list": {
|
|
101
|
+
const artifacts = await deps.store.list();
|
|
102
|
+
return {
|
|
103
|
+
kind: "artifacts",
|
|
104
|
+
humanSummary: `Listed ${artifacts.length.toString()} artifact(s) for this run`,
|
|
105
|
+
data: { op: "list", artifacts },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
case "signed_url": {
|
|
109
|
+
const ttl = input.ttl_seconds ?? defaultTtl;
|
|
110
|
+
const r = await deps.store.signedUrl(input.artifact_id, ttl);
|
|
111
|
+
return {
|
|
112
|
+
kind: "artifacts",
|
|
113
|
+
humanSummary: `Minted signed URL for artifact ${input.artifact_id}`,
|
|
114
|
+
data: {
|
|
115
|
+
op: "signed_url",
|
|
116
|
+
id: input.artifact_id,
|
|
117
|
+
signedUrl: r.signedUrl,
|
|
118
|
+
expiresAt: r.expiresAt,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { AuthContext } from "../support/index.js";
|
|
3
|
+
import type { SecretRefManifest } from "../wire/manifest.js";
|
|
4
|
+
/**
|
|
5
|
+
* Per-invocation context the worker threads through to a tool. Equivalent to
|
|
6
|
+
* Strands' `ToolContext` but typed in Boardwalk's idiom (AuthContext + run id +
|
|
7
|
+
* secret resolver scoped to this run's permissions).
|
|
8
|
+
*/
|
|
9
|
+
export interface ToolContext {
|
|
10
|
+
readonly auth: AuthContext;
|
|
11
|
+
/** Run id (`runs.id`). Used for tagging artifacts + correlation. */
|
|
12
|
+
readonly runId: string;
|
|
13
|
+
/** Org-scoped resolver. Returns the secret VALUE, not the ARN. */
|
|
14
|
+
readonly secrets: SecretResolver;
|
|
15
|
+
}
|
|
16
|
+
export interface SecretResolver {
|
|
17
|
+
/**
|
|
18
|
+
* Resolve a manifest secret reference to its plaintext value. Throws
|
|
19
|
+
* `AppError(FORBIDDEN)` when the reference isn't in the agent's
|
|
20
|
+
* `permissions.secrets` allowlist, or `AppError(NOT_FOUND)` when the named
|
|
21
|
+
* secret doesn't exist in the org.
|
|
22
|
+
*/
|
|
23
|
+
resolve(ref: SecretRefManifest): Promise<string>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Boardwalk-side tool interface. Concrete tools either:
|
|
27
|
+
* * Return a `ToolReturn` body — synchronous "normal" tools (echo, http,
|
|
28
|
+
* web_search). The body lands in the conversation as the assistant's
|
|
29
|
+
* tool-result block.
|
|
30
|
+
* * Return a `ToolControlSignal` — the legacy Strands-level sleep/workflows.call path. The
|
|
31
|
+
* current JS-body worker exposes these as program SDK hooks instead; agent() leaves strip
|
|
32
|
+
* control-flow tools before registering model-callable tools.
|
|
33
|
+
*/
|
|
34
|
+
export interface BoardwalkTool<TInput = unknown, TOutput = unknown> {
|
|
35
|
+
/** Stable tool name — what an `agent()` call names in `AgentOptions.tools`. */
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Description surfaced to the model. */
|
|
38
|
+
readonly description: string;
|
|
39
|
+
/** Zod schema for the tool's input. */
|
|
40
|
+
readonly inputSchema: z.ZodType<TInput>;
|
|
41
|
+
/**
|
|
42
|
+
* Zod schema for the tool's SUCCESS output (the `TOutput` shape — never the
|
|
43
|
+
* control-signal branch). The adapter validates the tool's return value
|
|
44
|
+
* against this before it lands in the LLM conversation, per CODE_QUALITY
|
|
45
|
+
* §2.1/§8.3 (LLM-facing output is treated like untrusted external input).
|
|
46
|
+
*/
|
|
47
|
+
readonly outputSchema: z.ZodType<TOutput>;
|
|
48
|
+
/**
|
|
49
|
+
* Secret names this tool requires (matching entries in the org's secret
|
|
50
|
+
* store / the manifest's `permissions.secrets` allowlist). Used for
|
|
51
|
+
* declarative per-tool secret scoping in the sandbox. Most built-ins are
|
|
52
|
+
* `[]` because they either need no secrets or receive them via `ctx`
|
|
53
|
+
* (resolved env / `ctx.secrets`); `web_search` declares the Tavily key.
|
|
54
|
+
*/
|
|
55
|
+
readonly secretsRequired: readonly string[];
|
|
56
|
+
/**
|
|
57
|
+
* Optional: normalize the raw LLM-supplied args BEFORE `inputSchema.parse`. Lets a tool accept a
|
|
58
|
+
* common-but-wrong shape the model tends to emit (e.g. `{command:"clone"}` aliased to the schema's
|
|
59
|
+
* `{op:"clone"}` discriminator) so the first attempt succeeds instead of bouncing off a `ZodError`
|
|
60
|
+
* and costing a retry. Keeps `inputSchema` (the model-facing JSON schema) pristine — the alias is
|
|
61
|
+
* applied at the adapter's parse boundary, never exposed. Must be pure + total (return the input
|
|
62
|
+
* unchanged when nothing applies).
|
|
63
|
+
*/
|
|
64
|
+
normalizeInput?(raw: unknown): unknown;
|
|
65
|
+
/**
|
|
66
|
+
* Invoke the tool. `input` (LLM-supplied) comes first, `ctx` (worker-supplied)
|
|
67
|
+
* second — see the `Tool` naming note in SPEC §9. Returns either the typed
|
|
68
|
+
* `TOutput` body or a `ToolControlSignal` (sleep / workflows.call) the worker
|
|
69
|
+
* intercepts before serialization.
|
|
70
|
+
*/
|
|
71
|
+
invoke(input: TInput, ctx: ToolContext): Promise<TOutput | ToolControlSignal>;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Discriminated union of "I'm not really returning a result; tell the engine
|
|
75
|
+
* something." Worker checks `result instanceof ToolControlSignal` (well —
|
|
76
|
+
* structurally on `__signal`) before serializing into the conversation.
|
|
77
|
+
*/
|
|
78
|
+
export type ToolControlSignal = SleepControlSignal | WaitForChildControlSignal;
|
|
79
|
+
export interface SleepControlSignal {
|
|
80
|
+
readonly __signal: "sleep";
|
|
81
|
+
/** Wall-clock time to wake at (ms since epoch). */
|
|
82
|
+
readonly wakeAtMs: number;
|
|
83
|
+
}
|
|
84
|
+
export interface WaitForChildControlSignal {
|
|
85
|
+
readonly __signal: "wait_for_child";
|
|
86
|
+
/** The child run id the parent is now blocked on. */
|
|
87
|
+
readonly childRunId: string;
|
|
88
|
+
}
|
|
89
|
+
export declare function isControlSignal(value: unknown): value is ToolControlSignal;
|
|
90
|
+
/**
|
|
91
|
+
* Tool registry — maps a tool name to its concrete `BoardwalkTool`. Built once
|
|
92
|
+
* per worker process; each `agent()` leaf sees a filtered view based on the
|
|
93
|
+
* tools that call named (`AgentOptions.tools`), materialized via
|
|
94
|
+
* `materializeFor(grants)`.
|
|
95
|
+
*/
|
|
96
|
+
export declare class ToolRegistry {
|
|
97
|
+
private readonly tools;
|
|
98
|
+
register(tool: BoardwalkTool): void;
|
|
99
|
+
get(name: string): BoardwalkTool | undefined;
|
|
100
|
+
has(name: string): boolean;
|
|
101
|
+
/** Names of every registered tool. Used by the `list_tools` MCP tool. */
|
|
102
|
+
list(): string[];
|
|
103
|
+
/**
|
|
104
|
+
* Filter the registry down to the tools granted by a manifest. Names absent
|
|
105
|
+
* from the registry are returned in `missing` so the worker can fail-loud
|
|
106
|
+
* before the LLM tries to call something that doesn't exist.
|
|
107
|
+
*/
|
|
108
|
+
materializeFor(grants: readonly {
|
|
109
|
+
name: string;
|
|
110
|
+
}[]): {
|
|
111
|
+
tools: BoardwalkTool[];
|
|
112
|
+
missing: string[];
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Boardwalk's internal Tool contract. Concrete tools (`echo`, `http`, `web_search`, `sleep`,
|
|
2
|
+
// `workflows.call`, …) implement this interface; the worker registers a `ZodTool`-flavored
|
|
3
|
+
// adapter into Strands at agent construction time.
|
|
4
|
+
//
|
|
5
|
+
// Why a Boardwalk-side interface instead of using `ZodTool` directly?
|
|
6
|
+
// * Tests can drive tool logic without instantiating Strands.
|
|
7
|
+
// * Control-signal tools (`sleep`, `workflows.call`) need to return a
|
|
8
|
+
// specially-shaped value the worker interprets — they don't really "run";
|
|
9
|
+
// they bubble a signal up to the engine. Our adapter (lands with the worker
|
|
10
|
+
// in Phase 10.5) translates between this interface and the Strands surface.
|
|
11
|
+
//
|
|
12
|
+
// Per MASTER_SPEC §12: tools NEVER see secret values. Secrets resolve to
|
|
13
|
+
// short-lived bearer tokens, ARNs, etc. via the injected `SecretResolver`.
|
|
14
|
+
export function isControlSignal(value) {
|
|
15
|
+
return (typeof value === "object" &&
|
|
16
|
+
value !== null &&
|
|
17
|
+
"__signal" in value &&
|
|
18
|
+
typeof value.__signal === "string");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tool registry — maps a tool name to its concrete `BoardwalkTool`. Built once
|
|
22
|
+
* per worker process; each `agent()` leaf sees a filtered view based on the
|
|
23
|
+
* tools that call named (`AgentOptions.tools`), materialized via
|
|
24
|
+
* `materializeFor(grants)`.
|
|
25
|
+
*/
|
|
26
|
+
export class ToolRegistry {
|
|
27
|
+
tools = new Map();
|
|
28
|
+
register(tool) {
|
|
29
|
+
if (this.tools.has(tool.name)) {
|
|
30
|
+
throw new Error(`Tool ${tool.name} is already registered`);
|
|
31
|
+
}
|
|
32
|
+
this.tools.set(tool.name, tool);
|
|
33
|
+
}
|
|
34
|
+
get(name) {
|
|
35
|
+
return this.tools.get(name);
|
|
36
|
+
}
|
|
37
|
+
has(name) {
|
|
38
|
+
return this.tools.has(name);
|
|
39
|
+
}
|
|
40
|
+
/** Names of every registered tool. Used by the `list_tools` MCP tool. */
|
|
41
|
+
list() {
|
|
42
|
+
return Array.from(this.tools.keys()).sort();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Filter the registry down to the tools granted by a manifest. Names absent
|
|
46
|
+
* from the registry are returned in `missing` so the worker can fail-loud
|
|
47
|
+
* before the LLM tries to call something that doesn't exist.
|
|
48
|
+
*/
|
|
49
|
+
materializeFor(grants) {
|
|
50
|
+
const tools = [];
|
|
51
|
+
const missing = [];
|
|
52
|
+
for (const grant of grants) {
|
|
53
|
+
const tool = this.tools.get(grant.name);
|
|
54
|
+
if (tool === undefined)
|
|
55
|
+
missing.push(grant.name);
|
|
56
|
+
else
|
|
57
|
+
tools.push(tool);
|
|
58
|
+
}
|
|
59
|
+
return { tools, missing };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { BoardwalkTool } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Logical secret name this tool depends on (the org/stage prefix is applied by
|
|
5
|
+
* the resolver — see the file header). Surfaced via `secretsRequired` so the
|
|
6
|
+
* sandbox can scope secret access declaratively without the value ever entering
|
|
7
|
+
* the tool's input.
|
|
8
|
+
*/
|
|
9
|
+
export declare const TAVILY_SECRET_NAME = "tavily/api-key";
|
|
10
|
+
export declare const webSearchInput: z.ZodObject<{
|
|
11
|
+
query: z.ZodString;
|
|
12
|
+
max_results: z.ZodOptional<z.ZodNumber>;
|
|
13
|
+
include_content: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
search_depth: z.ZodOptional<z.ZodEnum<{
|
|
15
|
+
basic: "basic";
|
|
16
|
+
advanced: "advanced";
|
|
17
|
+
}>>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export type WebSearchInput = z.infer<typeof webSearchInput>;
|
|
20
|
+
declare const webSearchResult: z.ZodObject<{
|
|
21
|
+
title: z.ZodString;
|
|
22
|
+
url: z.ZodString;
|
|
23
|
+
snippet: z.ZodString;
|
|
24
|
+
content: z.ZodOptional<z.ZodString>;
|
|
25
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export type WebSearchResult = z.infer<typeof webSearchResult>;
|
|
28
|
+
declare const webSearchOutput: z.ZodObject<{
|
|
29
|
+
kind: z.ZodLiteral<"web_search">;
|
|
30
|
+
humanSummary: z.ZodString;
|
|
31
|
+
data: z.ZodObject<{
|
|
32
|
+
query: z.ZodString;
|
|
33
|
+
results: z.ZodArray<z.ZodObject<{
|
|
34
|
+
title: z.ZodString;
|
|
35
|
+
url: z.ZodString;
|
|
36
|
+
snippet: z.ZodString;
|
|
37
|
+
content: z.ZodOptional<z.ZodString>;
|
|
38
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
39
|
+
}, z.core.$strip>>;
|
|
40
|
+
answer: z.ZodOptional<z.ZodString>;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
export type WebSearchOutput = z.infer<typeof webSearchOutput>;
|
|
44
|
+
export interface WebSearchDeps {
|
|
45
|
+
/** Returns the Tavily API key. Cached at the caller (one resolve per cold start). */
|
|
46
|
+
resolveApiKey: () => Promise<string>;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
}
|
|
49
|
+
export declare function makeWebSearchTool(deps: WebSearchDeps): BoardwalkTool<WebSearchInput, WebSearchOutput>;
|
|
50
|
+
/**
|
|
51
|
+
* Broker-backed web_search for the runner (docs/RUNNER_BROKER.md): identical name/schemas/behavior
|
|
52
|
+
* to {@link makeWebSearchTool}, but the Tavily call runs in the broker (which holds the platform key)
|
|
53
|
+
* — the runner just forwards the query, so it needs NO Tavily secret (`secretsRequired: []`). Wired on
|
|
54
|
+
* the worker when the control plane is on; the direct tool above is the pre-broker/local path.
|
|
55
|
+
*/
|
|
56
|
+
export declare function makeBrokerWebSearchTool(deps: {
|
|
57
|
+
search: (input: WebSearchInput) => Promise<WebSearchOutput>;
|
|
58
|
+
}): BoardwalkTool<WebSearchInput, WebSearchOutput>;
|
|
59
|
+
/**
|
|
60
|
+
* Drop near-duplicate results from the same hostname, keeping the first (highest-ranked, since
|
|
61
|
+
* Tavily returns by relevance). The agent shouldn't pay tokens for three hits off one site. A
|
|
62
|
+
* result whose URL won't parse is kept (we can't classify it, so we don't drop it). Exported for
|
|
63
|
+
* unit testing.
|
|
64
|
+
*/
|
|
65
|
+
export declare function dedupeByDomain(results: WebSearchResult[]): WebSearchResult[];
|
|
66
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// web_search — wraps the Tavily API (https://docs.tavily.com/docs/api-reference).
|
|
2
|
+
//
|
|
3
|
+
// Per MASTER_SPEC §13.5: Tavily is Boardwalk's default web-search provider. API
|
|
4
|
+
// key fetched from Secrets Manager (`boardwalk/<stage>/tavily/api-key`) and
|
|
5
|
+
// cached at the module level per container, NEVER appearing in the agent's
|
|
6
|
+
// conversation.
|
|
7
|
+
//
|
|
8
|
+
// 429 from Tavily → `AppError(RATE_LIMIT)` so Strands' retry strategy handles
|
|
9
|
+
// backoff. 5xx → `AppError(TOOL_ERROR)`. Network-level fetch failures fall
|
|
10
|
+
// back to `TOOL_ERROR`.
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { AppError, ErrorCode } from "../support/index.js";
|
|
13
|
+
const TAVILY_ENDPOINT = "https://api.tavily.com/search";
|
|
14
|
+
const MAX_RESULTS_CAP = 20;
|
|
15
|
+
/**
|
|
16
|
+
* Logical secret name this tool depends on (the org/stage prefix is applied by
|
|
17
|
+
* the resolver — see the file header). Surfaced via `secretsRequired` so the
|
|
18
|
+
* sandbox can scope secret access declaratively without the value ever entering
|
|
19
|
+
* the tool's input.
|
|
20
|
+
*/
|
|
21
|
+
export const TAVILY_SECRET_NAME = "tavily/api-key";
|
|
22
|
+
export const webSearchInput = z.object({
|
|
23
|
+
query: z.string().min(1).max(2000),
|
|
24
|
+
/** Clamped to [1, 20]. */
|
|
25
|
+
max_results: z.number().int().positive().optional(),
|
|
26
|
+
/** When true, include raw content alongside the snippet. */
|
|
27
|
+
include_content: z.boolean().optional(),
|
|
28
|
+
/**
|
|
29
|
+
* "basic" (default) | "advanced". Advanced is more thorough at higher
|
|
30
|
+
* latency; we surface both so agents can pick when accuracy matters.
|
|
31
|
+
*/
|
|
32
|
+
search_depth: z.enum(["basic", "advanced"]).optional(),
|
|
33
|
+
});
|
|
34
|
+
const webSearchResult = z.object({
|
|
35
|
+
title: z.string(),
|
|
36
|
+
url: z.string(),
|
|
37
|
+
snippet: z.string(),
|
|
38
|
+
/** Present only when include_content=true. */
|
|
39
|
+
content: z.string().optional(),
|
|
40
|
+
/** Tavily's relevance score (0..1). */
|
|
41
|
+
score: z.number().optional(),
|
|
42
|
+
});
|
|
43
|
+
const webSearchOutput = z.object({
|
|
44
|
+
kind: z.literal("web_search"),
|
|
45
|
+
humanSummary: z.string(),
|
|
46
|
+
data: z.object({
|
|
47
|
+
query: z.string(),
|
|
48
|
+
results: z.array(webSearchResult),
|
|
49
|
+
/** Tavily's optional answer summary. */
|
|
50
|
+
answer: z.string().optional(),
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
export function makeWebSearchTool(deps) {
|
|
54
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
55
|
+
return {
|
|
56
|
+
name: "web_search",
|
|
57
|
+
description: "Search the public web via Tavily. Returns up to 20 result rows with title, URL, snippet, and optional raw content.",
|
|
58
|
+
inputSchema: webSearchInput,
|
|
59
|
+
outputSchema: webSearchOutput,
|
|
60
|
+
secretsRequired: [TAVILY_SECRET_NAME],
|
|
61
|
+
async invoke(input, _ctx) {
|
|
62
|
+
const apiKey = await deps.resolveApiKey();
|
|
63
|
+
const maxResults = clamp(input.max_results ?? 5, 1, MAX_RESULTS_CAP);
|
|
64
|
+
const searchDepth = input.search_depth ?? "basic";
|
|
65
|
+
const payload = {
|
|
66
|
+
api_key: apiKey,
|
|
67
|
+
query: input.query,
|
|
68
|
+
max_results: maxResults,
|
|
69
|
+
include_raw_content: input.include_content === true,
|
|
70
|
+
search_depth: searchDepth,
|
|
71
|
+
// Advanced mode otherwise returns up to 3 ~500-char chunks per source; one keeps results
|
|
72
|
+
// lean. Only valid for advanced depth, so send it only then.
|
|
73
|
+
...(searchDepth === "advanced" ? { chunks_per_source: 1 } : {}),
|
|
74
|
+
};
|
|
75
|
+
let res;
|
|
76
|
+
try {
|
|
77
|
+
res = await fetchImpl(TAVILY_ENDPOINT, {
|
|
78
|
+
method: "POST",
|
|
79
|
+
headers: { "Content-Type": "application/json" },
|
|
80
|
+
body: JSON.stringify(payload),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
85
|
+
throw new AppError(ErrorCode.TOOL_ERROR, `Tavily request failed: ${message}`);
|
|
86
|
+
}
|
|
87
|
+
if (res.status === 429) {
|
|
88
|
+
throw new AppError(ErrorCode.RATE_LIMIT, "Tavily rate limit exceeded");
|
|
89
|
+
}
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
throw new AppError(ErrorCode.TOOL_ERROR, `Tavily returned ${res.status.toString()}`);
|
|
92
|
+
}
|
|
93
|
+
const json = (await res.json());
|
|
94
|
+
const results = dedupeByDomain((json.results ?? []).map((r) => projectResult(r, input.include_content === true)));
|
|
95
|
+
const summary = `Found ${results.length.toString()} result${results.length === 1 ? "" : "s"} for "${input.query}"`;
|
|
96
|
+
const data = {
|
|
97
|
+
query: input.query,
|
|
98
|
+
results,
|
|
99
|
+
};
|
|
100
|
+
if (typeof json.answer === "string" && json.answer.length > 0) {
|
|
101
|
+
data.answer = json.answer;
|
|
102
|
+
}
|
|
103
|
+
return { kind: "web_search", humanSummary: summary, data };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Broker-backed web_search for the runner (docs/RUNNER_BROKER.md): identical name/schemas/behavior
|
|
109
|
+
* to {@link makeWebSearchTool}, but the Tavily call runs in the broker (which holds the platform key)
|
|
110
|
+
* — the runner just forwards the query, so it needs NO Tavily secret (`secretsRequired: []`). Wired on
|
|
111
|
+
* the worker when the control plane is on; the direct tool above is the pre-broker/local path.
|
|
112
|
+
*/
|
|
113
|
+
export function makeBrokerWebSearchTool(deps) {
|
|
114
|
+
return {
|
|
115
|
+
name: "web_search",
|
|
116
|
+
description: "Search the public web via Tavily. Returns up to 20 result rows with title, URL, snippet, and optional raw content.",
|
|
117
|
+
inputSchema: webSearchInput,
|
|
118
|
+
outputSchema: webSearchOutput,
|
|
119
|
+
secretsRequired: [],
|
|
120
|
+
invoke: (input, _ctx) => deps.search(input),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function projectResult(r, includeContent) {
|
|
124
|
+
const out = {
|
|
125
|
+
title: r.title ?? "",
|
|
126
|
+
url: r.url ?? "",
|
|
127
|
+
snippet: r.content ?? "",
|
|
128
|
+
};
|
|
129
|
+
if (typeof r.score === "number")
|
|
130
|
+
out.score = r.score;
|
|
131
|
+
if (includeContent && typeof r.raw_content === "string")
|
|
132
|
+
out.content = r.raw_content;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
function clamp(value, min, max) {
|
|
136
|
+
return Math.max(min, Math.min(max, value));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Drop near-duplicate results from the same hostname, keeping the first (highest-ranked, since
|
|
140
|
+
* Tavily returns by relevance). The agent shouldn't pay tokens for three hits off one site. A
|
|
141
|
+
* result whose URL won't parse is kept (we can't classify it, so we don't drop it). Exported for
|
|
142
|
+
* unit testing.
|
|
143
|
+
*/
|
|
144
|
+
export function dedupeByDomain(results) {
|
|
145
|
+
const seen = new Set();
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const result of results) {
|
|
148
|
+
const host = domainOf(result.url);
|
|
149
|
+
if (host !== null) {
|
|
150
|
+
if (seen.has(host))
|
|
151
|
+
continue;
|
|
152
|
+
seen.add(host);
|
|
153
|
+
}
|
|
154
|
+
out.push(result);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
function domainOf(url) {
|
|
159
|
+
try {
|
|
160
|
+
return new URL(url).hostname.toLowerCase();
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null; // malformed URL — keep it rather than silently dropping
|
|
164
|
+
}
|
|
165
|
+
}
|