pi-roundtable-sandbox 0.23.1 → 0.24.0
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/CHANGELOG.md +27 -1
- package/README.md +157 -10
- package/examples/roundtable.config.ts +32 -0
- package/package.json +3 -3
- package/src/broker.ts +37 -6
- package/src/channel-store.ts +51 -1
- package/src/claim.ts +22 -8
- package/src/commands.ts +17 -1
- package/src/container-driver.ts +66 -7
- package/src/history.ts +52 -0
- package/src/index.ts +36 -2
- package/src/layout.ts +58 -0
- package/src/paging.ts +57 -0
- package/src/plugin.ts +10 -1
- package/src/protocol.ts +138 -0
- package/src/runtime-options.ts +185 -1
- package/src/runtime.ts +126 -29
- package/src/sandbox-turn.ts +61 -9
- package/src/skills.ts +185 -0
- package/worker/Dockerfile +3 -2
- package/worker/agent.ts +91 -14
- package/worker/files.ts +293 -0
- package/worker/memory.ts +48 -39
package/src/container-driver.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
@@ -35,6 +36,22 @@ export {
|
|
|
35
36
|
containerRunArgs,
|
|
36
37
|
DockerContainerDriver,
|
|
37
38
|
} from "./container-driver.ts";
|
|
39
|
+
export {
|
|
40
|
+
encodeHistory,
|
|
41
|
+
HISTORY_ENTRY_BYTES,
|
|
42
|
+
HISTORY_FILE,
|
|
43
|
+
HISTORY_MAX_ENTRIES,
|
|
44
|
+
type SandboxHistoryEntry,
|
|
45
|
+
userHistoryContent,
|
|
46
|
+
} from "./history.ts";
|
|
47
|
+
export {
|
|
48
|
+
channelStateDir,
|
|
49
|
+
channelWorkspaceDir,
|
|
50
|
+
guildWorkspaceDir,
|
|
51
|
+
historyFilePath,
|
|
52
|
+
workspaceFilesDir,
|
|
53
|
+
} from "./layout.ts";
|
|
54
|
+
export { pageText, textBytes } from "./paging.ts";
|
|
38
55
|
export {
|
|
39
56
|
collectPiAttachments,
|
|
40
57
|
type PiAttachmentOptions,
|
|
@@ -108,13 +125,30 @@ export {
|
|
|
108
125
|
precheckBroker,
|
|
109
126
|
precheckScriptRunner,
|
|
110
127
|
} from "./precheck-runner.ts";
|
|
111
|
-
export
|
|
128
|
+
export {
|
|
129
|
+
BUDGET_DEFAULTS,
|
|
130
|
+
BUDGET_LIMITS,
|
|
131
|
+
FILES_DEFAULTS,
|
|
132
|
+
SANDBOX_PROTOCOL,
|
|
133
|
+
SANDBOX_PROTOCOL_LABEL,
|
|
134
|
+
type SandboxBudgets,
|
|
135
|
+
type SandboxFiles,
|
|
136
|
+
type SandboxReply,
|
|
137
|
+
type SandboxTurn,
|
|
138
|
+
type ToolSpec,
|
|
139
|
+
toolResultTextBytes,
|
|
140
|
+
} from "./protocol.ts";
|
|
112
141
|
export {
|
|
113
142
|
type ResearchTools,
|
|
114
143
|
type SandboxResearchOptions,
|
|
115
144
|
SandboxResearchWorker,
|
|
116
145
|
} from "./research-worker.ts";
|
|
117
|
-
export {
|
|
146
|
+
export {
|
|
147
|
+
SandboxRuntime,
|
|
148
|
+
type SandboxRuntimeOptions,
|
|
149
|
+
type SandboxTurnContext,
|
|
150
|
+
SandboxTurnRefused,
|
|
151
|
+
} from "./runtime.ts";
|
|
118
152
|
export {
|
|
119
153
|
assertPublicUrl,
|
|
120
154
|
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
|
@@ -13,6 +13,12 @@ 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. */
|
|
@@ -28,7 +34,7 @@ export function sandbox(options: SandboxOptions) {
|
|
|
28
34
|
return definePlugin({
|
|
29
35
|
name: "sandbox",
|
|
30
36
|
provides: [SANDBOX],
|
|
31
|
-
setup: (context) => {
|
|
37
|
+
setup: async (context) => {
|
|
32
38
|
const runtime = new SandboxRuntime({
|
|
33
39
|
...options,
|
|
34
40
|
apiKey:
|
|
@@ -53,7 +59,10 @@ export function sandbox(options: SandboxOptions) {
|
|
|
53
59
|
const channels = new SandboxChannelStore(
|
|
54
60
|
stateFile,
|
|
55
61
|
options.initialChannels,
|
|
62
|
+
{ guilds: options.guilds },
|
|
56
63
|
);
|
|
64
|
+
// Last of the cheap checks: a typo above fails fast, without a `docker inspect`.
|
|
65
|
+
await runtime.verifyImage();
|
|
57
66
|
context.services.provide(SANDBOX, { channels, runtime });
|
|
58
67
|
const discord = context.services.find(DISCORD);
|
|
59
68
|
if (discord)
|
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) &&
|