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 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; package failures avoid credentials and upstream payloads. |
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, and bounded Docker logs (two 10 MiB local log files).
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.8",
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.8",
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
- upstream,
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
  }