@nijaru/pi-provider-compaction 0.0.2 → 0.0.4
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/README.md +55 -41
- package/dist/index.js +1983 -0
- package/index.ts +279 -456
- package/package.json +13 -11
- package/protocol.ts +50 -12
- package/provider.ts +85 -0
- package/replay.ts +140 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-provider-compaction",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
"responses"
|
|
13
13
|
],
|
|
14
14
|
"license": "MIT",
|
|
15
|
-
|
|
16
15
|
"publishConfig": {
|
|
17
16
|
"access": "public"
|
|
18
17
|
},
|
|
@@ -23,33 +22,36 @@
|
|
|
23
22
|
},
|
|
24
23
|
"pi": {
|
|
25
24
|
"extensions": [
|
|
26
|
-
"./index.
|
|
25
|
+
"./dist/index.js"
|
|
27
26
|
]
|
|
28
27
|
},
|
|
29
28
|
"scripts": {
|
|
29
|
+
"build": "bun run build.ts",
|
|
30
|
+
"prepack": "bun run build",
|
|
30
31
|
"test": "bun test",
|
|
31
|
-
"lint": "oxlint index.ts protocol.ts policy.ts tests/",
|
|
32
|
+
"lint": "oxlint build.ts index.ts protocol.ts policy.ts provider.ts replay.ts tests/",
|
|
32
33
|
"typecheck": "tsc --noEmit",
|
|
33
|
-
"check": "bun test &&
|
|
34
|
+
"check": "bun test && bun run lint && tsc --noEmit"
|
|
34
35
|
},
|
|
35
36
|
"files": [
|
|
37
|
+
"dist",
|
|
36
38
|
"index.ts",
|
|
37
39
|
"protocol.ts",
|
|
38
40
|
"policy.ts",
|
|
41
|
+
"provider.ts",
|
|
42
|
+
"replay.ts",
|
|
39
43
|
"README.md",
|
|
40
44
|
"LICENSE"
|
|
41
45
|
],
|
|
42
|
-
"dependencies": {
|
|
43
|
-
"@earendil-works/pi-ai": "0.85.1"
|
|
44
|
-
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@earendil-works/pi-
|
|
47
|
-
"@earendil-works/pi-
|
|
47
|
+
"@earendil-works/pi-ai": "0.87.0",
|
|
48
|
+
"@earendil-works/pi-agent-core": "0.87.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "0.87.0",
|
|
48
50
|
"@types/bun": "latest",
|
|
49
51
|
"oxlint": "^1.81.0",
|
|
50
52
|
"typescript": "^6.0.3"
|
|
51
53
|
},
|
|
52
54
|
"peerDependencies": {
|
|
53
|
-
"@earendil-works/pi-coding-agent": "
|
|
55
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
56
|
}
|
|
55
57
|
}
|
package/protocol.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AssistantMessageEventStream,
|
|
3
|
-
Context,
|
|
4
3
|
Model,
|
|
5
4
|
Provider,
|
|
6
5
|
ProviderHeaders,
|
|
6
|
+
TranscriptContext,
|
|
7
7
|
Usage,
|
|
8
8
|
} from "@earendil-works/pi-ai";
|
|
9
9
|
|
|
@@ -46,13 +46,37 @@ function validateItemSize(item: ResponseItem): void {
|
|
|
46
46
|
if (byteLength(item) > MAX_ITEM_BYTES) throw new Error("Native compaction output item exceeded 2 MiB");
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function validContent(value: unknown): boolean {
|
|
50
|
+
return typeof value === "string" || (Array.isArray(value) && value.every((part) => {
|
|
51
|
+
if (!isObject(part)) return false;
|
|
52
|
+
if (part.type === "input_text" || part.type === "output_text" || part.type === "summary_text") return typeof part.text === "string";
|
|
53
|
+
if (part.type === "input_image") return typeof part.image_url === "string" || typeof part.file_id === "string";
|
|
54
|
+
if (part.type === "refusal") return typeof part.refusal === "string";
|
|
55
|
+
return false;
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
export function validateCompactedOutput(value: unknown): ResponseItem[] {
|
|
50
60
|
if (!isObject(value) || !Array.isArray(value.output) || value.output.length === 0) {
|
|
51
61
|
throw new Error("Responses Compact returned an invalid output window");
|
|
52
62
|
}
|
|
63
|
+
if (byteLength(value.output) > MAX_RESPONSE_BYTES) throw new Error("Native compaction output exceeded 8 MiB");
|
|
53
64
|
const output = value.output.map((item) => {
|
|
54
65
|
if (!isObject(item)) throw new Error("Responses Compact returned a non-object output item");
|
|
55
66
|
validateItemSize(item);
|
|
67
|
+
const type = item.type ?? (typeof item.role === "string" ? "message" : undefined);
|
|
68
|
+
if (!["compaction", "message", "reasoning", "function_call", "function_call_output", "custom_tool_call", "custom_tool_call_output", "tool_search_call", "tool_search_output", "additional_tools"].includes(String(type))) throw new Error("Responses Compact returned an unsupported output item");
|
|
69
|
+
if (type === "message" && (!["user", "assistant", "system", "developer"].includes(String(item.role)) || !validContent(item.content))) throw new Error("Responses Compact returned an invalid message item");
|
|
70
|
+
if (type === "compaction" && !isCompactionItem(item)) throw new Error("Responses Compact returned an invalid checkpoint");
|
|
71
|
+
if (type === "reasoning" && !(typeof item.encrypted_content === "string" || (Array.isArray(item.summary) && validContent(item.summary)))) throw new Error("Responses Compact returned invalid reasoning");
|
|
72
|
+
if (String(type).includes("call") || type === "tool_search_output") {
|
|
73
|
+
if (typeof item.call_id !== "string" || !item.call_id) throw new Error("Responses Compact returned an invalid tool id");
|
|
74
|
+
if (type === "function_call" && (typeof item.name !== "string" || typeof item.arguments !== "string")) throw new Error("Responses Compact returned an invalid function call");
|
|
75
|
+
if (type === "custom_tool_call" && (typeof item.name !== "string" || typeof item.input !== "string")) throw new Error("Responses Compact returned an invalid custom call");
|
|
76
|
+
if ((type === "function_call_output" || type === "custom_tool_call_output") && !validContent(item.output)) throw new Error("Responses Compact returned an invalid tool output");
|
|
77
|
+
if (type === "tool_search_call" && !isObject(item.arguments)) throw new Error("Responses Compact returned an invalid tool search");
|
|
78
|
+
}
|
|
79
|
+
if ((type === "additional_tools" || type === "tool_search_output") && (!Array.isArray(item.tools) || !item.tools.every(isObject))) throw new Error("Responses Compact returned invalid tools");
|
|
56
80
|
return structuredClone(item);
|
|
57
81
|
});
|
|
58
82
|
const checkpoints = output.filter(isCompactionItem);
|
|
@@ -85,6 +109,7 @@ async function readJsonBounded(response: Response, signal: AbortSignal): Promise
|
|
|
85
109
|
}
|
|
86
110
|
} finally {
|
|
87
111
|
signal.removeEventListener("abort", onAbort);
|
|
112
|
+
void reader.cancel().catch(() => undefined);
|
|
88
113
|
reader.releaseLock();
|
|
89
114
|
}
|
|
90
115
|
const bytes = new Uint8Array(total);
|
|
@@ -193,8 +218,10 @@ async function collectProviderUsage(stream: AssistantMessageEventStream, signal:
|
|
|
193
218
|
interface TransportRequest {
|
|
194
219
|
provider: Provider;
|
|
195
220
|
model: Model<any>;
|
|
196
|
-
context:
|
|
221
|
+
context: TranscriptContext;
|
|
197
222
|
input: ResponseItem[];
|
|
223
|
+
/** Fully prepared request snapshot; only its validated prefix is sent. */
|
|
224
|
+
preparedPayload?: JsonObject;
|
|
198
225
|
apiKey?: string;
|
|
199
226
|
headers?: ProviderHeaders;
|
|
200
227
|
env?: Record<string, string>;
|
|
@@ -251,7 +278,8 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
251
278
|
fetch: bridgeFetch,
|
|
252
279
|
onPayload: (payload: unknown) => {
|
|
253
280
|
if (!isObject(payload)) throw new Error("Responses provider exposed a non-object payload");
|
|
254
|
-
preparedPayload
|
|
281
|
+
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
282
|
+
preparedPayload = { ...structuredClone(request.preparedPayload ?? payload), input: structuredClone(request.input) };
|
|
255
283
|
return preparedPayload;
|
|
256
284
|
},
|
|
257
285
|
});
|
|
@@ -325,6 +353,7 @@ async function collectCompactionSse(stream: ReadableStream<Uint8Array>, signal:
|
|
|
325
353
|
dispatch();
|
|
326
354
|
} finally {
|
|
327
355
|
signal.removeEventListener("abort", onAbort);
|
|
356
|
+
void reader.cancel().catch(() => undefined);
|
|
328
357
|
reader.releaseLock();
|
|
329
358
|
}
|
|
330
359
|
if (!completed) throw new Error("Remote V2 stream ended without response.completed");
|
|
@@ -359,12 +388,16 @@ function buildRemoteV2History(input: ResponseItem[], item: ResponseItem): Respon
|
|
|
359
388
|
async function requestRemoteV2(request: TransportRequest): Promise<TransportResponse> {
|
|
360
389
|
const baseFetch = request.fetch ?? globalThis.fetch;
|
|
361
390
|
let sentInput: ResponseItem[] | undefined;
|
|
362
|
-
const
|
|
391
|
+
const controller = new AbortController();
|
|
392
|
+
const signal = AbortSignal.any([request.signal, controller.signal]);
|
|
393
|
+
const inspections: Promise<{ result: CollectedSse } | { error: unknown }>[] = [];
|
|
363
394
|
const inspectedFetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
364
395
|
const response = await baseFetch(input, init);
|
|
365
396
|
if (!response.ok || !response.body) return response;
|
|
366
397
|
const [providerBody, inspectionBody] = response.body.tee();
|
|
367
|
-
|
|
398
|
+
// Attach the rejection handler immediately: inspection can fail before the
|
|
399
|
+
// provider finishes consuming its half of the stream.
|
|
400
|
+
inspections.push(collectCompactionSse(inspectionBody, signal).then((result) => ({ result }), (error: unknown) => ({ error })));
|
|
368
401
|
return new Response(providerBody, {
|
|
369
402
|
status: response.status,
|
|
370
403
|
statusText: response.statusText,
|
|
@@ -384,19 +417,24 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
384
417
|
onPayload: (payload: unknown) => {
|
|
385
418
|
if (!isObject(payload)) throw new Error("Codex provider exposed a non-object payload");
|
|
386
419
|
sentInput = structuredClone(request.input);
|
|
387
|
-
|
|
420
|
+
if (request.preparedPayload && request.preparedPayload.model !== payload.model) throw new Error("Native route model changed since capture");
|
|
421
|
+
return { ...structuredClone(request.preparedPayload ?? payload), stream: true, input: [...sentInput, { type: "compaction_trigger" }] };
|
|
388
422
|
},
|
|
389
423
|
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
|
|
424
|
+
try {
|
|
425
|
+
const usage = await collectProviderUsage(stream, request.signal);
|
|
426
|
+
if (!sentInput || inspections.length !== 1) throw new Error(`Remote V2 observed ${inspections.length} successful provider responses; expected exactly one`);
|
|
427
|
+
const inspection = await inspections[0]!;
|
|
428
|
+
if ("error" in inspection) throw inspection.error;
|
|
429
|
+
return { output: buildRemoteV2History(sentInput, inspection.result.item), usage };
|
|
430
|
+
} finally {
|
|
431
|
+
controller.abort();
|
|
393
432
|
}
|
|
394
|
-
const inspection = await inspections[0];
|
|
395
|
-
return { output: buildRemoteV2History(sentInput, inspection.item), usage };
|
|
396
433
|
}
|
|
397
434
|
|
|
398
435
|
export async function requestProviderCompaction(
|
|
399
436
|
request: TransportRequest & { protocol: NativeProtocol },
|
|
400
437
|
): Promise<TransportResponse> {
|
|
401
|
-
|
|
438
|
+
const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
|
|
439
|
+
return { ...result, output: validateCompactedOutput(result) };
|
|
402
440
|
}
|
package/provider.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Provider, StreamOptions, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => ((payload: unknown) => unknown | Promise<unknown>) | undefined;
|
|
5
|
+
|
|
6
|
+
function optionsWithObserver<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest): T | undefined {
|
|
7
|
+
const handler = observe(model, context, options ?? {});
|
|
8
|
+
if (!handler) return options;
|
|
9
|
+
return {
|
|
10
|
+
...options,
|
|
11
|
+
onPayload: async (payload: unknown, requestModel: Model<any>) => {
|
|
12
|
+
const replacement = await options?.onPayload?.(payload, requestModel);
|
|
13
|
+
const prepared = replacement === undefined ? payload : replacement;
|
|
14
|
+
// Observation failures must not suppress or alter the portable request.
|
|
15
|
+
try { return await handler(prepared); } catch { return prepared; }
|
|
16
|
+
},
|
|
17
|
+
} as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Preserve method receivers, getters, optional members and dynamic catalogs. */
|
|
21
|
+
export function decorateProvider(base: Provider, observe: ObserveRequest): Provider {
|
|
22
|
+
const stream: Provider["stream"] = (model, context, options) => base.stream(model, context, optionsWithObserver(model, context, options, observe));
|
|
23
|
+
const streamSimple: Provider["streamSimple"] = (model, context, options) => base.streamSimple(model, context, optionsWithObserver(model, context, options, observe));
|
|
24
|
+
const bound = new Map<PropertyKey, unknown>();
|
|
25
|
+
return new Proxy(base, {
|
|
26
|
+
get(target, key) {
|
|
27
|
+
if (key === "stream") return stream;
|
|
28
|
+
if (key === "streamSimple") return streamSimple;
|
|
29
|
+
const value = Reflect.get(target, key, target);
|
|
30
|
+
if (typeof value !== "function") return value;
|
|
31
|
+
if (!bound.has(key)) bound.set(key, value.bind(target));
|
|
32
|
+
return bound.get(key);
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pi 0.87 has replacement registration, not middleware. Native BASE providers can
|
|
39
|
+
* be decorated directly. Legacy stream overlays retain their configuration and
|
|
40
|
+
* delegate their original callback (including /fast). Never register an effective
|
|
41
|
+
* composed provider as a native base: that loses model headers and freezes config.
|
|
42
|
+
*/
|
|
43
|
+
export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): (() => void) | undefined {
|
|
44
|
+
const model = ctx.model;
|
|
45
|
+
if (!model) return;
|
|
46
|
+
const registry = ctx.modelRegistry;
|
|
47
|
+
const id = model.provider;
|
|
48
|
+
const native = registry.getRegisteredNativeProvider(id);
|
|
49
|
+
if (native) {
|
|
50
|
+
const guarded: ObserveRequest = (...args) => {
|
|
51
|
+
const handler = observe(...args);
|
|
52
|
+
return handler ? (payload) => registry.getRegisteredNativeProvider(id) === wrapper ? handler(payload) : payload : undefined;
|
|
53
|
+
};
|
|
54
|
+
const wrapper = decorateProvider(native, guarded);
|
|
55
|
+
pi.registerProvider(wrapper);
|
|
56
|
+
return () => {
|
|
57
|
+
if (registry.getRegisteredNativeProvider(id) === wrapper) pi.registerProvider(native);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const original = registry.getRegisteredProviderConfig(id);
|
|
61
|
+
// Refresh callbacks own mutable catalog state inside the composer. Replacing
|
|
62
|
+
// that composer cannot promise preservation, so leave it alone.
|
|
63
|
+
if (!original?.streamSimple || original.refreshModels || original.oauth?.modifyModels) return;
|
|
64
|
+
if (original.api !== model.api) return;
|
|
65
|
+
// An injected legacy streamSimple overlay would also intercept full stream()
|
|
66
|
+
// calls and change reasoning semantics. Only decorate an existing stream owner.
|
|
67
|
+
const delegate = original.streamSimple.bind(original);
|
|
68
|
+
const guarded: ObserveRequest = (...args) => {
|
|
69
|
+
const handler = observe(...args);
|
|
70
|
+
return handler ? (payload) => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper ? handler(payload) : payload : undefined;
|
|
71
|
+
};
|
|
72
|
+
const wrapper: Provider["streamSimple"] = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
|
|
73
|
+
pi.registerProvider(id, { ...original, api: original?.api ?? model.api, streamSimple: wrapper });
|
|
74
|
+
return () => {
|
|
75
|
+
const current = registry.getRegisteredProviderConfig(id);
|
|
76
|
+
if (current?.streamSimple !== wrapper || registry.getRegisteredNativeProvider(id)) return;
|
|
77
|
+
// registerProvider merges undefined properties: remove our layer before restore.
|
|
78
|
+
const restored = { ...current };
|
|
79
|
+
if (original?.streamSimple) restored.streamSimple = original.streamSimple;
|
|
80
|
+
else delete restored.streamSimple;
|
|
81
|
+
if (!original?.api && restored.api === model.api) delete restored.api;
|
|
82
|
+
pi.unregisterProvider(id);
|
|
83
|
+
if (Object.keys(restored).length) pi.registerProvider(id, restored);
|
|
84
|
+
};
|
|
85
|
+
}
|
package/replay.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getCurrentSystemPrompt, getCurrentTools, getDeclaredTools, normalizeContext, resolveTranscript } from "@earendil-works/pi-ai";
|
|
3
|
+
import { createGrammarToolInputProperties } from "@earendil-works/pi-ai/api/constrained-sampling";
|
|
4
|
+
import { convertResponsesMessages } from "@earendil-works/pi-ai/api/openai-responses-shared";
|
|
5
|
+
import { isObject, type JsonObject, type ResponseItem } from "./protocol";
|
|
6
|
+
|
|
7
|
+
const TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
|
8
|
+
export const MAX_SNAPSHOT_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
export const SNAPSHOT_TTL_MS = 5 * 60_000;
|
|
10
|
+
|
|
11
|
+
export function equal(left: unknown, right: unknown): boolean {
|
|
12
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Validator only. No serialized item from this function is transmitted. */
|
|
16
|
+
export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
|
|
17
|
+
const compat = model.compat as Record<string, unknown> | undefined;
|
|
18
|
+
const flag = (name: string) => compat?.[name] === true;
|
|
19
|
+
const codex = model.api === "openai-codex-responses";
|
|
20
|
+
const normalized = resolveTranscript(context, flag("supportsMidConvoSystemMessages"));
|
|
21
|
+
const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
|
|
22
|
+
return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? new Set([...TOOL_CALL_PROVIDERS, "azure-openai-responses"]) : TOOL_CALL_PROVIDERS, {
|
|
23
|
+
includeSystemPrompt: !codex,
|
|
24
|
+
grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
|
|
25
|
+
supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
|
|
26
|
+
supportsAdditionalTools: flag("supportsAdditionalTools"),
|
|
27
|
+
supportsToolSearch: flag("supportsToolSearch"),
|
|
28
|
+
toolOptions: {
|
|
29
|
+
...(codex ? { strict: null } : {}),
|
|
30
|
+
supportsStrictMode: (compat?.supportsStrictMode as boolean | undefined) ?? model.api !== "openai-responses",
|
|
31
|
+
supportsOpenAIGrammarTools: flag("supportsOpenAIGrammarTools"),
|
|
32
|
+
},
|
|
33
|
+
}) as unknown as ResponseItem[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PreparedRequest {
|
|
37
|
+
context: TranscriptContext;
|
|
38
|
+
canonical: TranscriptContext;
|
|
39
|
+
payload: JsonObject & { input: ResponseItem[] };
|
|
40
|
+
/** The portable wire input before our sole summary substitution. */
|
|
41
|
+
portableInput: ResponseItem[];
|
|
42
|
+
replacement?: { index: number; count: number };
|
|
43
|
+
createdAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function capturePreparedRequest(
|
|
47
|
+
model: Model<any>, context: TranscriptContext, canonical: TranscriptContext, payload: unknown,
|
|
48
|
+
reject: (reason: string) => void = () => {},
|
|
49
|
+
): PreparedRequest | undefined {
|
|
50
|
+
if (!isObject(payload) || !Array.isArray(payload.input) || !payload.input.every(isObject)) { reject("unsupported payload shape"); return; }
|
|
51
|
+
// Reject stateful provider requests: coverage must be the complete visible input.
|
|
52
|
+
if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
|
|
53
|
+
if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
|
|
54
|
+
const expected = serializeInput(model, context);
|
|
55
|
+
if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
|
|
56
|
+
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function promptCount(input: readonly ResponseItem[]): number {
|
|
60
|
+
let count = 0;
|
|
61
|
+
while (input[count]?.role === "system" || input[count]?.role === "developer") count++;
|
|
62
|
+
return count;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** No open/synthetic tool exchange may cross the coverage boundary. */
|
|
66
|
+
function closedTools(context: TranscriptContext): boolean {
|
|
67
|
+
const pending = new Set<string>();
|
|
68
|
+
for (const message of context.messages) {
|
|
69
|
+
if (message.role === "assistant") {
|
|
70
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") return false;
|
|
71
|
+
for (const block of message.content) if (block.type === "toolCall") {
|
|
72
|
+
if (pending.has(block.id)) return false;
|
|
73
|
+
pending.add(block.id);
|
|
74
|
+
}
|
|
75
|
+
} else if (message.role === "toolResult") {
|
|
76
|
+
if (!pending.delete(message.toolCallId)) return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return pending.size === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Only the head may differ (forced/request-only prompt); conversation mapping is exact. */
|
|
83
|
+
function mappedPrefix(snapshot: PreparedRequest, prefix: TranscriptContext): TranscriptContext | undefined {
|
|
84
|
+
if (!equal(snapshot.canonical.messages.slice(0, prefix.messages.length), prefix.messages)) return;
|
|
85
|
+
const head = prefix.messages[0]?.role === "system" ? 1 : 0;
|
|
86
|
+
if (head && snapshot.context.messages[0]?.role !== "system") return;
|
|
87
|
+
if (!equal(snapshot.context.messages.slice(head, prefix.messages.length), prefix.messages.slice(head))) return;
|
|
88
|
+
return normalizeContext({ messages: snapshot.context.messages.slice(0, prefix.messages.length) });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext, reject: (reason: string) => void = () => {}): ResponseItem[] | undefined {
|
|
92
|
+
if (Date.now() - snapshot.createdAt > SNAPSHOT_TTL_MS) { reject("snapshot expired"); return; }
|
|
93
|
+
if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
|
|
94
|
+
const mapped = mappedPrefix(snapshot, prefix);
|
|
95
|
+
if (!mapped) { reject("projected prefix differs from request context"); return; }
|
|
96
|
+
const expected = serializeInput(model, mapped, snapshot.context);
|
|
97
|
+
if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
|
|
98
|
+
const start = promptCount(expected);
|
|
99
|
+
let end = expected.length;
|
|
100
|
+
if (snapshot.replacement) {
|
|
101
|
+
if (snapshot.replacement.index < start || snapshot.replacement.index >= end) { reject("previous native slot is outside covered prefix"); return; }
|
|
102
|
+
end += snapshot.replacement.count - 1;
|
|
103
|
+
}
|
|
104
|
+
if (end <= start) { reject("empty covered prefix"); return; }
|
|
105
|
+
// Source is the prepared request, never the validator's reconstruction.
|
|
106
|
+
return structuredClone(snapshot.payload.input.slice(start, end));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Exact positional replacement, not a subsequence search or a suffix rebuild. */
|
|
110
|
+
export function substituteSummary(
|
|
111
|
+
payload: unknown, index: number, summary: ResponseItem, output: ResponseItem[],
|
|
112
|
+
): (JsonObject & { input: ResponseItem[] }) | undefined {
|
|
113
|
+
if (!isObject(payload) || !Array.isArray(payload.input) || !payload.input.every(isObject)) return;
|
|
114
|
+
if (!equal(payload.input[index], summary)) return;
|
|
115
|
+
if (payload.input.filter((item) => equal(item, summary)).length !== 1) return;
|
|
116
|
+
return { ...payload, input: [...payload.input.slice(0, index), ...structuredClone(output), ...payload.input.slice(index + 1)] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Pin the one portable-summary slot using the canonical prefix, not text search. */
|
|
120
|
+
export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
|
|
121
|
+
const mapped = mappedPrefix(snapshot, summaryPrefix);
|
|
122
|
+
if (!mapped) return;
|
|
123
|
+
const prefix = serializeInput(model, mapped, snapshot.context);
|
|
124
|
+
const index = promptCount(prefix);
|
|
125
|
+
if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
|
|
126
|
+
return index;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Changes to prompt, tools or model compatibility revoke opaque state. */
|
|
130
|
+
export function requestPolicy(model: Model<any>, snapshot: PreparedRequest): unknown {
|
|
131
|
+
return {
|
|
132
|
+
model,
|
|
133
|
+
system: getCurrentSystemPrompt(snapshot.context.messages),
|
|
134
|
+
currentTools: getCurrentTools(snapshot.context.messages),
|
|
135
|
+
declarations: getDeclaredTools(resolveTranscript(snapshot.context, (model.compat as { supportsMidConvoSystemMessages?: boolean } | undefined)?.supportsMidConvoSystemMessages).messages),
|
|
136
|
+
instructions: snapshot.payload.instructions,
|
|
137
|
+
tools: snapshot.payload.tools,
|
|
138
|
+
prompt: snapshot.portableInput.slice(0, promptCount(snapshot.portableInput)),
|
|
139
|
+
};
|
|
140
|
+
}
|