pi-roundtable-sandbox 0.7.11 → 0.7.12

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.12
4
+
5
+ - A host compactor gets at most half the time the turn has left (the runtime passes the broker its `deadline`, `PiHostContext.deadline`), so Pi's own summary still fits after it runs out; with under two seconds left the broker falls back to Pi's summary at once.
6
+ - The broker logs the first `error` event of a server-sent event stream that started with a 200 (`sandbox upstream call failed`, with the event's data, credentials removed), which the status alone never showed; the worker still receives the whole stream.
7
+
3
8
  ## 0.7.11
4
9
 
5
10
  - `precheckScriptRunner` runs agents' precheck scripts (pi-roundtable 0.7.11) in a sealed container per run: no network, a read-only root, no capabilities, a non-root user, an empty workspace, and a per-run broker (`precheckBroker`) that forwards only single `tools/call` requests of the tools `grant(scope)` allows, with the host's credential (`PrecheckMcpServer`). The container runs `worker/precheck-main.ts` (`PRECHECK_ENTRYPOINT`, `PrecheckWorkerInput`), which gives the script `mcp.call`, `mcp.json`, `firedAt`, `timeZone`, `today`, and `schedule`.
package/README.md CHANGED
@@ -262,7 +262,7 @@ new PiSandboxRuntime({
262
262
 
263
263
  `PiCompactRequest` carries Pi's preparation: `reason`, `tokensBefore`, `firstKeptEntryId`, `isSplitTurn`, `messagesToSummarize`, `turnPrefixMessages`, `keptMessages` (the messages from `firstKeptEntryId` on), `previousSummary?`, `customInstructions?`, `readFiles` and `modifiedFiles`.
264
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.
265
+ A compactor that returns `undefined`, throws, answers out of shape, outlasts `timeoutMs` or half the time the turn has left, whichever is shorter (its `signal` aborts), or gets a request over `maxRequestBytes` falls back to Pi's summary, and the host logs the reason.
266
266
  The timeout may be at most half of `turnTimeoutMs`, so Pi's summary keeps time to run.
267
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
268
  Without `compaction` the worker registers no compaction handler; the tiers and Pi's summary still apply.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-sandbox",
3
- "version": "0.7.11",
3
+ "version": "0.7.12",
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.11",
55
+ "pi-roundtable": "0.7.12",
56
56
  "typebox": "1.3.34",
57
57
  "typescript": "7.0.2"
58
58
  }
package/src/pi-broker.ts CHANGED
@@ -27,6 +27,8 @@ export interface PiHostContext {
27
27
  /** The host-judged level for this turn; the worker can request less but never more. */
28
28
  thinking: PiThinkingLevel;
29
29
  signal: AbortSignal;
30
+ /** When the turn must end, in epoch milliseconds; a host compactor gets at most half the time left. */
31
+ deadline?: number;
30
32
  }
31
33
  export interface PiMcpServer {
32
34
  name: string;
@@ -127,12 +129,55 @@ function endpoint(raw: string, allowHttp = false): URL {
127
129
  throw new Error("Invalid trusted endpoint");
128
130
  return url;
129
131
  }
132
+ /**
133
+ * Watches a server-sent event stream as it passes and reports its first `error` event: an
134
+ * upstream can fail mid-answer after a 200, which the status alone never shows.
135
+ */
136
+ function sseErrorWatcher(
137
+ onError: (data: string) => void,
138
+ ): (chunk: Uint8Array, done: boolean) => void {
139
+ const decoder = new TextDecoder();
140
+ let pending = "";
141
+ let reported = false;
142
+ const scan = (event: string) => {
143
+ const lines = event.split(/\r?\n/);
144
+ const type = lines
145
+ .find((line) => line.startsWith("event:"))
146
+ ?.slice(6)
147
+ .trim();
148
+ const data = lines
149
+ .filter((line) => line.startsWith("data:"))
150
+ .map((line) => line.slice(5).replace(/^ /, ""))
151
+ .join("\n");
152
+ let typed: unknown;
153
+ try {
154
+ typed = JSON.parse(data);
155
+ } catch {
156
+ typed = undefined;
157
+ }
158
+ if (type === "error" || (isRecord(typed) && typed.type === "error")) {
159
+ reported = true;
160
+ onError(data);
161
+ }
162
+ };
163
+ return (chunk, done) => {
164
+ if (reported) return;
165
+ pending += decoder.decode(chunk, { stream: !done });
166
+ const events = pending.split(/\r?\n\r?\n/);
167
+ pending = done ? "" : (events.pop() ?? "");
168
+ // An event this long is no error report; keep scanning without holding it.
169
+ if (pending.length > 64 * 1024) pending = "";
170
+ for (const event of events) if (!reported) scan(event);
171
+ };
172
+ }
173
+
130
174
  function streamBounded(
131
175
  response: Response,
132
176
  signal: AbortSignal,
133
177
  maxBytes: number,
134
178
  secret: string,
135
179
  onFailure: (error: unknown) => void,
180
+ watch?: (chunk: Uint8Array, done: boolean) => void,
136
181
  ): ReadableStream<Uint8Array> {
137
182
  const reader = response.body?.getReader();
138
183
  let total = 0;
@@ -157,6 +202,7 @@ function streamBounded(
157
202
  throw new Error("Credential reflected");
158
203
  total += result?.value?.byteLength ?? 0;
159
204
  if (total > maxBytes) throw new Error("Response too large");
205
+ watch?.(result?.value ?? new Uint8Array(), !result || result.done);
160
206
  if (!result || result.done) {
161
207
  if (data.length) controller.enqueue(data);
162
208
  controller.close();
@@ -632,6 +678,17 @@ export class PiSandboxBroker {
632
678
  PI_MEDIA_LIMITS.totalFileBytes,
633
679
  secret,
634
680
  (error) => failed(secret, { status: upstream.status, error }),
681
+ upstream.status < 400 &&
682
+ (upstream.headers.get("content-type") ?? "").includes(
683
+ "text/event-stream",
684
+ )
685
+ ? sseErrorWatcher((body) =>
686
+ failed(secret, {
687
+ status: upstream.status,
688
+ body: `stream error event: ${body}`,
689
+ }),
690
+ )
691
+ : undefined,
635
692
  ),
636
693
  { headers: outgoing, status: upstream.status },
637
694
  );
@@ -725,11 +782,22 @@ export class PiSandboxBroker {
725
782
  compactor.maxRequestBytes ?? PI_COMPACT_LIMITS.requestBytes;
726
783
  if (Number(request.headers.get("content-length") ?? 0) > maxBytes)
727
784
  return fallback(`the request is over ${maxBytes} bytes`);
728
- let running: Promise<unknown> = Promise.resolve();
729
- this.#compacting = running;
730
- const timeout = AbortSignal.timeout(
785
+ // Pi's own summary must still fit after a compactor that runs out of time.
786
+ const left =
787
+ turn.context.deadline === undefined
788
+ ? Number.POSITIVE_INFINITY
789
+ : turn.context.deadline - Date.now();
790
+ const timeoutMs = Math.min(
731
791
  compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs,
792
+ Math.floor(left / 2),
732
793
  );
794
+ if (timeoutMs < 1000)
795
+ return fallback(
796
+ "the turn has too little time left for the host compactor",
797
+ );
798
+ let running: Promise<unknown> = Promise.resolve();
799
+ this.#compacting = running;
800
+ const timeout = AbortSignal.timeout(timeoutMs);
733
801
  const signal = AbortSignal.any([
734
802
  turn.context.signal,
735
803
  request.signal,
@@ -797,7 +865,7 @@ export class PiSandboxBroker {
797
865
  } catch (error) {
798
866
  return fallback(
799
867
  timeout.aborted
800
- ? `the compactor took over ${compactor.timeoutMs ?? PI_COMPACT_LIMITS.timeoutMs} ms`
868
+ ? `the compactor took over ${timeoutMs} ms`
801
869
  : signal.aborted
802
870
  ? "the compaction was aborted"
803
871
  : `the compactor failed: ${scrubDiagnostic(errorText(error), 2000)}`,
package/src/pi-runtime.ts CHANGED
@@ -351,10 +351,12 @@ export class PiSandboxRuntime {
351
351
  return { ok: false, error: new AgentRunError("Channel is busy") };
352
352
  const controller = new AbortController();
353
353
  this.#active.set(turn.channel, controller);
354
+ const turnTimeoutMs = this.#options.turnTimeoutMs ?? 600_000;
355
+ const deadline = Date.now() + turnTimeoutMs;
354
356
  const signal = AbortSignal.any([
355
357
  controller.signal,
356
358
  ...(turn.signal ? [turn.signal] : []),
357
- AbortSignal.timeout(this.#options.turnTimeoutMs ?? 600_000),
359
+ AbortSignal.timeout(turnTimeoutMs),
358
360
  ]);
359
361
  signal.addEventListener("abort", () => controller.abort(), { once: true });
360
362
  const timedOut = () =>
@@ -408,6 +410,7 @@ export class PiSandboxRuntime {
408
410
  speaker: turn.author,
409
411
  thinking,
410
412
  signal,
413
+ deadline,
411
414
  });
412
415
  const body = await entry.broker.execute(request, signal);
413
416
  signal.throwIfAborted();