pi-roundtable-sandbox 0.23.2 → 0.24.1

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/src/commands.ts CHANGED
@@ -11,7 +11,10 @@ import type { SandboxChannelStore } from "./channel-store.ts";
11
11
  /** Owner-only /roundtable sandbox on|off|status; mode changes use the same channel queue as turns. */
12
12
  export function sandboxCommands(
13
13
  guard: CommandGuard,
14
- channels: SandboxChannelStore,
14
+ channels: Pick<
15
+ SandboxChannelStore,
16
+ "configured" | "disable" | "enable" | "has"
17
+ >,
15
18
  queue: QueuePort,
16
19
  ): InteractionContribution {
17
20
  return {
@@ -47,8 +50,21 @@ export function sandboxCommands(
47
50
  "Sandbox commands require a guild channel.",
48
51
  );
49
52
  const channel = `discord:${interaction.channelId}` as const;
53
+ const configured = channels.configured(
54
+ interaction.guildId ?? undefined,
55
+ );
50
56
  await queue.run(channel, async () => {
51
57
  const action = interaction.options.getSubcommand(true);
58
+ if (configured) {
59
+ await interaction.editReply(
60
+ action === "off"
61
+ ? "This server is sandboxed by configuration (`guilds`); change the configuration to turn it off."
62
+ : action === "on"
63
+ ? "Sandbox routing is already on: this server is sandboxed by configuration (`guilds`)."
64
+ : "Sandbox routing is enabled: this server is sandboxed by configuration (`guilds`).",
65
+ );
66
+ return;
67
+ }
52
68
  if (action === "on") channels.enable(channel);
53
69
  else if (action === "off") channels.disable(channel);
54
70
  await interaction.editReply(
@@ -2,7 +2,9 @@ import { realpathSync } from "node:fs";
2
2
  import { isAbsolute } from "node:path";
3
3
  import {
4
4
  boundedText,
5
+ CHANNEL_PATH,
5
6
  isRecord,
7
+ SANDBOX_PROTOCOL_LABEL,
6
8
  type SandboxReply,
7
9
  type SandboxTurn,
8
10
  } from "./protocol.ts";
@@ -12,6 +14,8 @@ export interface ContainerSpec {
12
14
  image: string;
13
15
  runDir: string;
14
16
  workspaceDir: string;
17
+ /** A third, read-write mount at `/channel` holding one channel's own state (`workspace: "server"`). */
18
+ channelDir?: string;
15
19
  uid: number;
16
20
  gid: number;
17
21
  memoryMb?: number;
@@ -28,6 +32,12 @@ export interface ContainerDriver {
28
32
  turn: SandboxTurn,
29
33
  signal: AbortSignal,
30
34
  ): Promise<SandboxReply>;
35
+ /**
36
+ * The worker protocol version the image's `pi-roundtable-sandbox.protocol` label names, or
37
+ * undefined for an image without a readable one (an older worker). Asked at startup when an
38
+ * option needs a newer worker; a driver that cannot answer stops that startup.
39
+ */
40
+ imageProtocol?(image: string): Promise<number | undefined>;
31
41
  }
32
42
 
33
43
  /** Pure argument generation: no Docker daemon is required to test the isolation boundary. */
@@ -39,6 +49,8 @@ export function containerRunArgs(spec: ContainerSpec): string[] {
39
49
  /\s/.test(spec.image)
40
50
  )
41
51
  throw new Error("invalid container name or image");
52
+ const mounts = [spec.runDir, spec.workspaceDir];
53
+ if (spec.channelDir !== undefined) mounts.push(spec.channelDir);
42
54
  if (
43
55
  !Number.isInteger(spec.uid) ||
44
56
  spec.uid <= 0 ||
@@ -46,16 +58,20 @@ export function containerRunArgs(spec: ContainerSpec): string[] {
46
58
  spec.gid <= 0
47
59
  )
48
60
  throw new Error("sandbox requires a non-root user and group");
49
- for (const dir of [spec.runDir, spec.workspaceDir]) {
61
+ for (const dir of mounts) {
50
62
  if (!isAbsolute(dir) || dir === "/" || /[,\n\r]/.test(dir))
51
63
  throw new Error("mount paths must be absolute dedicated directories");
52
64
  }
53
- if (
54
- spec.runDir === spec.workspaceDir ||
55
- spec.runDir.startsWith(`${spec.workspaceDir}/`) ||
56
- spec.workspaceDir.startsWith(`${spec.runDir}/`)
57
- )
58
- throw new Error("broker and workspace mounts must be separate");
65
+ for (const [index, dir] of mounts.entries())
66
+ for (const other of mounts.slice(index + 1))
67
+ if (
68
+ dir === other ||
69
+ dir.startsWith(`${other}/`) ||
70
+ other.startsWith(`${dir}/`)
71
+ )
72
+ throw new Error(
73
+ "broker, workspace and channel mounts must be separate",
74
+ );
59
75
  const memory = spec.memoryMb ?? 512;
60
76
  const cpus = spec.cpus ?? 1;
61
77
  const pids = spec.pids ?? 64;
@@ -113,6 +129,12 @@ export function containerRunArgs(spec: ContainerSpec): string[] {
113
129
  `type=bind,src=${spec.runDir},dst=/broker,readonly,bind-propagation=rprivate`,
114
130
  "--mount",
115
131
  `type=bind,src=${spec.workspaceDir},dst=/workspace,${spec.workspaceReadOnly ? "readonly," : ""}bind-propagation=rprivate`,
132
+ ...(spec.channelDir === undefined
133
+ ? []
134
+ : [
135
+ "--mount",
136
+ `type=bind,src=${spec.channelDir},dst=${CHANNEL_PATH},bind-propagation=rprivate`,
137
+ ]),
116
138
  "--env",
117
139
  "HOME=/tmp/home",
118
140
  ...(command ? ["--entrypoint", command] : []),
@@ -127,6 +149,40 @@ export class DockerContainerDriver implements ContainerDriver {
127
149
  this.#binary = binary;
128
150
  }
129
151
 
152
+ async imageProtocol(image: string): Promise<number | undefined> {
153
+ if (!image || image.startsWith("-") || /\s/.test(image))
154
+ throw new Error("invalid container name or image");
155
+ const child = Bun.spawn(
156
+ [
157
+ this.#binary,
158
+ "image",
159
+ "inspect",
160
+ "--format",
161
+ `{{ index .Config.Labels "${SANDBOX_PROTOCOL_LABEL}" }}`,
162
+ image,
163
+ ],
164
+ { stdout: "pipe", stderr: "pipe" },
165
+ );
166
+ const timer = setTimeout(() => child.kill("SIGKILL"), 30_000);
167
+ try {
168
+ const [output, , code] = await Promise.all([
169
+ boundedText(child.stdout, 4096),
170
+ boundedText(child.stderr, 16 * 1024),
171
+ child.exited,
172
+ ]);
173
+ if (code !== 0)
174
+ throw new Error(
175
+ `cannot inspect sandbox image ${image}; build it before starting`,
176
+ );
177
+ const label = output.trim();
178
+ return /^\d{1,6}$/.test(label) ? Number(label) : undefined;
179
+ } finally {
180
+ clearTimeout(timer);
181
+ child.kill("SIGKILL");
182
+ await child.exited;
183
+ }
184
+ }
185
+
130
186
  async run(
131
187
  spec: ContainerSpec,
132
188
  turn: SandboxTurn,
@@ -161,6 +217,9 @@ export class DockerContainerDriver implements ContainerDriver {
161
217
  ...spec,
162
218
  runDir: realpathSync(spec.runDir),
163
219
  workspaceDir: realpathSync(spec.workspaceDir),
220
+ ...(spec.channelDir === undefined
221
+ ? {}
222
+ : { channelDir: realpathSync(spec.channelDir) }),
164
223
  });
165
224
  signal.throwIfAborted();
166
225
  const child = Bun.spawn([this.#binary, ...args], {
package/src/history.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The conversation history file the sealed worker reads and writes, one per channel: a JSON array
3
+ * of at most 20 `{ role, content }` entries, oldest first. A host that writes history before a
4
+ * channel's first turn, such as an import of old chats, uses this encoder so the worker accepts it.
5
+ */
6
+ export const HISTORY_FILE = "history.json";
7
+ /** The most entries a history file holds; the worker keeps the last 20 after each turn. */
8
+ export const HISTORY_MAX_ENTRIES = 20;
9
+ /** The most bytes one entry's `content` may take, measured as `Buffer.byteLength(JSON.stringify(content))`. */
10
+ export const HISTORY_ENTRY_BYTES = 8192;
11
+
12
+ export interface SandboxHistoryEntry {
13
+ role: "user" | "assistant";
14
+ content: string;
15
+ }
16
+
17
+ /**
18
+ * The `content` of a user entry: the JSON text new turns write, naming who spoke and what they
19
+ * said. The speaker's `id` and `name` are the surface's own, as the worker is told them.
20
+ */
21
+ export function userHistoryContent(
22
+ speaker: { id: string; name: string },
23
+ message: string,
24
+ ): string {
25
+ return JSON.stringify({ speaker, message });
26
+ }
27
+
28
+ /**
29
+ * The text of a history file for `entries`, refusing what the worker would: more than 20
30
+ * entries, a role other than user or assistant, or content over 8,192 bytes. Write it with mode
31
+ * 0600 into the channel's directory (see `historyFilePath`).
32
+ */
33
+ export function encodeHistory(entries: readonly SandboxHistoryEntry[]): string {
34
+ if (entries.length > HISTORY_MAX_ENTRIES)
35
+ throw new Error(`history holds at most ${HISTORY_MAX_ENTRIES} entries`);
36
+ for (const entry of entries) {
37
+ if (
38
+ typeof entry !== "object" ||
39
+ entry === null ||
40
+ (entry.role !== "user" && entry.role !== "assistant") ||
41
+ typeof entry.content !== "string"
42
+ )
43
+ throw new Error("a history entry is { role: user | assistant, content }");
44
+ if (Buffer.byteLength(JSON.stringify(entry.content)) > HISTORY_ENTRY_BYTES)
45
+ throw new Error(
46
+ `a history entry's content is over ${HISTORY_ENTRY_BYTES} bytes`,
47
+ );
48
+ }
49
+ return JSON.stringify(
50
+ entries.map(({ role, content }) => ({ role, content })),
51
+ );
52
+ }
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  type BrokerOptions,
18
18
  type HostTool,
19
19
  type HostToolContext,
20
+ type HostToolScope,
20
21
  type McpServer,
21
22
  SandboxBroker,
22
23
  type SandboxCredentialScope,
@@ -26,6 +27,7 @@ export { SandboxChannelStore } from "./channel-store.ts";
26
27
  export {
27
28
  isSandboxAddress,
28
29
  type SandboxClaimOptions,
30
+ type SandboxReplyText,
29
31
  sandboxClaim,
30
32
  } from "./claim.ts";
31
33
  export { sandboxCommands } from "./commands.ts";
@@ -35,6 +37,22 @@ export {
35
37
  containerRunArgs,
36
38
  DockerContainerDriver,
37
39
  } from "./container-driver.ts";
40
+ export {
41
+ encodeHistory,
42
+ HISTORY_ENTRY_BYTES,
43
+ HISTORY_FILE,
44
+ HISTORY_MAX_ENTRIES,
45
+ type SandboxHistoryEntry,
46
+ userHistoryContent,
47
+ } from "./history.ts";
48
+ export {
49
+ channelStateDir,
50
+ channelWorkspaceDir,
51
+ guildWorkspaceDir,
52
+ historyFilePath,
53
+ workspaceFilesDir,
54
+ } from "./layout.ts";
55
+ export { pageText, textBytes } from "./paging.ts";
38
56
  export {
39
57
  collectPiAttachments,
40
58
  type PiAttachmentOptions,
@@ -108,13 +126,30 @@ export {
108
126
  precheckBroker,
109
127
  precheckScriptRunner,
110
128
  } from "./precheck-runner.ts";
111
- export type { SandboxReply, SandboxTurn, ToolSpec } from "./protocol.ts";
129
+ export {
130
+ BUDGET_DEFAULTS,
131
+ BUDGET_LIMITS,
132
+ FILES_DEFAULTS,
133
+ SANDBOX_PROTOCOL,
134
+ SANDBOX_PROTOCOL_LABEL,
135
+ type SandboxBudgets,
136
+ type SandboxFiles,
137
+ type SandboxReply,
138
+ type SandboxTurn,
139
+ type ToolSpec,
140
+ toolResultTextBytes,
141
+ } from "./protocol.ts";
112
142
  export {
113
143
  type ResearchTools,
114
144
  type SandboxResearchOptions,
115
145
  SandboxResearchWorker,
116
146
  } from "./research-worker.ts";
117
- export { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
147
+ export {
148
+ SandboxRuntime,
149
+ type SandboxRuntimeOptions,
150
+ type SandboxTurnContext,
151
+ SandboxTurnRefused,
152
+ } from "./runtime.ts";
118
153
  export {
119
154
  assertPublicUrl,
120
155
  isPublicAddress,
package/src/layout.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import type { ChannelKey } from "pi-roundtable";
4
+ import { HISTORY_FILE } from "./history.ts";
5
+ import { FILES_DIR } from "./protocol.ts";
6
+
7
+ const digest = (text: string): string =>
8
+ createHash("sha256").update(text).digest("hex");
9
+
10
+ /** Directory name of a channel's own workspace (`workspace: "channel"`, and a message with no guild). */
11
+ export function channelWorkspaceName(channel: ChannelKey): string {
12
+ return digest(channel);
13
+ }
14
+ /** Directory name of a guild's shared workspace (`workspace: "server"`). */
15
+ export function guildWorkspaceName(surface: string, guild: string): string {
16
+ return `guild-${digest(`guild\0${surface}:${guild}`)}`;
17
+ }
18
+ /** Directory name of a channel's own state directory, mounted at `/channel` in `"server"` mode. */
19
+ export function channelStateName(channel: ChannelKey): string {
20
+ return `state-${digest(`state\0${channel}`)}`;
21
+ }
22
+
23
+ /** A channel's own workspace under `workspaceRoot`; memory, files and history live in it. */
24
+ export function channelWorkspaceDir(
25
+ workspaceRoot: string,
26
+ channel: ChannelKey,
27
+ ): string {
28
+ return join(workspaceRoot, channelWorkspaceName(channel));
29
+ }
30
+ /**
31
+ * The workspace every sandbox channel of one guild shares in `workspace: "server"` mode, for the
32
+ * guild `guild` of `surface` (`"discord"`). It holds memory notes and workspace files.
33
+ */
34
+ export function guildWorkspaceDir(
35
+ workspaceRoot: string,
36
+ surface: string,
37
+ guild: string,
38
+ ): string {
39
+ return join(workspaceRoot, guildWorkspaceName(surface, guild));
40
+ }
41
+ /**
42
+ * One channel's state directory in `workspace: "server"` mode; its conversation history
43
+ * (`historyFilePath`) lives there, and a turn sees only its own channel's.
44
+ */
45
+ export function channelStateDir(
46
+ workspaceRoot: string,
47
+ channel: ChannelKey,
48
+ ): string {
49
+ return join(workspaceRoot, channelStateName(channel));
50
+ }
51
+ /** The history file inside a channel's state directory (or its workspace in `"channel"` mode). */
52
+ export function historyFilePath(directory: string): string {
53
+ return join(directory, HISTORY_FILE);
54
+ }
55
+ /** Where the file tools keep a workspace's files; an import writes its files here. */
56
+ export function workspaceFilesDir(workspaceDir: string): string {
57
+ return join(workspaceDir, FILES_DIR);
58
+ }
package/src/paging.ts ADDED
@@ -0,0 +1,57 @@
1
+ /** `Buffer.byteLength(JSON.stringify(text))`: how the sealed worker measures text against a budget. */
2
+ export function textBytes(text: string): number {
3
+ return Buffer.byteLength(JSON.stringify(text));
4
+ }
5
+
6
+ const isHigh = (code: number): boolean => code >= 0xd800 && code <= 0xdbff;
7
+ const isLow = (code: number): boolean => code >= 0xdc00 && code <= 0xdfff;
8
+
9
+ /**
10
+ * One page of `text` from character `offset` (a JavaScript string index), as much as fits
11
+ * `budget` under `measure` (the worker's `textBytes` by default; a host tool whose result the
12
+ * broker wraps passes `toolResultTextBytes` and its `resultBytes`). When more text follows, the
13
+ * page begins with the line `[More: next offset N]` so a cut never removes it; pass N as the
14
+ * next call's offset. Throws on an offset that is not a whole position in the text.
15
+ */
16
+ export function pageText(
17
+ text: string,
18
+ offset: number,
19
+ budget: number,
20
+ measure: (text: string) => number = textBytes,
21
+ ): string {
22
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length)
23
+ throw new RangeError(
24
+ `offset must be a whole number from 0 to ${text.length}`,
25
+ );
26
+ if (
27
+ offset > 0 &&
28
+ offset < text.length &&
29
+ isHigh(text.charCodeAt(offset - 1)) &&
30
+ isLow(text.charCodeAt(offset))
31
+ )
32
+ throw new RangeError("offset falls inside a character");
33
+ if (offset === text.length)
34
+ return `(No text at offset ${offset}: that is the end of the text.)`;
35
+ const rest = text.slice(offset);
36
+ if (measure(rest) <= budget) return rest;
37
+ const header = (end: number): string => `[More: next offset ${end}]\n`;
38
+ const fits = (end: number): boolean =>
39
+ measure(header(end) + text.slice(offset, end)) <= budget;
40
+ let low = offset;
41
+ let high = text.length - 1;
42
+ while (low < high) {
43
+ const middle = Math.ceil((low + high) / 2);
44
+ if (fits(middle)) low = middle;
45
+ else high = middle - 1;
46
+ }
47
+ // Never end between the halves of a surrogate pair.
48
+ if (
49
+ low > offset &&
50
+ isHigh(text.charCodeAt(low - 1)) &&
51
+ isLow(text.charCodeAt(low))
52
+ )
53
+ low--;
54
+ if (low === offset)
55
+ throw new Error("the result budget is too small for one character");
56
+ return header(low) + text.slice(offset, low);
57
+ }
package/src/plugin.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { type ChannelKey, definePlugin, serviceKey } from "pi-roundtable";
4
4
  import { DISCORD } from "pi-roundtable/discord";
5
5
  import { SandboxChannelStore } from "./channel-store.ts";
6
- import { sandboxClaim } from "./claim.ts";
6
+ import { type SandboxReplyText, sandboxClaim } from "./claim.ts";
7
7
  import { sandboxCommands } from "./commands.ts";
8
8
  import { SandboxRuntime, type SandboxRuntimeOptions } from "./runtime.ts";
9
9
 
@@ -13,10 +13,22 @@ export interface SandboxOptions
13
13
  stateFile: string;
14
14
  /** Used only when stateFile does not yet exist. */
15
15
  initialChannels?: readonly ChannelKey[];
16
+ /**
17
+ * Discord guild ids whose every channel and thread is a sandbox channel, with no
18
+ * `/roundtable sandbox on`; ids are validated at startup. A configured guild cannot be turned
19
+ * off by command. Direct messages and other guilds are not affected.
20
+ */
21
+ guilds?: readonly string[];
16
22
  /** Host login provider used for each model call, unless apiKey is supplied. */
17
23
  provider?: string;
18
24
  /** Read before each model call with its channel and speaker; never falls back to the host login. */
19
25
  apiKey?: SandboxRuntimeOptions["apiKey"];
26
+ /**
27
+ * Rewrites each successful model reply on the host before it is posted, for example to
28
+ * convert Simplified to Traditional Chinese. Failure texts are not passed to it; a throw
29
+ * fails the turn.
30
+ */
31
+ replyText?: SandboxReplyText;
20
32
  }
21
33
  export interface SandboxService {
22
34
  channels: SandboxChannelStore;
@@ -28,7 +40,7 @@ export function sandbox(options: SandboxOptions) {
28
40
  return definePlugin({
29
41
  name: "sandbox",
30
42
  provides: [SANDBOX],
31
- setup: (context) => {
43
+ setup: async (context) => {
32
44
  const runtime = new SandboxRuntime({
33
45
  ...options,
34
46
  apiKey:
@@ -53,7 +65,10 @@ export function sandbox(options: SandboxOptions) {
53
65
  const channels = new SandboxChannelStore(
54
66
  stateFile,
55
67
  options.initialChannels,
68
+ { guilds: options.guilds },
56
69
  );
70
+ // Last of the cheap checks: a typo above fails fast, without a `docker inspect`.
71
+ await runtime.verifyImage();
57
72
  context.services.provide(SANDBOX, { channels, runtime });
58
73
  const discord = context.services.find(DISCORD);
59
74
  if (discord)
@@ -66,10 +81,11 @@ export function sandbox(options: SandboxOptions) {
66
81
  channels,
67
82
  runtime,
68
83
  surfaces: context.surfaces,
69
- reportFailure: (channel) =>
84
+ ...(options.replyText ? { replyText: options.replyText } : {}),
85
+ reportFailure: (channel, error) =>
70
86
  context.logger.error(
71
- { channel },
72
- "sandbox turn failed; inspect container cleanup and broker configuration",
87
+ { channel, err: error },
88
+ "sandbox turn failed; inspect the error, then container cleanup, broker configuration and replyText",
73
89
  ),
74
90
  }),
75
91
  ],
package/src/protocol.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  export const DUMMY_KEY = "sandbox-dummy-key";
2
2
  export const BROKER_PATH = "/broker/broker.sock";
3
3
  export const WORKSPACE_PATH = "/workspace";
4
+ /** Where a channel's own state directory is mounted in `workspace: "server"` mode. */
5
+ export const CHANNEL_PATH = "/channel";
6
+ /** The image label naming the worker protocol version an image speaks. */
7
+ export const SANDBOX_PROTOCOL_LABEL = "pi-roundtable-sandbox.protocol";
8
+ /**
9
+ * The worker protocol this package speaks: 2 adds `historyDir`, `budgets` and the file and skill
10
+ * tools. An image labelled lower, or unlabelled, is an older worker.
11
+ */
12
+ export const SANDBOX_PROTOCOL = 2;
4
13
 
5
14
  export interface ToolSpec {
6
15
  name: string;
@@ -8,6 +17,125 @@ export interface ToolSpec {
8
17
  parameters: Record<string, unknown>;
9
18
  }
10
19
 
20
+ /**
21
+ * Byte budgets the sealed worker enforces, each measured as
22
+ * `Buffer.byteLength(JSON.stringify(text))`. An absent key means the worker's default.
23
+ */
24
+ export interface SandboxBudgets {
25
+ /** The system prompt, including a skill index. */
26
+ promptBytes?: number;
27
+ /** One tool result, as the worker measures it after encoding. */
28
+ toolResultBytes?: number;
29
+ /** One model request; the oldest history and tool-call groups are dropped to fit. */
30
+ requestBytes?: number;
31
+ }
32
+ export const BUDGET_DEFAULTS = {
33
+ promptBytes: 8192,
34
+ toolResultBytes: 4096,
35
+ requestBytes: 196_608,
36
+ } as const satisfies Required<SandboxBudgets>;
37
+ export const BUDGET_LIMITS = {
38
+ promptBytes: { min: 256, max: 32_768 },
39
+ toolResultBytes: { min: 256, max: 32_768 },
40
+ requestBytes: { min: 4096, max: 196_608 },
41
+ } as const;
42
+
43
+ /** The most the worker keeps of the current message (JSON-encoded bytes). */
44
+ export const CURRENT_MESSAGE_MAX_BYTES = 48 * 1024;
45
+ /** The speaker memory and the channel notes in the system line, 4 KiB each. */
46
+ export const MEMORY_ROOM_BYTES = 8192;
47
+ /** The most tool calls one model reply may make. */
48
+ export const MAX_TOOL_CALLS = 8;
49
+ /** The least room per tool result a request must leave when a reply makes the most calls. */
50
+ export const MIN_TOOL_RESULT_ROOM_BYTES = 1024;
51
+
52
+ /**
53
+ * Why `requestBytes` cannot hold one turn's fixed parts plus a minimum of room for every
54
+ * result of a full reply of tool calls, or undefined when it can. Sizes are the worker's measure.
55
+ */
56
+ export function requestBytesError(
57
+ budgets: SandboxBudgets | undefined,
58
+ ): string | undefined {
59
+ const promptBytes = budgets?.promptBytes ?? BUDGET_DEFAULTS.promptBytes;
60
+ const requestBytes = budgets?.requestBytes ?? BUDGET_DEFAULTS.requestBytes;
61
+ const toolRoom = MAX_TOOL_CALLS * MIN_TOOL_RESULT_ROOM_BYTES;
62
+ const needed =
63
+ promptBytes + MEMORY_ROOM_BYTES + CURRENT_MESSAGE_MAX_BYTES + toolRoom;
64
+ if (requestBytes >= needed) return undefined;
65
+ return `budgets.requestBytes ${requestBytes} cannot hold budgets.promptBytes ${promptBytes} + ${MEMORY_ROOM_BYTES} bytes of memory notes + ${CURRENT_MESSAGE_MAX_BYTES} bytes for the current message + ${toolRoom} bytes for ${MAX_TOOL_CALLS} tool results of ${MIN_TOOL_RESULT_ROOM_BYTES} bytes (${needed} in all); raise requestBytes or lower promptBytes`;
66
+ }
67
+
68
+ /** Why `value` is not a valid budgets object, or undefined when it is one. */
69
+ export function budgetsError(value: unknown): string | undefined {
70
+ if (!isRecord(value)) return "budgets must be an object";
71
+ for (const name of Object.keys(value)) {
72
+ if (!(name in BUDGET_LIMITS)) return `unknown budget ${name}`;
73
+ }
74
+ for (const name of Object.keys(BUDGET_LIMITS) as (keyof SandboxBudgets)[]) {
75
+ const entry = value[name];
76
+ if (entry === undefined) continue;
77
+ const { min, max } = BUDGET_LIMITS[name];
78
+ if (
79
+ !Number.isSafeInteger(entry) ||
80
+ (entry as number) < min ||
81
+ (entry as number) > max
82
+ )
83
+ return `budgets.${name} must be an integer from ${min} to ${max}`;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /**
89
+ * What the broker's `{"text":…}` wrapper costs a host tool's result in the worker's measure:
90
+ * the wrapper is encoded twice, once into the broker's reply and once into the tool message.
91
+ */
92
+ export const TOOL_RESULT_WRAPPER_BYTES = 17;
93
+
94
+ /**
95
+ * The size of a host tool's result `text` in the worker's measure after the wrapper, to compare
96
+ * with `HostToolContext.resultBytes`: the bytes of its text encoded twice, so a newline or a
97
+ * quote costs four.
98
+ */
99
+ export function toolResultTextBytes(text: string): number {
100
+ return Buffer.byteLength(JSON.stringify(JSON.stringify(text))) - 2;
101
+ }
102
+
103
+ /** The workspace file tools' limits, as the turn carries them. */
104
+ export interface SandboxFiles {
105
+ /** The most UTF-8 bytes one file may hold. */
106
+ maxFileBytes: number;
107
+ /** The most bytes everything under `files/` may hold. */
108
+ maxTotalBytes: number;
109
+ }
110
+ export const FILES_DIR = "files";
111
+ export const FILES_DEFAULTS = {
112
+ maxFileBytes: 256 * 1024,
113
+ maxTotalBytes: 32 * 1024 * 1024,
114
+ } as const satisfies SandboxFiles;
115
+ const FILES_FILE_CEILING = 16 * 1024 * 1024;
116
+ const FILES_TOTAL_CEILING = 100 * 1024 ** 3;
117
+
118
+ /** Why `value` is not a valid files limit pair, or undefined when it is one. */
119
+ export function filesError(value: unknown): string | undefined {
120
+ if (!isRecord(value)) return "files must be an object of limits";
121
+ const { maxFileBytes, maxTotalBytes } = value;
122
+ if (
123
+ !Number.isSafeInteger(maxFileBytes) ||
124
+ (maxFileBytes as number) < 1 ||
125
+ (maxFileBytes as number) > FILES_FILE_CEILING
126
+ )
127
+ return `files.maxFileBytes must be an integer from 1 to ${FILES_FILE_CEILING}`;
128
+ if (
129
+ !Number.isSafeInteger(maxTotalBytes) ||
130
+ (maxTotalBytes as number) < 1 ||
131
+ (maxTotalBytes as number) > FILES_TOTAL_CEILING
132
+ )
133
+ return `files.maxTotalBytes must be an integer from 1 to ${FILES_TOTAL_CEILING}`;
134
+ if ((maxFileBytes as number) > (maxTotalBytes as number))
135
+ return "files.maxFileBytes must not exceed files.maxTotalBytes";
136
+ return undefined;
137
+ }
138
+
11
139
  export interface SandboxTurn {
12
140
  text: string;
13
141
  speaker: { id: string; name: string };
@@ -16,6 +144,12 @@ export interface SandboxTurn {
16
144
  timeZone: string;
17
145
  tools: ToolSpec[];
18
146
  reset?: boolean;
147
+ /** Absent unless the operator set a budget; the worker then keeps its defaults. */
148
+ budgets?: SandboxBudgets;
149
+ /** Present when the file tools are on; absent means the worker offers none. */
150
+ files?: SandboxFiles;
151
+ /** Where the history file lives; absent means in the workspace, as before. */
152
+ historyDir?: typeof CHANNEL_PATH;
19
153
  mcp: { server: string; tools: ToolSpec[] }[];
20
154
  }
21
155
 
@@ -49,6 +183,10 @@ export function isSandboxTurn(value: unknown): value is SandboxTurn {
49
183
  typeof value.prompt === "string" &&
50
184
  typeof value.timeZone === "string" &&
51
185
  (value.reset === undefined || typeof value.reset === "boolean") &&
186
+ (value.budgets === undefined ||
187
+ budgetsError(value.budgets) === undefined) &&
188
+ (value.historyDir === undefined || value.historyDir === CHANNEL_PATH) &&
189
+ (value.files === undefined || filesError(value.files) === undefined) &&
52
190
  Array.isArray(value.tools) &&
53
191
  value.tools.every(isToolSpec) &&
54
192
  Array.isArray(value.mcp) &&