@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.
Files changed (97) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +68 -0
  3. package/dist/bin.d.ts +2 -0
  4. package/dist/bin.js +214 -0
  5. package/dist/contract.d.ts +154 -0
  6. package/dist/contract.js +196 -0
  7. package/dist/daemon/daemon.d.ts +45 -0
  8. package/dist/daemon/daemon.js +129 -0
  9. package/dist/daemon/identity.d.ts +15 -0
  10. package/dist/daemon/identity.js +44 -0
  11. package/dist/daemon/index.d.ts +3 -0
  12. package/dist/daemon/index.js +4 -0
  13. package/dist/daemon/pool_client.d.ts +31 -0
  14. package/dist/daemon/pool_client.js +101 -0
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +8 -0
  17. package/dist/runtime/agent/budget.d.ts +123 -0
  18. package/dist/runtime/agent/budget.js +174 -0
  19. package/dist/runtime/agent/events.d.ts +46 -0
  20. package/dist/runtime/agent/events.js +8 -0
  21. package/dist/runtime/agent/model_rates.d.ts +7 -0
  22. package/dist/runtime/agent/model_rates.js +21 -0
  23. package/dist/runtime/agent/secret_redactor.d.ts +42 -0
  24. package/dist/runtime/agent/secret_redactor.js +98 -0
  25. package/dist/runtime/broker_artifact_store.d.ts +17 -0
  26. package/dist/runtime/broker_artifact_store.js +46 -0
  27. package/dist/runtime/broker_child_dispatcher.d.ts +31 -0
  28. package/dist/runtime/broker_child_dispatcher.js +88 -0
  29. package/dist/runtime/broker_event_publisher.d.ts +25 -0
  30. package/dist/runtime/broker_event_publisher.js +69 -0
  31. package/dist/runtime/broker_tool_host.d.ts +49 -0
  32. package/dist/runtime/broker_tool_host.js +240 -0
  33. package/dist/runtime/cancel_watcher.d.ts +31 -0
  34. package/dist/runtime/cancel_watcher.js +77 -0
  35. package/dist/runtime/credit_watcher.d.ts +30 -0
  36. package/dist/runtime/credit_watcher.js +78 -0
  37. package/dist/runtime/direct_inference.d.ts +29 -0
  38. package/dist/runtime/direct_inference.js +78 -0
  39. package/dist/runtime/index.d.ts +85 -0
  40. package/dist/runtime/index.js +525 -0
  41. package/dist/runtime/inference_transport.d.ts +5 -0
  42. package/dist/runtime/inference_transport.js +9 -0
  43. package/dist/runtime/leaf_executor.d.ts +104 -0
  44. package/dist/runtime/leaf_executor.js +333 -0
  45. package/dist/runtime/lease_renewer.d.ts +31 -0
  46. package/dist/runtime/lease_renewer.js +80 -0
  47. package/dist/runtime/main.d.ts +1 -0
  48. package/dist/runtime/main.js +7 -0
  49. package/dist/runtime/phase_tracker.d.ts +26 -0
  50. package/dist/runtime/phase_tracker.js +45 -0
  51. package/dist/runtime/program_log_capture.d.ts +20 -0
  52. package/dist/runtime/program_log_capture.js +79 -0
  53. package/dist/runtime/program_runner.d.ts +82 -0
  54. package/dist/runtime/program_runner.js +116 -0
  55. package/dist/runtime/program_worker.d.ts +177 -0
  56. package/dist/runtime/program_worker.js +272 -0
  57. package/dist/runtime/recording_secret_resolver.d.ts +9 -0
  58. package/dist/runtime/recording_secret_resolver.js +22 -0
  59. package/dist/runtime/run_abort.d.ts +16 -0
  60. package/dist/runtime/run_abort.js +38 -0
  61. package/dist/runtime/run_event_emitter.d.ts +24 -0
  62. package/dist/runtime/run_event_emitter.js +63 -0
  63. package/dist/runtime/runner_control_client.d.ts +184 -0
  64. package/dist/runtime/runner_control_client.js +501 -0
  65. package/dist/runtime/runtime_flusher.d.ts +41 -0
  66. package/dist/runtime/runtime_flusher.js +90 -0
  67. package/dist/runtime/sandbox_config.d.ts +21 -0
  68. package/dist/runtime/sandbox_config.js +34 -0
  69. package/dist/runtime/support/index.d.ts +53 -0
  70. package/dist/runtime/support/index.js +114 -0
  71. package/dist/runtime/suspension.d.ts +141 -0
  72. package/dist/runtime/suspension.js +120 -0
  73. package/dist/runtime/testing_artifact_build.d.ts +27 -0
  74. package/dist/runtime/testing_artifact_build.js +96 -0
  75. package/dist/runtime/tools/artifacts.d.ts +146 -0
  76. package/dist/runtime/tools/artifacts.js +125 -0
  77. package/dist/runtime/tools/types.d.ts +114 -0
  78. package/dist/runtime/tools/types.js +61 -0
  79. package/dist/runtime/tools/web_search.d.ts +66 -0
  80. package/dist/runtime/tools/web_search.js +165 -0
  81. package/dist/runtime/wire/artifact_storage.d.ts +37 -0
  82. package/dist/runtime/wire/artifact_storage.js +93 -0
  83. package/dist/runtime/wire/artifact_verify.d.ts +1 -0
  84. package/dist/runtime/wire/artifact_verify.js +8 -0
  85. package/dist/runtime/wire/human_input.d.ts +62 -0
  86. package/dist/runtime/wire/human_input.js +156 -0
  87. package/dist/runtime/wire/inference_proxy.d.ts +70 -0
  88. package/dist/runtime/wire/inference_proxy.js +157 -0
  89. package/dist/runtime/wire/manifest.d.ts +16 -0
  90. package/dist/runtime/wire/manifest.js +18 -0
  91. package/dist/runtime/wire/run.d.ts +54 -0
  92. package/dist/runtime/wire/run.js +2 -0
  93. package/dist/runtime/workflow_host.d.ts +194 -0
  94. package/dist/runtime/workflow_host.js +382 -0
  95. package/dist/runtime/workspace_store.d.ts +73 -0
  96. package/dist/runtime/workspace_store.js +118 -0
  97. 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
+ }