pi-roundtable-sandbox 0.7.8 → 0.7.10
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 +6 -0
- package/README.md +37 -2
- package/package.json +2 -2
- package/src/index.ts +13 -0
- package/src/pi-broker.ts +353 -6
- package/src/pi-container-driver.ts +74 -3
- package/src/pi-protocol.ts +118 -0
- package/src/pi-runtime.ts +67 -11
- package/worker/pi-compaction.ts +225 -0
- package/worker/pi-main.ts +96 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.10
|
|
4
|
+
|
|
5
|
+
- Compact Pi worker sessions with the core's tiers (300,000 tokens through a host compactor, Pi's summary past 500,000), through the new `compaction` option (`PiCompactor`: `engine`, `compact(request, { channel, signal })`, `timeoutMs`, `maxRequestBytes`) and the protocol types `PiCompactRequest`, `PiCompaction`, `PiCompactResponse`, `PiCompactionReport`, `PiCompactMessage` and `PiWorkerConfig`; the host logs every compaction and fallback per channel.
|
|
6
|
+
- A failed `runTurn` keeps its cause (`AgentRunError` message and `cause`, whether it timed out) and logs it; the broker logs upstream failures with channel, status, latency and the error body; a timed-out worker's last 200 log lines are logged before its container is removed (`PiContainerDriver.logs`).
|
|
7
|
+
- Behavior change: Pi containers log to journald tagged `sandbox/<channel>` by default, instead of two 10 MiB `local` files, so the Docker daemon must run with journald (pass `PiDockerContainerDriver` a `log` option for another driver) (`PiContainerSpec.log`, `PiDockerContainerDriver` option `log`, `defaultContainerLog`), and the worker logs model, broker, tool and compaction failures.
|
|
8
|
+
|
|
3
9
|
## 0.7.5
|
|
4
10
|
|
|
5
11
|
- `ScopedSandboxDelegator` limits a title to 200 characters by default again, and accepts `maxTitleChars`, `maxReportChars` and `diagnosticChars`.
|
package/README.md
CHANGED
|
@@ -226,7 +226,8 @@ Other model transports are not implicitly proxied by this broker.
|
|
|
226
226
|
| `timeZone` | Explicit worker time zone; default UTC. |
|
|
227
227
|
| `containerPrefix`, `labelChannel`, `labelProfile` | Operator compatibility names for existing containers, not guest data. |
|
|
228
228
|
| `driver` | Trusted local Docker driver or offline fixture; no remote bind mounts. |
|
|
229
|
-
| `logger` | Host logger
|
|
229
|
+
| `logger` | Host logger: failed turns with their cause, upstream failures (channel, status, latency, error body cut to 2,000 characters with credentials masked), a timed-out worker's last 200 log lines, and every compaction. |
|
|
230
|
+
| `compaction` | Optional host compactor (`PiCompactor`), see [Compaction](#compaction). |
|
|
230
231
|
| `startTimeoutMs`, `turnTimeoutMs` | 1–600 seconds; defaults 90 and 600 seconds. |
|
|
231
232
|
| `maxCalls`, `maxOutputTokens` | Default 128 credential-bearing calls and 128,000 output tokens per model call; configurable 1–1,000 calls and 1,025–200,000 tokens. |
|
|
232
233
|
|
|
@@ -238,6 +239,37 @@ Callbacks must still validate input and enforce per-person quotas, memory author
|
|
|
238
239
|
Optional person/notes/moments tools can use an existing PostgreSQL store without copying the connection, schema, migration ledger or owner memory into the image.
|
|
239
240
|
Schedules can be host tools with a declared channel-local background target and host-bound author; no scheduler is enabled automatically.
|
|
240
241
|
|
|
242
|
+
### Compaction
|
|
243
|
+
|
|
244
|
+
Worker sessions compact with the core's tiers, as the host's own sessions do: a model whose window leaves more than 300,000 tokens compacts at 300,000 through the host compactor, and past 500,000 through Pi's own summary, whose model calls go through the broker like any other.
|
|
245
|
+
A compaction that left the context within 50,000 tokens of its threshold moves the next one to the hard ceiling, so it does not repeat on the next request.
|
|
246
|
+
|
|
247
|
+
The host compactor runs on the host, where the worker cannot reach (a remote compaction service, say):
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
new PiSandboxRuntime({
|
|
251
|
+
// ...
|
|
252
|
+
compaction: {
|
|
253
|
+
engine: "my-compaction", // recorded as details.engine of its compactions
|
|
254
|
+
compact: async (request, { channel, signal }) => {
|
|
255
|
+
// request: PiCompactRequest; return a PiCompaction, or undefined for Pi's summary
|
|
256
|
+
},
|
|
257
|
+
timeoutMs: 120_000, // optional; default the smaller of 120 s and a third of turnTimeoutMs
|
|
258
|
+
maxRequestBytes: 32 * 1024 * 1024, // optional; default 32 MiB
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`PiCompactRequest` carries Pi's preparation: `reason`, `tokensBefore`, `firstKeptEntryId`, `isSplitTurn`, `messagesToSummarize`, `turnPrefixMessages`, `keptMessages` (the messages from `firstKeptEntryId` on), `previousSummary?`, `customInstructions?`, `readFiles` and `modifiedFiles`.
|
|
264
|
+
A `PiCompaction` is Pi's `CompactionResult`: `summary`, `firstKeptEntryId` (the request's), `tokensBefore`, `estimatedTokensAfter?` and `details?` (an object; the broker sets its `engine`).
|
|
265
|
+
A compactor that returns `undefined`, throws, answers out of shape, outlasts `timeoutMs` (its `signal` aborts), or gets a request over `maxRequestBytes` falls back to Pi's summary, and the host logs the reason.
|
|
266
|
+
The timeout may be at most half of `turnTimeoutMs`, so Pi's summary keeps time to run.
|
|
267
|
+
A turn may ask the host for three compactions and send sixteen compaction reports; later compactions fall back to Pi's summary, and while a compactor that ignored its signal still runs, a new request falls back too.
|
|
268
|
+
Without `compaction` the worker registers no compaction handler; the tiers and Pi's summary still apply.
|
|
269
|
+
Compactions are written to the session file in the channel workspace, so they survive container removal and restarts.
|
|
270
|
+
|
|
271
|
+
The host logs, per channel: `conversation compacted` (`trigger`, `engine` `extension` or `pi`, `tokensBefore`, `tokensAfter`, `nextCompactionAt`), `compaction failed`, `compaction skips the extension for Pi's summary` past the ceiling, and `compaction falls back to Pi's summary` with its `fallback` reason.
|
|
272
|
+
|
|
241
273
|
### Worker content and files
|
|
242
274
|
|
|
243
275
|
The trusted image exports `workerContent(profile): PiWorkerContent` from `/app/worker/content.ts`.
|
|
@@ -268,7 +300,10 @@ Never include real auth files or host source in those content directories.
|
|
|
268
300
|
### Pi isolation and continuity
|
|
269
301
|
|
|
270
302
|
Pi containers remain network-none, non-root, read-only-root, capability-free and `no-new-privileges`.
|
|
271
|
-
They have 1.5 GiB RAM with no additional swap, one CPU, 256 PIDs, a 512 MiB temporary filesystem
|
|
303
|
+
They have 1.5 GiB RAM with no additional swap, one CPU, 256 PIDs, and a 512 MiB temporary filesystem.
|
|
304
|
+
Their logs go to journald tagged `sandbox/<channel>`, so they outlive the container; the Docker daemon must then run under systemd with journald, or containers fail to start.
|
|
305
|
+
journald bounds the lines through its own rate limit and `SystemMaxUse`, not per container; `PiDockerContainerDriver`'s third argument, `{ log: { driver, options } }` (or a function of the channel), names another Docker log driver.
|
|
306
|
+
The worker logs model errors, broker call failures, tool failures and compactions to stdout as JSON lines.
|
|
272
307
|
Only the channel workspace (writable) and host-created broker directory (read-only) are mounted.
|
|
273
308
|
Worker-initiated `/worker/ready`, `/worker/next` and `/worker/result` requests transport turns and byte-backed replies; the host never connects to a guest-created socket or reads guest reply paths.
|
|
274
309
|
Only one idle long poll, one admitted turn and one incoming reply packet per channel are allowed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-roundtable-sandbox",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.10",
|
|
4
4
|
"description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@biomejs/biome": "2.5.15",
|
|
53
53
|
"@types/bun": "1.4.2",
|
|
54
54
|
"discord.js": "14.27.0",
|
|
55
|
-
"pi-roundtable": "0.7.
|
|
55
|
+
"pi-roundtable": "0.7.10",
|
|
56
56
|
"typebox": "1.3.34",
|
|
57
57
|
"typescript": "7.0.2"
|
|
58
58
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,13 +38,18 @@ export {
|
|
|
38
38
|
type PiAttachmentOptions,
|
|
39
39
|
} from "./pi-attachments.ts";
|
|
40
40
|
export {
|
|
41
|
+
PI_COMPACT_LIMITS,
|
|
41
42
|
type PiBrokerOptions,
|
|
43
|
+
type PiCompactor,
|
|
42
44
|
type PiHostContext,
|
|
43
45
|
type PiMcpServer,
|
|
44
46
|
PiSandboxBroker,
|
|
45
47
|
} from "./pi-broker.ts";
|
|
46
48
|
export {
|
|
49
|
+
defaultContainerLog,
|
|
50
|
+
demuxDockerLog,
|
|
47
51
|
type PiContainerDriver,
|
|
52
|
+
type PiContainerLog,
|
|
48
53
|
type PiContainerSpec,
|
|
49
54
|
type PiContainerStatus,
|
|
50
55
|
PiDockerContainerDriver,
|
|
@@ -60,6 +65,11 @@ export {
|
|
|
60
65
|
PI_RUN_DIR,
|
|
61
66
|
PI_THINKING_LEVELS,
|
|
62
67
|
PI_WORKSPACE,
|
|
68
|
+
type PiCompaction,
|
|
69
|
+
type PiCompactionReport,
|
|
70
|
+
type PiCompactMessage,
|
|
71
|
+
type PiCompactRequest,
|
|
72
|
+
type PiCompactResponse,
|
|
63
73
|
type PiMcpDiscovery,
|
|
64
74
|
type PiReplyFile,
|
|
65
75
|
type PiThinkingLevel,
|
|
@@ -67,7 +77,10 @@ export {
|
|
|
67
77
|
type PiTurnContext,
|
|
68
78
|
type PiTurnRequest,
|
|
69
79
|
type PiTurnResponse,
|
|
80
|
+
type PiWorkerConfig,
|
|
70
81
|
safeFileName,
|
|
82
|
+
validateCompactionReport,
|
|
83
|
+
validateCompactRequest,
|
|
71
84
|
validateImages,
|
|
72
85
|
validateReplyFiles,
|
|
73
86
|
} from "./pi-protocol.ts";
|
package/src/pi-broker.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
import type { ChannelKey } from "pi-roundtable";
|
|
1
|
+
import type { ChannelKey, Logger } from "pi-roundtable";
|
|
2
|
+
import { HARD_COMPACT_TOKENS, scrubDiagnostic } from "pi-roundtable/kit";
|
|
2
3
|
import { type BrokerListener, listenBroker } from "./broker.ts";
|
|
3
4
|
import { piModelInput } from "./pi-model-input.ts";
|
|
4
5
|
import {
|
|
5
6
|
PI_MEDIA_LIMITS,
|
|
7
|
+
type PiCompaction,
|
|
8
|
+
type PiCompactionReport,
|
|
9
|
+
type PiCompactRequest,
|
|
10
|
+
type PiCompactResponse,
|
|
6
11
|
type PiThinkingLevel,
|
|
7
12
|
type PiToolResponse,
|
|
8
13
|
type PiTurnRequest,
|
|
9
14
|
type PiTurnResponse,
|
|
15
|
+
type PiWorkerConfig,
|
|
16
|
+
validateCompactionReport,
|
|
17
|
+
validateCompactRequest,
|
|
10
18
|
validateImages,
|
|
11
19
|
validateReplyFiles,
|
|
12
20
|
} from "./pi-protocol.ts";
|
|
@@ -25,6 +33,31 @@ export interface PiMcpServer {
|
|
|
25
33
|
url: string;
|
|
26
34
|
tools: readonly string[];
|
|
27
35
|
}
|
|
36
|
+
/** Defaults for a host compactor's limits. */
|
|
37
|
+
export const PI_COMPACT_LIMITS = {
|
|
38
|
+
/** A larger compact request falls back to Pi's summary unread. */
|
|
39
|
+
requestBytes: 32 * 1024 * 1024,
|
|
40
|
+
/** A compactor still running after this falls back to Pi's summary. */
|
|
41
|
+
timeoutMs: 120_000,
|
|
42
|
+
/** Compact requests one turn may make; the rest fall back to Pi's summary. */
|
|
43
|
+
compactsPerTurn: 3,
|
|
44
|
+
/** Compaction reports one turn may send. */
|
|
45
|
+
reportsPerTurn: 16,
|
|
46
|
+
} as const;
|
|
47
|
+
/** Compacts a sandbox session on the host, with what the worker cannot reach (a remote service, say). */
|
|
48
|
+
export interface PiCompactor {
|
|
49
|
+
/** The `engine` this compactor's compactions record in their details; the broker stamps it. */
|
|
50
|
+
engine: string;
|
|
51
|
+
/** A compaction, or undefined to leave it to Pi's own summary. A throw also falls back. */
|
|
52
|
+
compact(
|
|
53
|
+
request: PiCompactRequest,
|
|
54
|
+
context: { channel: ChannelKey; signal: AbortSignal },
|
|
55
|
+
): Promise<PiCompaction | undefined>;
|
|
56
|
+
/** Longest a compaction may take before Pi's summary runs instead (default 120 seconds). */
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
/** Largest request accepted, in bytes (default 32 MiB); a larger one falls back to Pi's summary. */
|
|
59
|
+
maxRequestBytes?: number;
|
|
60
|
+
}
|
|
28
61
|
export interface PiBrokerOptions {
|
|
29
62
|
model: string;
|
|
30
63
|
/** Host-only, read at each call. Never sent to the worker. */
|
|
@@ -47,6 +80,10 @@ export interface PiBrokerOptions {
|
|
|
47
80
|
maxOutputTokens?: number;
|
|
48
81
|
/** Trusted endpoints can use HTTP only for explicitly configured local MCP. */
|
|
49
82
|
allowHttpMcp?: boolean;
|
|
83
|
+
/** Host-side compaction; without it the worker compacts with Pi's summary alone. */
|
|
84
|
+
compaction?: PiCompactor;
|
|
85
|
+
/** Receives upstream failures and the worker's compactions, per channel. */
|
|
86
|
+
logger?: Logger;
|
|
50
87
|
}
|
|
51
88
|
const MODEL_PATHS = new Set([
|
|
52
89
|
"/anthropic/v1/messages",
|
|
@@ -69,6 +106,14 @@ function passthroughHeader(name: string): boolean {
|
|
|
69
106
|
RESPONSE_HEADERS.includes(name)
|
|
70
107
|
);
|
|
71
108
|
}
|
|
109
|
+
/** An error's name, message and cause chain, for a log line. */
|
|
110
|
+
export function errorText(error: unknown): string {
|
|
111
|
+
if (!(error instanceof Error)) return String(error);
|
|
112
|
+
const text = `${error.name}: ${error.message}`;
|
|
113
|
+
return error.cause === undefined
|
|
114
|
+
? text
|
|
115
|
+
: `${text} (cause: ${errorText(error.cause)})`;
|
|
116
|
+
}
|
|
72
117
|
function endpoint(raw: string, allowHttp = false): URL {
|
|
73
118
|
// pi-lens-ignore: unchecked-throwing-call -- invalid trusted endpoint configuration must fail startup.
|
|
74
119
|
const url = new URL(raw);
|
|
@@ -87,6 +132,7 @@ function streamBounded(
|
|
|
87
132
|
signal: AbortSignal,
|
|
88
133
|
maxBytes: number,
|
|
89
134
|
secret: string,
|
|
135
|
+
onFailure: (error: unknown) => void,
|
|
90
136
|
): ReadableStream<Uint8Array> {
|
|
91
137
|
const reader = response.body?.getReader();
|
|
92
138
|
let total = 0;
|
|
@@ -119,7 +165,8 @@ function streamBounded(
|
|
|
119
165
|
const cut = Math.max(0, data.length - tail);
|
|
120
166
|
if (cut) controller.enqueue(data.subarray(0, cut));
|
|
121
167
|
carry = data.subarray(cut);
|
|
122
|
-
} catch {
|
|
168
|
+
} catch (error) {
|
|
169
|
+
onFailure(error);
|
|
123
170
|
await reader?.cancel().catch(() => {});
|
|
124
171
|
controller.error(new Error("Broker response failed"));
|
|
125
172
|
}
|
|
@@ -128,6 +175,57 @@ function streamBounded(
|
|
|
128
175
|
});
|
|
129
176
|
}
|
|
130
177
|
|
|
178
|
+
/** At most `limit` bytes of a body as text; the rest is cancelled, never read. */
|
|
179
|
+
async function bodyHead(
|
|
180
|
+
body: ReadableStream<Uint8Array>,
|
|
181
|
+
limit: number,
|
|
182
|
+
): Promise<string> {
|
|
183
|
+
const reader = body.getReader();
|
|
184
|
+
const chunks: Uint8Array[] = [];
|
|
185
|
+
let size = 0;
|
|
186
|
+
try {
|
|
187
|
+
while (size < limit) {
|
|
188
|
+
const { done, value } = await reader.read();
|
|
189
|
+
if (done) break;
|
|
190
|
+
chunks.push(value);
|
|
191
|
+
size += value.byteLength;
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
// The worker's branch reports a broken stream; the log keeps what arrived.
|
|
195
|
+
} finally {
|
|
196
|
+
await reader.cancel().catch(() => {});
|
|
197
|
+
}
|
|
198
|
+
return Buffer.concat(chunks).subarray(0, limit).toString("utf8");
|
|
199
|
+
}
|
|
200
|
+
/** A credential as it may appear in text: raw, URL-encoded, base64 and hex. */
|
|
201
|
+
function secretForms(secret: string): string[] {
|
|
202
|
+
if (!secret) return [];
|
|
203
|
+
return [
|
|
204
|
+
secret,
|
|
205
|
+
encodeURIComponent(secret),
|
|
206
|
+
Buffer.from(secret).toString("base64"),
|
|
207
|
+
Buffer.from(secret).toString("base64url"),
|
|
208
|
+
Buffer.from(secret).toString("hex"),
|
|
209
|
+
];
|
|
210
|
+
}
|
|
211
|
+
/** A compactor's answer, or a rejection once the signal aborts, even if the compactor ignores it. */
|
|
212
|
+
function abortableCompact<T>(
|
|
213
|
+
promise: Promise<T>,
|
|
214
|
+
signal: AbortSignal,
|
|
215
|
+
): Promise<T> {
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
const abort = () => reject(signal.reason);
|
|
218
|
+
if (signal.aborted) {
|
|
219
|
+
abort();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
223
|
+
promise
|
|
224
|
+
.then(resolve, reject)
|
|
225
|
+
.finally(() => signal.removeEventListener("abort", abort));
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
131
229
|
/** Explicit subscription mode. A broker is idle except while the host binds one admitted turn. */
|
|
132
230
|
export class PiSandboxBroker {
|
|
133
231
|
readonly #options: PiBrokerOptions;
|
|
@@ -181,6 +279,12 @@ export class PiSandboxBroker {
|
|
|
181
279
|
this.#rearmStartup();
|
|
182
280
|
return new Response("Ready");
|
|
183
281
|
}
|
|
282
|
+
if (path === "/worker/config" && request.method === "GET") {
|
|
283
|
+
const compaction = this.#options.compaction;
|
|
284
|
+
return Response.json({
|
|
285
|
+
...(compaction ? { compaction: { engine: compaction.engine } } : {}),
|
|
286
|
+
} satisfies PiWorkerConfig);
|
|
287
|
+
}
|
|
184
288
|
if (path === "/worker/next" && request.method === "GET") {
|
|
185
289
|
if (this.#next) return new Response("Already waiting", { status: 429 });
|
|
186
290
|
if (this.#pending && !this.#pending.delivered) {
|
|
@@ -306,6 +410,23 @@ export class PiSandboxBroker {
|
|
|
306
410
|
(options.maxCalls ?? 128) > 1000
|
|
307
411
|
)
|
|
308
412
|
throw new Error("Invalid broker budget");
|
|
413
|
+
const compaction = options.compaction;
|
|
414
|
+
if (
|
|
415
|
+
compaction &&
|
|
416
|
+
(!/^[a-zA-Z0-9@/._-]{1,100}$/.test(compaction.engine) ||
|
|
417
|
+
!Number.isSafeInteger(
|
|
418
|
+
compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
|
|
419
|
+
) ||
|
|
420
|
+
(compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs) < 1000 ||
|
|
421
|
+
(compaction.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs) > 600_000 ||
|
|
422
|
+
!Number.isSafeInteger(
|
|
423
|
+
compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes,
|
|
424
|
+
) ||
|
|
425
|
+
(compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes) < 1 ||
|
|
426
|
+
(compaction.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes) >
|
|
427
|
+
96 * 1024 * 1024)
|
|
428
|
+
)
|
|
429
|
+
throw new Error("Invalid compactor");
|
|
309
430
|
if (
|
|
310
431
|
!Number.isSafeInteger(options.maxOutputTokens ?? 128_000) ||
|
|
311
432
|
(options.maxOutputTokens ?? 128_000) < 1025 ||
|
|
@@ -354,6 +475,12 @@ export class PiSandboxBroker {
|
|
|
354
475
|
})),
|
|
355
476
|
});
|
|
356
477
|
}
|
|
478
|
+
if (
|
|
479
|
+
(url.pathname === "/compaction/compact" ||
|
|
480
|
+
url.pathname === "/compaction/report") &&
|
|
481
|
+
!url.search
|
|
482
|
+
)
|
|
483
|
+
return this.#compaction(request, url.pathname);
|
|
357
484
|
const startup = !this.#turn;
|
|
358
485
|
const turn = this.#turn ?? this.#startup;
|
|
359
486
|
if (!turn || turn.context.signal.aborted)
|
|
@@ -378,6 +505,11 @@ export class PiSandboxBroker {
|
|
|
378
505
|
turn.active++;
|
|
379
506
|
// The turn signal carries the deadline: a long generation or image call must not be cut shorter.
|
|
380
507
|
const signal = AbortSignal.any([turn.context.signal, request.signal]);
|
|
508
|
+
const failed = this.#failureLog(
|
|
509
|
+
turn.context.channel,
|
|
510
|
+
toolName ? `tool:${toolName}` : server ? `mcp:${server.name}` : "model",
|
|
511
|
+
);
|
|
512
|
+
let secret = "";
|
|
381
513
|
try {
|
|
382
514
|
const input: unknown = JSON.parse(
|
|
383
515
|
await boundedText(request.body, 96 * 1024 * 1024),
|
|
@@ -405,7 +537,6 @@ export class PiSandboxBroker {
|
|
|
405
537
|
return Response.json(result);
|
|
406
538
|
}
|
|
407
539
|
let target: string;
|
|
408
|
-
let secret: string;
|
|
409
540
|
let body: Record<string, unknown>;
|
|
410
541
|
const headers = new Headers({
|
|
411
542
|
"content-type": "application/json",
|
|
@@ -478,23 +609,239 @@ export class PiSandboxBroker {
|
|
|
478
609
|
if (id && !id.includes(secret) && id.length <= 256)
|
|
479
610
|
outgoing.set("mcp-session-id", id);
|
|
480
611
|
}
|
|
481
|
-
if (upstream.status < 200 || upstream.status > 599)
|
|
612
|
+
if (upstream.status < 200 || upstream.status > 599) {
|
|
613
|
+
failed(secret, { status: upstream.status });
|
|
482
614
|
return new Response("Upstream refused", { status: 502 });
|
|
615
|
+
}
|
|
616
|
+
let answer = upstream;
|
|
617
|
+
if (upstream.status >= 400 && upstream.body) {
|
|
618
|
+
// The log reads the error body's head from one branch; the worker gets all of it from the other.
|
|
619
|
+
const [logged, sent] = upstream.body.tee();
|
|
620
|
+
void bodyHead(logged, 64 * 1024).then((body) =>
|
|
621
|
+
failed(secret, { status: upstream.status, body }),
|
|
622
|
+
);
|
|
623
|
+
answer = new Response(sent, { status: upstream.status });
|
|
624
|
+
} else if (upstream.status >= 400)
|
|
625
|
+
failed(secret, { status: upstream.status });
|
|
483
626
|
return new Response(
|
|
484
627
|
upstream.status === 204 || upstream.status === 205
|
|
485
628
|
? null
|
|
486
629
|
: streamBounded(
|
|
487
|
-
|
|
630
|
+
answer,
|
|
488
631
|
signal,
|
|
489
632
|
PI_MEDIA_LIMITS.totalFileBytes,
|
|
490
633
|
secret,
|
|
634
|
+
(error) => failed(secret, { status: upstream.status, error }),
|
|
491
635
|
),
|
|
492
636
|
{ headers: outgoing, status: upstream.status },
|
|
493
637
|
);
|
|
494
|
-
} catch {
|
|
638
|
+
} catch (error) {
|
|
639
|
+
failed(secret, { error });
|
|
495
640
|
return new Response("Broker call failed", { status: 502 });
|
|
496
641
|
} finally {
|
|
497
642
|
turn.active--;
|
|
498
643
|
}
|
|
499
644
|
}
|
|
645
|
+
/** Logs a failed upstream call with its latency; the body and error lose any credential. */
|
|
646
|
+
#failureLog(channel: ChannelKey, upstream: string) {
|
|
647
|
+
const started = Date.now();
|
|
648
|
+
return (
|
|
649
|
+
secret: string,
|
|
650
|
+
failure: { status?: number; body?: string; error?: unknown },
|
|
651
|
+
): void => {
|
|
652
|
+
const clean = (text: string) =>
|
|
653
|
+
scrubDiagnostic(
|
|
654
|
+
secretForms(secret).reduce(
|
|
655
|
+
(masked, form) => masked.replaceAll(form, "[redacted]"),
|
|
656
|
+
text,
|
|
657
|
+
),
|
|
658
|
+
2000,
|
|
659
|
+
);
|
|
660
|
+
this.#options.logger?.warn(
|
|
661
|
+
{
|
|
662
|
+
channel,
|
|
663
|
+
upstream,
|
|
664
|
+
...(failure.status === undefined ? {} : { status: failure.status }),
|
|
665
|
+
latencyMs: Date.now() - started,
|
|
666
|
+
...(failure.body === undefined ? {} : { body: clean(failure.body) }),
|
|
667
|
+
...(failure.error === undefined
|
|
668
|
+
? {}
|
|
669
|
+
: { error: clean(errorText(failure.error)) }),
|
|
670
|
+
},
|
|
671
|
+
"sandbox upstream call failed",
|
|
672
|
+
);
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
/** Settles when the running compactor really ends, even after its request fell back. */
|
|
676
|
+
#compacting: Promise<unknown> | undefined;
|
|
677
|
+
readonly #compactionUse = new WeakMap<
|
|
678
|
+
object,
|
|
679
|
+
{ compacts: number; reports: number }
|
|
680
|
+
>();
|
|
681
|
+
/** The worker's compactions, only inside an admitted turn: a host compaction, or a report to log. */
|
|
682
|
+
async #compaction(request: Request, path: string): Promise<Response> {
|
|
683
|
+
const turn = this.#turn;
|
|
684
|
+
if (request.method !== "POST")
|
|
685
|
+
return new Response("Not found", { status: 404 });
|
|
686
|
+
if (!turn || turn.context.signal.aborted)
|
|
687
|
+
return new Response("Turn ended", { status: 410 });
|
|
688
|
+
const { channel } = turn.context;
|
|
689
|
+
const logger = this.#options.logger;
|
|
690
|
+
const use = this.#compactionUse.get(turn) ?? { compacts: 0, reports: 0 };
|
|
691
|
+
this.#compactionUse.set(turn, use);
|
|
692
|
+
if (path === "/compaction/report") {
|
|
693
|
+
// A turn compacts a few times at most; more reports only fill the host's log.
|
|
694
|
+
if (++use.reports > PI_COMPACT_LIMITS.reportsPerTurn)
|
|
695
|
+
return new Response("Report budget exhausted", { status: 429 });
|
|
696
|
+
try {
|
|
697
|
+
const report: unknown = JSON.parse(
|
|
698
|
+
await boundedText(request.body, 64 * 1024, turn.context.signal),
|
|
699
|
+
);
|
|
700
|
+
validateCompactionReport(report);
|
|
701
|
+
this.#logReport(channel, report);
|
|
702
|
+
return new Response("Accepted");
|
|
703
|
+
} catch {
|
|
704
|
+
return new Response("Invalid report", { status: 400 });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
const compactor = this.#options.compaction;
|
|
708
|
+
const fallback = (reason: string): Response => {
|
|
709
|
+
logger?.warn(
|
|
710
|
+
{ channel, engine: compactor?.engine, fallback: reason },
|
|
711
|
+
"compaction falls back to Pi's summary",
|
|
712
|
+
);
|
|
713
|
+
return Response.json({
|
|
714
|
+
ok: false,
|
|
715
|
+
fallback: reason,
|
|
716
|
+
} satisfies PiCompactResponse);
|
|
717
|
+
};
|
|
718
|
+
if (!compactor) return fallback("the host has no compactor");
|
|
719
|
+
if (this.#compacting) return fallback("a compaction is already running");
|
|
720
|
+
if (++use.compacts > PI_COMPACT_LIMITS.compactsPerTurn)
|
|
721
|
+
return fallback(
|
|
722
|
+
`the turn already asked for ${PI_COMPACT_LIMITS.compactsPerTurn} compactions`,
|
|
723
|
+
);
|
|
724
|
+
const maxBytes =
|
|
725
|
+
compactor.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes;
|
|
726
|
+
if (Number(request.headers.get("content-length") ?? 0) > maxBytes)
|
|
727
|
+
return fallback(`the request is over ${maxBytes} bytes`);
|
|
728
|
+
let running: Promise<unknown> = Promise.resolve();
|
|
729
|
+
this.#compacting = running;
|
|
730
|
+
const timeout = AbortSignal.timeout(
|
|
731
|
+
compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
|
|
732
|
+
);
|
|
733
|
+
const signal = AbortSignal.any([
|
|
734
|
+
turn.context.signal,
|
|
735
|
+
request.signal,
|
|
736
|
+
timeout,
|
|
737
|
+
]);
|
|
738
|
+
const started = Date.now();
|
|
739
|
+
try {
|
|
740
|
+
let compactRequest: unknown;
|
|
741
|
+
try {
|
|
742
|
+
compactRequest = JSON.parse(
|
|
743
|
+
await boundedText(request.body, maxBytes, signal),
|
|
744
|
+
);
|
|
745
|
+
validateCompactRequest(compactRequest);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (signal.aborted) throw error;
|
|
748
|
+
return fallback(
|
|
749
|
+
error instanceof Error && error.message === "body too large"
|
|
750
|
+
? `the request is over ${maxBytes} bytes`
|
|
751
|
+
: "the request is invalid",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
const compacting = compactor.compact(compactRequest, { channel, signal });
|
|
755
|
+
running = compacting.catch(() => {});
|
|
756
|
+
this.#compacting = running;
|
|
757
|
+
const result = await abortableCompact(compacting, signal);
|
|
758
|
+
if (!result) return fallback("the compactor declined");
|
|
759
|
+
const details =
|
|
760
|
+
result.details === undefined
|
|
761
|
+
? { engine: compactor.engine }
|
|
762
|
+
: isRecord(result.details)
|
|
763
|
+
? { ...result.details, engine: compactor.engine }
|
|
764
|
+
: undefined;
|
|
765
|
+
if (
|
|
766
|
+
!details ||
|
|
767
|
+
typeof result.summary !== "string" ||
|
|
768
|
+
result.summary.length === 0 ||
|
|
769
|
+
result.firstKeptEntryId !== compactRequest.firstKeptEntryId ||
|
|
770
|
+
!Number.isSafeInteger(result.tokensBefore) ||
|
|
771
|
+
(result.estimatedTokensAfter !== undefined &&
|
|
772
|
+
!Number.isSafeInteger(result.estimatedTokensAfter))
|
|
773
|
+
)
|
|
774
|
+
return fallback("the compactor's result is invalid");
|
|
775
|
+
logger?.info(
|
|
776
|
+
{
|
|
777
|
+
channel,
|
|
778
|
+
engine: compactor.engine,
|
|
779
|
+
tokensBefore: result.tokensBefore,
|
|
780
|
+
tokensAfter: result.estimatedTokensAfter,
|
|
781
|
+
latencyMs: Date.now() - started,
|
|
782
|
+
},
|
|
783
|
+
"host compactor answered",
|
|
784
|
+
);
|
|
785
|
+
return Response.json({
|
|
786
|
+
ok: true,
|
|
787
|
+
compaction: {
|
|
788
|
+
summary: result.summary,
|
|
789
|
+
firstKeptEntryId: result.firstKeptEntryId,
|
|
790
|
+
tokensBefore: result.tokensBefore,
|
|
791
|
+
...(result.estimatedTokensAfter === undefined
|
|
792
|
+
? {}
|
|
793
|
+
: { estimatedTokensAfter: result.estimatedTokensAfter }),
|
|
794
|
+
details,
|
|
795
|
+
},
|
|
796
|
+
} satisfies PiCompactResponse);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
return fallback(
|
|
799
|
+
timeout.aborted
|
|
800
|
+
? `the compactor took over ${compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs} ms`
|
|
801
|
+
: signal.aborted
|
|
802
|
+
? "the compaction was aborted"
|
|
803
|
+
: `the compactor failed: ${scrubDiagnostic(errorText(error), 2000)}`,
|
|
804
|
+
);
|
|
805
|
+
} finally {
|
|
806
|
+
// A compactor that ignored its signal still holds the slot until it really ends.
|
|
807
|
+
void running.then(() => {
|
|
808
|
+
if (this.#compacting === running) this.#compacting = undefined;
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Logs a worker compaction the way the host logs its own sessions' compactions. */
|
|
813
|
+
#logReport(channel: ChannelKey, report: PiCompactionReport): void {
|
|
814
|
+
const logger = this.#options.logger;
|
|
815
|
+
if (report.type === "bypass") {
|
|
816
|
+
logger?.info(
|
|
817
|
+
{
|
|
818
|
+
channel,
|
|
819
|
+
reason: report.reason,
|
|
820
|
+
tokensBefore: report.tokensBefore,
|
|
821
|
+
ceiling: HARD_COMPACT_TOKENS,
|
|
822
|
+
},
|
|
823
|
+
"compaction skips the extension for Pi's summary",
|
|
824
|
+
);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const trigger = report.reason === "manual" ? "self" : report.reason;
|
|
828
|
+
if (!report.engine) {
|
|
829
|
+
logger?.warn(
|
|
830
|
+
{ channel, trigger, aborted: report.aborted, error: report.error },
|
|
831
|
+
"compaction failed",
|
|
832
|
+
);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
logger?.info(
|
|
836
|
+
{
|
|
837
|
+
channel,
|
|
838
|
+
trigger,
|
|
839
|
+
engine: report.engine,
|
|
840
|
+
tokensBefore: report.tokensBefore,
|
|
841
|
+
tokensAfter: report.tokensAfter,
|
|
842
|
+
nextCompactionAt: report.nextCompactionAt,
|
|
843
|
+
},
|
|
844
|
+
"conversation compacted",
|
|
845
|
+
);
|
|
846
|
+
}
|
|
500
847
|
}
|