pi-roundtable-sandbox 0.7.9 → 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 -10
- package/worker/pi-compaction.ts +225 -0
- package/worker/pi-main.ts +96 -13
|
@@ -14,6 +14,14 @@ export interface PiContainerSpec {
|
|
|
14
14
|
/** The host user that owns the mounted directories. */
|
|
15
15
|
uid: number;
|
|
16
16
|
gid: number;
|
|
17
|
+
/** Docker's log driver for the container; journald tagged `sandbox/<channel>` by default. */
|
|
18
|
+
log?: PiContainerLog;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A Docker log driver and its options, as `docker run --log-driver/--log-opt` takes them. */
|
|
22
|
+
export interface PiContainerLog {
|
|
23
|
+
driver: string;
|
|
24
|
+
options?: Readonly<Record<string, string>>;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export type PiContainerState = "missing" | "running" | "stopped";
|
|
@@ -30,6 +38,16 @@ export interface PiContainerDriver {
|
|
|
30
38
|
ensureRunning(spec: PiContainerSpec): Promise<void>;
|
|
31
39
|
remove(name: string): Promise<void>;
|
|
32
40
|
status(name: string): Promise<PiContainerStatus>;
|
|
41
|
+
/** The container's last log lines, read before it is removed; empty when it is gone. */
|
|
42
|
+
logs?(name: string, lines: number): Promise<string>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** journald keeps a removed container's lines; the tag names the channel they came from. */
|
|
46
|
+
export function defaultContainerLog(channel: string): PiContainerLog {
|
|
47
|
+
return {
|
|
48
|
+
driver: "journald",
|
|
49
|
+
options: { tag: `sandbox/${channel.replace(/[^\w.:/-]/g, "_")}` },
|
|
50
|
+
};
|
|
33
51
|
}
|
|
34
52
|
|
|
35
53
|
export const PI_LABEL_CHANNEL = "roundtable.sandbox.channel";
|
|
@@ -59,6 +77,17 @@ export function piContainerCreateBody(spec: PiContainerSpec): object {
|
|
|
59
77
|
if (!dir.startsWith("/") || dir === "/" || /[:,\r\n]/.test(dir))
|
|
60
78
|
throw new Error("Invalid mount path");
|
|
61
79
|
new Intl.DateTimeFormat("en-US", { timeZone: spec.timeZone ?? "UTC" });
|
|
80
|
+
const log = spec.log ?? defaultContainerLog(spec.channel);
|
|
81
|
+
if (
|
|
82
|
+
!/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(log.driver) ||
|
|
83
|
+
Object.entries(log.options ?? {}).some(
|
|
84
|
+
([key, value]) =>
|
|
85
|
+
!/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(key) ||
|
|
86
|
+
typeof value !== "string" ||
|
|
87
|
+
/[\r\n]/.test(value),
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
throw new Error("Invalid container log driver");
|
|
62
91
|
return {
|
|
63
92
|
Image: spec.image,
|
|
64
93
|
User: `${spec.uid}:${spec.gid}`,
|
|
@@ -96,13 +125,34 @@ export function piContainerCreateBody(spec: PiContainerSpec): object {
|
|
|
96
125
|
],
|
|
97
126
|
RestartPolicy: { Name: "unless-stopped" },
|
|
98
127
|
LogConfig: {
|
|
99
|
-
Type:
|
|
100
|
-
Config: {
|
|
128
|
+
Type: log.driver,
|
|
129
|
+
Config: { ...log.options },
|
|
101
130
|
},
|
|
102
131
|
},
|
|
103
132
|
};
|
|
104
133
|
}
|
|
105
134
|
|
|
135
|
+
/**
|
|
136
|
+
* A container without a TTY sends its log as frames: one byte naming the stream, three zero bytes,
|
|
137
|
+
* a big-endian length, then that many bytes. Text that is not framed is returned as it came.
|
|
138
|
+
*/
|
|
139
|
+
export function demuxDockerLog(data: Uint8Array): string {
|
|
140
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
141
|
+
const parts: Uint8Array[] = [];
|
|
142
|
+
let at = 0;
|
|
143
|
+
while (at + 8 <= data.length) {
|
|
144
|
+
const stream = data[at] ?? 255;
|
|
145
|
+
if (stream > 2 || data[at + 1] || data[at + 2] || data[at + 3])
|
|
146
|
+
return new TextDecoder().decode(data);
|
|
147
|
+
const size = view.getUint32(at + 4);
|
|
148
|
+
parts.push(data.subarray(at + 8, at + 8 + size));
|
|
149
|
+
at += 8 + size;
|
|
150
|
+
}
|
|
151
|
+
if (at !== data.length && parts.length === 0)
|
|
152
|
+
return new TextDecoder().decode(data);
|
|
153
|
+
return parts.map((part) => new TextDecoder().decode(part)).join("");
|
|
154
|
+
}
|
|
155
|
+
|
|
106
156
|
interface InspectBody {
|
|
107
157
|
Config?: { Image?: string; Labels?: Record<string, string> };
|
|
108
158
|
State?: { Running?: boolean };
|
|
@@ -112,11 +162,19 @@ interface InspectBody {
|
|
|
112
162
|
export class PiDockerContainerDriver implements PiContainerDriver {
|
|
113
163
|
readonly #socket: string;
|
|
114
164
|
|
|
165
|
+
readonly #log: ((channel: string) => PiContainerLog) | undefined;
|
|
166
|
+
|
|
115
167
|
constructor(
|
|
116
168
|
socket = "/var/run/docker.sock",
|
|
117
169
|
readonly labelProfile = PI_LABEL_PROFILE,
|
|
170
|
+
/** The log driver every container gets; journald tagged `sandbox/<channel>` by default. */
|
|
171
|
+
options: {
|
|
172
|
+
log?: PiContainerLog | ((channel: string) => PiContainerLog);
|
|
173
|
+
} = {},
|
|
118
174
|
) {
|
|
119
175
|
this.#socket = socket;
|
|
176
|
+
const { log } = options;
|
|
177
|
+
this.#log = typeof log === "function" ? log : log && (() => log);
|
|
120
178
|
}
|
|
121
179
|
|
|
122
180
|
async ensureRunning(spec: PiContainerSpec): Promise<void> {
|
|
@@ -132,7 +190,9 @@ export class PiDockerContainerDriver implements PiContainerDriver {
|
|
|
132
190
|
await this.#call(
|
|
133
191
|
"POST",
|
|
134
192
|
`/containers/create?name=${encodeURIComponent(spec.name)}`,
|
|
135
|
-
piContainerCreateBody(
|
|
193
|
+
piContainerCreateBody(
|
|
194
|
+
this.#log ? { ...spec, log: this.#log(spec.channel) } : spec,
|
|
195
|
+
),
|
|
136
196
|
);
|
|
137
197
|
await this.#call(
|
|
138
198
|
"POST",
|
|
@@ -149,6 +209,17 @@ export class PiDockerContainerDriver implements PiContainerDriver {
|
|
|
149
209
|
);
|
|
150
210
|
}
|
|
151
211
|
|
|
212
|
+
async logs(name: string, lines: number): Promise<string> {
|
|
213
|
+
const response = await this.#call(
|
|
214
|
+
"GET",
|
|
215
|
+
`/containers/${encodeURIComponent(name)}/logs?stdout=1&stderr=1&tail=${Math.max(1, Math.floor(lines))}`,
|
|
216
|
+
undefined,
|
|
217
|
+
[404],
|
|
218
|
+
);
|
|
219
|
+
if (response.status === 404) return "";
|
|
220
|
+
return demuxDockerLog(new Uint8Array(await response.arrayBuffer()));
|
|
221
|
+
}
|
|
222
|
+
|
|
152
223
|
async status(name: string): Promise<PiContainerStatus> {
|
|
153
224
|
const response = await this.#call(
|
|
154
225
|
"GET",
|
package/src/pi-protocol.ts
CHANGED
|
@@ -78,6 +78,124 @@ export interface PiTurnContext {
|
|
|
78
78
|
export interface PiMcpDiscovery {
|
|
79
79
|
servers: { name: string; tools: string[] }[];
|
|
80
80
|
}
|
|
81
|
+
/** What the worker learns from the host at startup; `compaction` is absent when the host has no compactor. */
|
|
82
|
+
export interface PiWorkerConfig {
|
|
83
|
+
compaction?: { engine: string };
|
|
84
|
+
}
|
|
85
|
+
/** The JSON of one Pi `AgentMessage` from the worker's session. */
|
|
86
|
+
export type PiCompactMessage = { role: string } & Record<string, unknown>;
|
|
87
|
+
/** The worker's compaction preparation, sent to the host's compactor. */
|
|
88
|
+
export interface PiCompactRequest {
|
|
89
|
+
/** What triggered the compaction: a manual compact, the context threshold, or an overflow. */
|
|
90
|
+
reason: "manual" | "threshold" | "overflow";
|
|
91
|
+
tokensBefore: number;
|
|
92
|
+
/** The first session entry the compaction keeps; a compaction must keep the same one. */
|
|
93
|
+
firstKeptEntryId: string;
|
|
94
|
+
/** Whether the cut falls inside a turn, so `turnPrefixMessages` holds that turn's start. */
|
|
95
|
+
isSplitTurn: boolean;
|
|
96
|
+
messagesToSummarize: PiCompactMessage[];
|
|
97
|
+
turnPrefixMessages: PiCompactMessage[];
|
|
98
|
+
/** The messages from `firstKeptEntryId` on, which stay in the context. */
|
|
99
|
+
keptMessages: PiCompactMessage[];
|
|
100
|
+
previousSummary?: string;
|
|
101
|
+
customInstructions?: string;
|
|
102
|
+
/** Files only read, and files written or edited, in the summarized messages. */
|
|
103
|
+
readFiles: string[];
|
|
104
|
+
modifiedFiles: string[];
|
|
105
|
+
}
|
|
106
|
+
/** A compaction the host's compactor wrote, shaped as Pi's `CompactionResult`. */
|
|
107
|
+
export interface PiCompaction {
|
|
108
|
+
summary: string;
|
|
109
|
+
firstKeptEntryId: string;
|
|
110
|
+
tokensBefore: number;
|
|
111
|
+
estimatedTokensAfter?: number;
|
|
112
|
+
details?: unknown;
|
|
113
|
+
}
|
|
114
|
+
/** The host's answer: a compaction, or a fallback to Pi's own summary with its reason. */
|
|
115
|
+
export type PiCompactResponse =
|
|
116
|
+
| { ok: true; compaction: PiCompaction }
|
|
117
|
+
| { ok: false; fallback: string };
|
|
118
|
+
/** What the worker tells the host about its compactions, so the host logs them per channel. */
|
|
119
|
+
export type PiCompactionReport =
|
|
120
|
+
| {
|
|
121
|
+
type: "bypass";
|
|
122
|
+
/** Why the hard ceiling skipped the host's compactor for Pi's summary. */
|
|
123
|
+
reason: string;
|
|
124
|
+
tokensBefore: number;
|
|
125
|
+
}
|
|
126
|
+
| {
|
|
127
|
+
type: "end";
|
|
128
|
+
reason: PiCompactRequest["reason"];
|
|
129
|
+
aborted: boolean;
|
|
130
|
+
willRetry: boolean;
|
|
131
|
+
/** Absent when the compaction failed. */
|
|
132
|
+
engine?: "extension" | "pi";
|
|
133
|
+
tokensBefore?: number;
|
|
134
|
+
tokensAfter?: number;
|
|
135
|
+
nextCompactionAt?: number;
|
|
136
|
+
error?: string;
|
|
137
|
+
};
|
|
138
|
+
const COMPACT_REASONS = ["manual", "threshold", "overflow"];
|
|
139
|
+
function isMessageList(value: unknown): value is PiCompactMessage[] {
|
|
140
|
+
return (
|
|
141
|
+
Array.isArray(value) &&
|
|
142
|
+
value.every((item) => isRecord(item) && typeof item.role === "string")
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
function isStringList(value: unknown): value is string[] {
|
|
146
|
+
return (
|
|
147
|
+
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
function isTokenCount(value: unknown): value is number {
|
|
151
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
152
|
+
}
|
|
153
|
+
export function validateCompactRequest(
|
|
154
|
+
value: unknown,
|
|
155
|
+
): asserts value is PiCompactRequest {
|
|
156
|
+
if (
|
|
157
|
+
!isRecord(value) ||
|
|
158
|
+
!COMPACT_REASONS.includes(value.reason as string) ||
|
|
159
|
+
!isTokenCount(value.tokensBefore) ||
|
|
160
|
+
typeof value.firstKeptEntryId !== "string" ||
|
|
161
|
+
value.firstKeptEntryId.length === 0 ||
|
|
162
|
+
value.firstKeptEntryId.length > 256 ||
|
|
163
|
+
typeof value.isSplitTurn !== "boolean" ||
|
|
164
|
+
!isMessageList(value.messagesToSummarize) ||
|
|
165
|
+
!isMessageList(value.turnPrefixMessages) ||
|
|
166
|
+
!isMessageList(value.keptMessages) ||
|
|
167
|
+
(value.previousSummary !== undefined &&
|
|
168
|
+
typeof value.previousSummary !== "string") ||
|
|
169
|
+
(value.customInstructions !== undefined &&
|
|
170
|
+
typeof value.customInstructions !== "string") ||
|
|
171
|
+
!isStringList(value.readFiles) ||
|
|
172
|
+
!isStringList(value.modifiedFiles)
|
|
173
|
+
)
|
|
174
|
+
throw new Error("Invalid compact request");
|
|
175
|
+
}
|
|
176
|
+
export function validateCompactionReport(
|
|
177
|
+
value: unknown,
|
|
178
|
+
): asserts value is PiCompactionReport {
|
|
179
|
+
const valid =
|
|
180
|
+
isRecord(value) &&
|
|
181
|
+
(value.type === "bypass"
|
|
182
|
+
? typeof value.reason === "string" &&
|
|
183
|
+
value.reason.length <= 1000 &&
|
|
184
|
+
isTokenCount(value.tokensBefore)
|
|
185
|
+
: value.type === "end" &&
|
|
186
|
+
COMPACT_REASONS.includes(value.reason as string) &&
|
|
187
|
+
typeof value.aborted === "boolean" &&
|
|
188
|
+
typeof value.willRetry === "boolean" &&
|
|
189
|
+
(value.engine === undefined ||
|
|
190
|
+
value.engine === "extension" ||
|
|
191
|
+
value.engine === "pi") &&
|
|
192
|
+
[value.tokensBefore, value.tokensAfter, value.nextCompactionAt].every(
|
|
193
|
+
(count) => count === undefined || isTokenCount(count),
|
|
194
|
+
) &&
|
|
195
|
+
(value.error === undefined ||
|
|
196
|
+
(typeof value.error === "string" && value.error.length <= 10_000)));
|
|
197
|
+
if (!valid) throw new Error("Invalid compaction report");
|
|
198
|
+
}
|
|
81
199
|
export function isPiThinkingLevel(value: unknown): value is PiThinkingLevel {
|
|
82
200
|
return (
|
|
83
201
|
typeof value === "string" &&
|
package/src/pi-runtime.ts
CHANGED
|
@@ -14,9 +14,14 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { isAbsolute, join } from "node:path";
|
|
16
16
|
import { AgentRunError, type ChannelKey, type Logger } from "pi-roundtable";
|
|
17
|
-
import { channelSegment } from "pi-roundtable/kit";
|
|
17
|
+
import { channelSegment, scrubDiagnostic } from "pi-roundtable/kit";
|
|
18
18
|
import { ownDirectory } from "./directory-file.ts";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
errorText,
|
|
21
|
+
PI_COMPACT_LIMITS,
|
|
22
|
+
type PiBrokerOptions,
|
|
23
|
+
PiSandboxBroker,
|
|
24
|
+
} from "./pi-broker.ts";
|
|
20
25
|
import {
|
|
21
26
|
type PiContainerDriver,
|
|
22
27
|
type PiContainerStatus,
|
|
@@ -129,17 +134,31 @@ export class PiSandboxRuntime {
|
|
|
129
134
|
)
|
|
130
135
|
)
|
|
131
136
|
throw new Error("Invalid container prefix");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
options.startTimeoutMs ?? 90_000,
|
|
135
|
-
])
|
|
137
|
+
const turnTimeoutMs = options.turnTimeoutMs ?? 600_000;
|
|
138
|
+
for (const timeout of [turnTimeoutMs, options.startTimeoutMs ?? 90_000])
|
|
136
139
|
if (!Number.isSafeInteger(timeout) || timeout < 1000 || timeout > 600_000)
|
|
137
140
|
throw new Error("Invalid runtime deadline");
|
|
141
|
+
// A compactor that runs out of time still leaves Pi's summary half the turn.
|
|
142
|
+
const compaction = options.compaction && {
|
|
143
|
+
...options.compaction,
|
|
144
|
+
timeoutMs:
|
|
145
|
+
options.compaction.timeoutMs ??
|
|
146
|
+
Math.max(
|
|
147
|
+
1000,
|
|
148
|
+
Math.min(PI_COMPACT_LIMITS.timeoutMs, Math.floor(turnTimeoutMs / 3)),
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
if (compaction && compaction.timeoutMs > turnTimeoutMs / 2)
|
|
152
|
+
throw new Error("The compactor's timeout must leave half the turn");
|
|
138
153
|
mkdirSync(options.partyDir, { recursive: true, mode: 0o700 });
|
|
139
154
|
const info = statSync(options.partyDir);
|
|
140
155
|
if (info.uid !== process.getuid?.() || (info.mode & 0o077) !== 0)
|
|
141
156
|
throw new Error("State directory must be private and host-owned");
|
|
142
|
-
this.#options = {
|
|
157
|
+
this.#options = {
|
|
158
|
+
...options,
|
|
159
|
+
partyDir: realpathSync(options.partyDir),
|
|
160
|
+
...(compaction ? { compaction } : {}),
|
|
161
|
+
};
|
|
143
162
|
this.#driver =
|
|
144
163
|
options.driver ??
|
|
145
164
|
new PiDockerContainerDriver(undefined, options.labelProfile);
|
|
@@ -311,6 +330,22 @@ export class PiSandboxRuntime {
|
|
|
311
330
|
await Promise.all([...this.#brokers.values()].map((entry) => entry.stop()));
|
|
312
331
|
this.#brokers.clear();
|
|
313
332
|
}
|
|
333
|
+
/** The worker's last log lines, read before its container is removed, so they are not lost with it. */
|
|
334
|
+
async #logWorker(channel: ChannelKey, turnId: string): Promise<void> {
|
|
335
|
+
if (!this.#driver.logs) return;
|
|
336
|
+
try {
|
|
337
|
+
const lines = await this.#driver.logs(this.#name(channel), 200);
|
|
338
|
+
this.#options.logger.warn(
|
|
339
|
+
{ channel, turnId, lines: scrubDiagnostic(lines, 200_000) },
|
|
340
|
+
"sandbox worker log before removal",
|
|
341
|
+
);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
this.#options.logger.warn(
|
|
344
|
+
{ channel, turnId, error: scrubDiagnostic(errorText(error)) },
|
|
345
|
+
"sandbox worker log unavailable",
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
314
349
|
async runTurn(turn: PiSandboxTurn): Promise<PiSandboxTurnResult> {
|
|
315
350
|
if (this.#active.has(turn.channel))
|
|
316
351
|
return { ok: false, error: new AgentRunError("Channel is busy") };
|
|
@@ -322,6 +357,10 @@ export class PiSandboxRuntime {
|
|
|
322
357
|
AbortSignal.timeout(this.#options.turnTimeoutMs ?? 600_000),
|
|
323
358
|
]);
|
|
324
359
|
signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
360
|
+
const timedOut = () =>
|
|
361
|
+
signal.aborted &&
|
|
362
|
+
signal.reason instanceof DOMException &&
|
|
363
|
+
signal.reason.name === "TimeoutError";
|
|
325
364
|
let release: (() => void) | undefined;
|
|
326
365
|
try {
|
|
327
366
|
signal.throwIfAborted();
|
|
@@ -372,20 +411,38 @@ export class PiSandboxRuntime {
|
|
|
372
411
|
});
|
|
373
412
|
const body = await entry.broker.execute(request, signal);
|
|
374
413
|
signal.throwIfAborted();
|
|
375
|
-
if (!body.ok) throw new Error(
|
|
414
|
+
if (!body.ok) throw new Error(`Worker turn failed: ${body.error}`);
|
|
376
415
|
validateReplyFiles(body.files);
|
|
377
416
|
const files = body.files.map((file) => ({
|
|
378
417
|
name: file.name,
|
|
379
418
|
data: Buffer.from(file.data, "base64"),
|
|
380
419
|
}));
|
|
381
420
|
return { ok: true, text: body.text, files };
|
|
382
|
-
} catch {
|
|
421
|
+
} catch (error) {
|
|
422
|
+
const failure = {
|
|
423
|
+
message: scrubDiagnostic(errorText(error), 4000),
|
|
424
|
+
timedOut: timedOut(),
|
|
425
|
+
cancelled: signal.aborted && !timedOut(),
|
|
426
|
+
};
|
|
427
|
+
this.#options.logger.error(
|
|
428
|
+
{ channel: turn.channel, turnId: turn.turnId, ...failure },
|
|
429
|
+
"sandbox turn failed",
|
|
430
|
+
);
|
|
383
431
|
// A timed-out Pi session must not keep consuming broker tools or corrupt the next turn.
|
|
384
432
|
if (signal.aborted) {
|
|
385
433
|
await this.#starts.get(turn.channel)?.catch(() => {});
|
|
434
|
+
await this.#logWorker(turn.channel, turn.turnId);
|
|
386
435
|
await this.#driver.remove(this.#name(turn.channel));
|
|
387
436
|
}
|
|
388
|
-
return {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: new AgentRunError(
|
|
440
|
+
failure.timedOut
|
|
441
|
+
? "Sandbox turn timed out"
|
|
442
|
+
: `Sandbox turn failed: ${failure.message}`,
|
|
443
|
+
{ cause: error },
|
|
444
|
+
),
|
|
445
|
+
};
|
|
389
446
|
} finally {
|
|
390
447
|
controller.abort();
|
|
391
448
|
release?.();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker session's compaction: the host's tiers, and the host's compactor reached over the broker.
|
|
3
|
+
*/
|
|
4
|
+
import type {
|
|
5
|
+
AgentSession,
|
|
6
|
+
AgentSessionEvent,
|
|
7
|
+
ExtensionFactory,
|
|
8
|
+
SessionBeforeCompactEvent,
|
|
9
|
+
SessionBeforeCompactResult,
|
|
10
|
+
SettingsManager,
|
|
11
|
+
} from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import {
|
|
13
|
+
type CompactionHistory,
|
|
14
|
+
CompactionTiers,
|
|
15
|
+
compactionEngine,
|
|
16
|
+
} from "pi-roundtable/kit";
|
|
17
|
+
import type {
|
|
18
|
+
PiCompactionReport,
|
|
19
|
+
PiCompactMessage,
|
|
20
|
+
PiCompactRequest,
|
|
21
|
+
PiCompactResponse,
|
|
22
|
+
PiWorkerConfig,
|
|
23
|
+
} from "../src/pi-protocol.ts";
|
|
24
|
+
|
|
25
|
+
type CompactionEnd = Extract<AgentSessionEvent, { type: "compaction_end" }>;
|
|
26
|
+
export type WorkerLog = (msg: string, fields?: Record<string, unknown>) => void;
|
|
27
|
+
|
|
28
|
+
export interface WorkerCompactionOptions {
|
|
29
|
+
/** The session's entries, for the tiers' loop guard. */
|
|
30
|
+
history: CompactionHistory;
|
|
31
|
+
contextWindow(provider: string, id: string): number | undefined;
|
|
32
|
+
/** What the host told the worker at startup; no `compaction` means no host compactor. */
|
|
33
|
+
config: PiWorkerConfig;
|
|
34
|
+
/** The broker's Unix socket. */
|
|
35
|
+
socket: string;
|
|
36
|
+
log: WorkerLog;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The request the host's compactor reads: Pi's preparation, with the messages it keeps. */
|
|
40
|
+
export function compactRequest(
|
|
41
|
+
event: SessionBeforeCompactEvent,
|
|
42
|
+
): PiCompactRequest {
|
|
43
|
+
const { preparation } = event;
|
|
44
|
+
const start = event.branchEntries.findIndex(
|
|
45
|
+
(entry) => entry.id === preparation.firstKeptEntryId,
|
|
46
|
+
);
|
|
47
|
+
const keptMessages =
|
|
48
|
+
start < 0
|
|
49
|
+
? []
|
|
50
|
+
: event.branchEntries
|
|
51
|
+
.slice(start)
|
|
52
|
+
.flatMap((entry) =>
|
|
53
|
+
entry.type === "message" ? [entry.message] : [],
|
|
54
|
+
);
|
|
55
|
+
const { read, written, edited } = preparation.fileOps;
|
|
56
|
+
const modified = new Set([...written, ...edited]);
|
|
57
|
+
return {
|
|
58
|
+
reason: event.reason,
|
|
59
|
+
tokensBefore: preparation.tokensBefore,
|
|
60
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
61
|
+
isSplitTurn: preparation.isSplitTurn,
|
|
62
|
+
messagesToSummarize: preparation.messagesToSummarize as PiCompactMessage[],
|
|
63
|
+
turnPrefixMessages: preparation.turnPrefixMessages as PiCompactMessage[],
|
|
64
|
+
keptMessages: keptMessages as PiCompactMessage[],
|
|
65
|
+
...(preparation.previousSummary === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { previousSummary: preparation.previousSummary }),
|
|
68
|
+
...(event.customInstructions === undefined
|
|
69
|
+
? {}
|
|
70
|
+
: { customInstructions: event.customInstructions }),
|
|
71
|
+
readFiles: [...read].filter((path) => !modified.has(path)).sort(),
|
|
72
|
+
modifiedFiles: [...modified].sort(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compacts as the host's own sessions do: large windows at the soft threshold through the host's
|
|
78
|
+
* compactor, past the hard ceiling through Pi's summary, which reaches the model over the broker.
|
|
79
|
+
*/
|
|
80
|
+
export class WorkerCompaction {
|
|
81
|
+
readonly #options: WorkerCompactionOptions;
|
|
82
|
+
readonly #tiers: CompactionTiers;
|
|
83
|
+
readonly #pending = new Set<Promise<void>>();
|
|
84
|
+
|
|
85
|
+
constructor(options: WorkerCompactionOptions) {
|
|
86
|
+
this.#options = options;
|
|
87
|
+
this.#tiers = new CompactionTiers(
|
|
88
|
+
options.history,
|
|
89
|
+
options.contextWindow,
|
|
90
|
+
options.config.compaction?.engine,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pi's settings, with each model's reserve set so it compacts at its tier. */
|
|
95
|
+
settings(): SettingsManager {
|
|
96
|
+
return this.#tiers.settings();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The host compactor's handler, held back by the tiers; none when the host has no compactor. */
|
|
100
|
+
extensions(): { name: string; factory: ExtensionFactory }[] {
|
|
101
|
+
if (!this.#options.config.compaction) return [];
|
|
102
|
+
const handler: ExtensionFactory = (pi) => {
|
|
103
|
+
pi.on("session_before_compact", (event) => this.#compact(event));
|
|
104
|
+
};
|
|
105
|
+
return [
|
|
106
|
+
{
|
|
107
|
+
name: "host-compaction",
|
|
108
|
+
factory: this.#tiers.wrapCompactor(handler, (bypass) => {
|
|
109
|
+
this.#options.log("compaction skips the host compactor", bypass);
|
|
110
|
+
void this.#report({ type: "bypass", ...bypass });
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Logs a finished compaction and reports it to the host, which logs it for the channel. */
|
|
117
|
+
async ended(event: CompactionEnd, session: AgentSession): Promise<void> {
|
|
118
|
+
const { result } = event;
|
|
119
|
+
const { model, settingsManager } = session;
|
|
120
|
+
const report: PiCompactionReport = result
|
|
121
|
+
? {
|
|
122
|
+
type: "end",
|
|
123
|
+
reason: event.reason,
|
|
124
|
+
aborted: event.aborted,
|
|
125
|
+
willRetry: event.willRetry,
|
|
126
|
+
engine: compactionEngine(
|
|
127
|
+
result.details,
|
|
128
|
+
this.#options.config.compaction?.engine,
|
|
129
|
+
),
|
|
130
|
+
tokensBefore: result.tokensBefore,
|
|
131
|
+
...(result.estimatedTokensAfter === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { tokensAfter: result.estimatedTokensAfter }),
|
|
134
|
+
...(model
|
|
135
|
+
? {
|
|
136
|
+
nextCompactionAt:
|
|
137
|
+
model.contextWindow -
|
|
138
|
+
settingsManager.getCompactionSettings(model).reserveTokens,
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
141
|
+
}
|
|
142
|
+
: {
|
|
143
|
+
type: "end",
|
|
144
|
+
reason: event.reason,
|
|
145
|
+
aborted: event.aborted,
|
|
146
|
+
willRetry: event.willRetry,
|
|
147
|
+
...(event.errorMessage === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { error: event.errorMessage.slice(0, 10_000) }),
|
|
150
|
+
};
|
|
151
|
+
const { type: _type, ...fields } = report;
|
|
152
|
+
this.#options.log(
|
|
153
|
+
result ? "conversation compacted" : "compaction failed",
|
|
154
|
+
fields,
|
|
155
|
+
);
|
|
156
|
+
await this.#report(report);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #compact(
|
|
160
|
+
event: SessionBeforeCompactEvent,
|
|
161
|
+
): Promise<SessionBeforeCompactResult | undefined> {
|
|
162
|
+
const { log } = this.#options;
|
|
163
|
+
try {
|
|
164
|
+
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
165
|
+
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
166
|
+
const response = await fetch("http://broker/compaction/compact", {
|
|
167
|
+
unix: this.#options.socket,
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "content-type": "application/json" },
|
|
170
|
+
body: JSON.stringify(compactRequest(event)),
|
|
171
|
+
signal: event.signal,
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
await response.body?.cancel();
|
|
175
|
+
log("compaction falls back to Pi's summary", {
|
|
176
|
+
fallback: `the broker answered ${response.status}`,
|
|
177
|
+
});
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
const answer = (await response.json()) as PiCompactResponse;
|
|
181
|
+
if (!answer.ok) {
|
|
182
|
+
log("compaction falls back to Pi's summary", {
|
|
183
|
+
fallback: answer.fallback,
|
|
184
|
+
});
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
return { compaction: answer.compaction };
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log("compaction falls back to Pi's summary", {
|
|
190
|
+
fallback: `the compact request failed: ${String(error)}`,
|
|
191
|
+
});
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Waits for the reports still on their way, so they reach the host while the turn is bound. */
|
|
197
|
+
async settled(): Promise<void> {
|
|
198
|
+
await Promise.allSettled([...this.#pending]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#report(report: PiCompactionReport): Promise<void> {
|
|
202
|
+
const sending = this.#send(report).finally(() =>
|
|
203
|
+
this.#pending.delete(sending),
|
|
204
|
+
);
|
|
205
|
+
this.#pending.add(sending);
|
|
206
|
+
return sending;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async #send(report: PiCompactionReport): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
// Local Unix-socket transport; no TCP connection or DNS lookup is made.
|
|
212
|
+
// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request
|
|
213
|
+
const response = await fetch("http://broker/compaction/report", {
|
|
214
|
+
unix: this.#options.socket,
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "content-type": "application/json" },
|
|
217
|
+
body: JSON.stringify(report),
|
|
218
|
+
signal: AbortSignal.timeout(5000),
|
|
219
|
+
});
|
|
220
|
+
await response.body?.cancel();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
this.#options.log("compaction report failed", { error: String(error) });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|