@nijaru/pi-provider-compaction 0.0.5 → 0.0.7
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 +4 -4
- package/dist/index.js +118 -66
- package/index.ts +41 -16
- package/package.json +4 -4
- package/policy.ts +15 -40
- package/provider.ts +34 -16
- package/replay.ts +22 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-provider-compaction
|
|
2
2
|
|
|
3
|
-
Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.
|
|
3
|
+
Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.3.0.
|
|
4
4
|
|
|
5
5
|
**This experimental design is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
|
|
6
6
|
|
|
@@ -38,7 +38,7 @@ Request-only suffix additions, deletions, redactions and reordering can survive
|
|
|
38
38
|
|
|
39
39
|
Version 3 stores the portable summary, native output, usage, route identity and validated prefix metadata in one compaction entry. Both generation requests contribute to usage accounting. Raw credentials, complete auth headers and request snapshots are never persisted.
|
|
40
40
|
|
|
41
|
-
Native eligibility is deliberately **session-local and short-lived**. Restart, reload, resume, fork, tree navigation, model changes, covered-history edits, and relevant prompt/tool/route changes revoke it. A credential refresh may conservatively revoke it too. Persisted native metadata alone is not authority to replay hidden content under a new runtime's privacy policy.
|
|
41
|
+
Native eligibility is deliberately **session-local and short-lived**. Restart, reload, SDK disposal, resume, fork, tree navigation, model changes, covered-history edits, and relevant prompt/tool/route changes revoke it. A credential refresh may conservatively revoke it too. Persisted native metadata alone is not authority to replay hidden content under a new runtime's privacy policy.
|
|
42
42
|
|
|
43
43
|
Dynamic privacy policies that can change what previously covered history is allowed to reveal are **not transparently supported**. A cooperating policy owner must emit `pi-provider-compaction:invalidate` before such a change; otherwise keep this extension disabled. Matching visible summary text cannot establish that hidden native state obeys a new policy.
|
|
44
44
|
|
|
@@ -60,7 +60,7 @@ An explicit generic model takes precedence, matching `pi-compactor`:
|
|
|
60
60
|
2. trusted `.pi/compaction-policy.json`;
|
|
61
61
|
3. agent `compaction-policy.json`.
|
|
62
62
|
|
|
63
|
-
An empty `models` list is not an override.
|
|
63
|
+
An empty `models` list is not an override. When `pi-compactor` is loaded, its effective selection takes precedence, including flags injected by an SDK host rather than passed on the command line.
|
|
64
64
|
|
|
65
65
|
## Local development
|
|
66
66
|
|
|
@@ -73,7 +73,7 @@ pi --extension ./dist/index.js
|
|
|
73
73
|
|
|
74
74
|
The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
|
|
75
75
|
|
|
76
|
-
Tests include real Pi 1.
|
|
76
|
+
Tests include real Pi 1.3.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
|
|
77
77
|
|
|
78
78
|
Authenticated protocol smoke tests are separate and send three small requests:
|
|
79
79
|
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// index.ts
|
|
2
2
|
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
3
|
-
import { normalizeContext as normalizeContext2 } from "@earendil-works/pi-ai";
|
|
3
|
+
import { normalizeContext as normalizeContext2, registerSessionResourceCleanup } from "@earendil-works/pi-ai";
|
|
4
4
|
import { compact, convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
|
|
6
6
|
// policy.ts
|
|
@@ -119,18 +119,21 @@ function readCompactionModelFlagFromArgv(argv = process.argv.slice(2)) {
|
|
|
119
119
|
return value;
|
|
120
120
|
}
|
|
121
121
|
function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
|
|
122
|
+
let effective;
|
|
123
|
+
pi.events.emit("pi-compactor:resolve-model-policy", {
|
|
124
|
+
context: ctx,
|
|
125
|
+
respond: (hasSelectors) => {
|
|
126
|
+
if (typeof hasSelectors === "boolean")
|
|
127
|
+
effective = hasSelectors;
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
if (effective !== undefined)
|
|
131
|
+
return effective ? { hasSelectors: true, source: "compactor" } : { hasSelectors: false };
|
|
122
132
|
const argvRaw = readCompactionModelFlagFromArgv(argv);
|
|
123
133
|
if (typeof argvRaw === "string" && argvRaw.trim()) {
|
|
124
134
|
const selector = normalizeSelector(argvRaw);
|
|
125
135
|
return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
|
|
126
136
|
}
|
|
127
|
-
try {
|
|
128
|
-
const rawFlag = pi.getFlag(COMPACTION_MODEL_FLAG);
|
|
129
|
-
if (typeof rawFlag === "string" && rawFlag.trim()) {
|
|
130
|
-
const selector = normalizeSelector(rawFlag);
|
|
131
|
-
return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
|
|
132
|
-
}
|
|
133
|
-
} catch {}
|
|
134
137
|
const agentDir = activeAgentDir(ctx);
|
|
135
138
|
const candidates = [];
|
|
136
139
|
if (isProjectPolicyTrusted(ctx, agentDir)) {
|
|
@@ -147,8 +150,14 @@ function resolveCompactionModelPolicy(pi, ctx, argv = process.argv.slice(2)) {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
// provider.ts
|
|
153
|
+
var STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
|
|
150
154
|
function optionsWithObserver(model, context, options, observe) {
|
|
151
|
-
|
|
155
|
+
let handler;
|
|
156
|
+
try {
|
|
157
|
+
handler = observe(model, context, options ?? {});
|
|
158
|
+
} catch {
|
|
159
|
+
return options;
|
|
160
|
+
}
|
|
152
161
|
if (!handler)
|
|
153
162
|
return options;
|
|
154
163
|
return {
|
|
@@ -191,15 +200,22 @@ function installObserver(pi, ctx, observe) {
|
|
|
191
200
|
const id = model.provider;
|
|
192
201
|
const native = registry.getRegisteredNativeProvider(id);
|
|
193
202
|
if (native) {
|
|
203
|
+
const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
|
|
194
204
|
const guarded = (...args) => {
|
|
205
|
+
if (!isInstalled())
|
|
206
|
+
return;
|
|
195
207
|
const handler = observe(...args);
|
|
196
|
-
return handler ? (payload) =>
|
|
208
|
+
return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
|
|
197
209
|
};
|
|
198
210
|
const wrapper = decorateProvider(native, guarded);
|
|
199
211
|
pi.registerProvider(wrapper);
|
|
200
|
-
return
|
|
201
|
-
|
|
202
|
-
|
|
212
|
+
return {
|
|
213
|
+
provider: id,
|
|
214
|
+
isInstalled,
|
|
215
|
+
remove() {
|
|
216
|
+
if (isInstalled())
|
|
217
|
+
registry.registerProvider(native);
|
|
218
|
+
}
|
|
203
219
|
};
|
|
204
220
|
}
|
|
205
221
|
const original = registry.getRegisteredProviderConfig(id);
|
|
@@ -208,27 +224,24 @@ function installObserver(pi, ctx, observe) {
|
|
|
208
224
|
if (original.api !== model.api)
|
|
209
225
|
return;
|
|
210
226
|
const delegate = original.streamSimple.bind(original);
|
|
227
|
+
const isInstalled = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
|
|
211
228
|
const guarded = (...args) => {
|
|
229
|
+
if (!isInstalled())
|
|
230
|
+
return;
|
|
212
231
|
const handler = observe(...args);
|
|
213
|
-
return handler ? (payload) =>
|
|
232
|
+
return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
|
|
214
233
|
};
|
|
215
234
|
const wrapper = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const current = registry.getRegisteredProviderConfig(id);
|
|
219
|
-
if (current?.streamSimple !== wrapper || registry.getRegisteredNativeProvider(id))
|
|
235
|
+
const remove = () => {
|
|
236
|
+
if (!isInstalled())
|
|
220
237
|
return;
|
|
221
|
-
const restored = { ...
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
else
|
|
225
|
-
delete restored.streamSimple;
|
|
226
|
-
if (!original?.api && restored.api === model.api)
|
|
227
|
-
delete restored.api;
|
|
228
|
-
pi.unregisterProvider(id);
|
|
229
|
-
if (Object.keys(restored).length)
|
|
230
|
-
pi.registerProvider(id, restored);
|
|
238
|
+
const restored = { ...registry.getRegisteredProviderConfig(id), streamSimple: original.streamSimple };
|
|
239
|
+
registry.unregisterProvider(id);
|
|
240
|
+
registry.registerProvider(id, restored);
|
|
231
241
|
};
|
|
242
|
+
Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: remove } });
|
|
243
|
+
pi.registerProvider(id, { ...original, streamSimple: wrapper });
|
|
244
|
+
return { provider: id, isInstalled, remove };
|
|
232
245
|
}
|
|
233
246
|
|
|
234
247
|
// replay.ts
|
|
@@ -624,19 +637,13 @@ function transformMessages(messages, model, normalizeToolCallId) {
|
|
|
624
637
|
const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;
|
|
625
638
|
const transformedContent = assistantMsg.content.flatMap((block) => {
|
|
626
639
|
if (block.type === "thinking") {
|
|
627
|
-
if (
|
|
628
|
-
return
|
|
629
|
-
|
|
630
|
-
if (isSameModel && block.thinkingSignature)
|
|
640
|
+
if (!isSameModel)
|
|
641
|
+
return [];
|
|
642
|
+
if (block.redacted || block.thinkingSignature)
|
|
631
643
|
return block;
|
|
632
644
|
if (!block.thinking || block.thinking.trim() === "")
|
|
633
645
|
return [];
|
|
634
|
-
|
|
635
|
-
return block;
|
|
636
|
-
return {
|
|
637
|
-
type: "text",
|
|
638
|
-
text: block.thinking
|
|
639
|
-
};
|
|
646
|
+
return block;
|
|
640
647
|
}
|
|
641
648
|
if (block.type === "text") {
|
|
642
649
|
if (isSameModel)
|
|
@@ -827,6 +834,12 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
827
834
|
});
|
|
828
835
|
};
|
|
829
836
|
const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
|
|
837
|
+
const pushConfigurationUpdate = (effort) => {
|
|
838
|
+
messages.push({
|
|
839
|
+
type: "configuration_update",
|
|
840
|
+
reasoning: { effort }
|
|
841
|
+
});
|
|
842
|
+
};
|
|
830
843
|
const compat = model.compat;
|
|
831
844
|
const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
|
832
845
|
let msgIndex = 0;
|
|
@@ -933,6 +946,9 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
933
946
|
}
|
|
934
947
|
if (output.length === 0)
|
|
935
948
|
continue;
|
|
949
|
+
if (options?.configurationUpdateEffort !== undefined && isSameModel && msg.providerThinkingLevel !== undefined) {
|
|
950
|
+
pushConfigurationUpdate(msg.providerThinkingLevel);
|
|
951
|
+
}
|
|
936
952
|
messages.push(...output);
|
|
937
953
|
} else if (msg.role === "toolResult") {
|
|
938
954
|
const [callId] = msg.toolCallId.split("|");
|
|
@@ -954,6 +970,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
954
970
|
if (!isLeadingSystemMessage)
|
|
955
971
|
msgIndex++;
|
|
956
972
|
}
|
|
973
|
+
if (options?.configurationUpdateEffort !== undefined)
|
|
974
|
+
pushConfigurationUpdate(options.configurationUpdateEffort);
|
|
957
975
|
return messages;
|
|
958
976
|
}
|
|
959
977
|
function convertResponsesTools(tools, options) {
|
|
@@ -1450,7 +1468,7 @@ var SNAPSHOT_TTL_MS = 5 * 60000;
|
|
|
1450
1468
|
function equal(left, right) {
|
|
1451
1469
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
1452
1470
|
}
|
|
1453
|
-
function serializeInput(model, context, declarations = context) {
|
|
1471
|
+
function serializeInput(model, context, declarations = context, configurationUpdateEffort) {
|
|
1454
1472
|
const compat = model.compat;
|
|
1455
1473
|
const flag = (name) => compat?.[name] === true;
|
|
1456
1474
|
const codex = model.api === "openai-codex-responses";
|
|
@@ -1458,6 +1476,7 @@ function serializeInput(model, context, declarations = context) {
|
|
|
1458
1476
|
const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
|
|
1459
1477
|
return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
|
|
1460
1478
|
includeSystemPrompt: !codex,
|
|
1479
|
+
configurationUpdateEffort,
|
|
1461
1480
|
grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
|
|
1462
1481
|
supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
|
|
1463
1482
|
supportsAdditionalTools: flag("supportsAdditionalTools"),
|
|
@@ -1482,12 +1501,15 @@ function capturePreparedRequest(model, context, canonical, payload, reject = ()
|
|
|
1482
1501
|
reject("snapshot exceeds size limit");
|
|
1483
1502
|
return;
|
|
1484
1503
|
}
|
|
1485
|
-
const
|
|
1504
|
+
const last = payload.input.at(-1);
|
|
1505
|
+
const compat = model.compat;
|
|
1506
|
+
const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true && last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string" ? last.reasoning.effort : undefined;
|
|
1507
|
+
const expected = serializeInput(model, context, context, configurationUpdateEffort);
|
|
1486
1508
|
if (!equal(expected, payload.input)) {
|
|
1487
1509
|
reject("prepared input differs from serializer");
|
|
1488
1510
|
return;
|
|
1489
1511
|
}
|
|
1490
|
-
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, createdAt: Date.now() };
|
|
1512
|
+
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
|
|
1491
1513
|
}
|
|
1492
1514
|
function promptCount(input) {
|
|
1493
1515
|
let count = 0;
|
|
@@ -1495,6 +1517,10 @@ function promptCount(input) {
|
|
|
1495
1517
|
count++;
|
|
1496
1518
|
return count;
|
|
1497
1519
|
}
|
|
1520
|
+
function serializePrefix(model, snapshot, prefix) {
|
|
1521
|
+
const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
|
|
1522
|
+
return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
|
|
1523
|
+
}
|
|
1498
1524
|
function closedTools(context) {
|
|
1499
1525
|
const pending = new Set;
|
|
1500
1526
|
for (const message of context.messages) {
|
|
@@ -1538,7 +1564,7 @@ function selectCoveredPrefix(model, snapshot, prefix, reject = () => {}) {
|
|
|
1538
1564
|
reject("projected prefix differs from request context");
|
|
1539
1565
|
return;
|
|
1540
1566
|
}
|
|
1541
|
-
const expected =
|
|
1567
|
+
const expected = serializePrefix(model, snapshot, mapped);
|
|
1542
1568
|
if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) {
|
|
1543
1569
|
reject("serialized prefix does not match prepared input");
|
|
1544
1570
|
return;
|
|
@@ -1571,7 +1597,7 @@ function summarySlot(model, snapshot, summaryPrefix) {
|
|
|
1571
1597
|
const mapped = mappedPrefix(snapshot, summaryPrefix);
|
|
1572
1598
|
if (!mapped)
|
|
1573
1599
|
return;
|
|
1574
|
-
const prefix =
|
|
1600
|
+
const prefix = serializePrefix(model, snapshot, mapped);
|
|
1575
1601
|
const index = promptCount(prefix);
|
|
1576
1602
|
if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix))
|
|
1577
1603
|
return;
|
|
@@ -1692,8 +1718,8 @@ function pi_provider_compaction_default(pi) {
|
|
|
1692
1718
|
let snapshot;
|
|
1693
1719
|
let active;
|
|
1694
1720
|
let pending;
|
|
1695
|
-
let
|
|
1696
|
-
let
|
|
1721
|
+
let observer;
|
|
1722
|
+
let unregisterCleanup;
|
|
1697
1723
|
let generation = 0;
|
|
1698
1724
|
let captureStatus = "no request observed";
|
|
1699
1725
|
let compactionStatus = "not attempted in this runtime";
|
|
@@ -1707,7 +1733,7 @@ function pi_provider_compaction_default(pi) {
|
|
|
1707
1733
|
description: "Show content-free native compaction eligibility and last attempt",
|
|
1708
1734
|
handler: async (_args, context) => {
|
|
1709
1735
|
context.ui.notify(`Provider compaction
|
|
1710
|
-
Observer: ${
|
|
1736
|
+
Observer: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}
|
|
1711
1737
|
Capture: ${captureStatus}
|
|
1712
1738
|
Last attempt: ${compactionStatus}`, "info");
|
|
1713
1739
|
}
|
|
@@ -1720,12 +1746,30 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1720
1746
|
pending = undefined;
|
|
1721
1747
|
captureStatus = "invalidated by lifecycle/history/policy change";
|
|
1722
1748
|
};
|
|
1749
|
+
const retire = () => {
|
|
1750
|
+
invalidate();
|
|
1751
|
+
const installed = observer;
|
|
1752
|
+
observer = undefined;
|
|
1753
|
+
ctx = undefined;
|
|
1754
|
+
unregisterCleanup?.();
|
|
1755
|
+
unregisterCleanup = undefined;
|
|
1756
|
+
installed?.remove();
|
|
1757
|
+
};
|
|
1723
1758
|
const generic = (context) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
1724
1759
|
const route = (model, options) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
1725
1760
|
const unchanged = (branch, length, hash) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
1726
1761
|
const observe = (model, context, options) => {
|
|
1727
1762
|
const owner = ctx;
|
|
1728
|
-
if (!owner
|
|
1763
|
+
if (!owner)
|
|
1764
|
+
return;
|
|
1765
|
+
let manager;
|
|
1766
|
+
try {
|
|
1767
|
+
manager = owner.sessionManager;
|
|
1768
|
+
} catch {
|
|
1769
|
+
retire();
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
if (options.sessionId !== manager.getSessionId())
|
|
1729
1773
|
return;
|
|
1730
1774
|
if (!supportsNativeCompaction(model)) {
|
|
1731
1775
|
captureRejected("unsupported API");
|
|
@@ -1744,10 +1788,19 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1744
1788
|
return;
|
|
1745
1789
|
}
|
|
1746
1790
|
const ticket = generation;
|
|
1747
|
-
const branch =
|
|
1791
|
+
const branch = manager.getBranch();
|
|
1748
1792
|
const branchHash = digest(branch);
|
|
1749
|
-
const canonical = transcript(
|
|
1750
|
-
const current = () =>
|
|
1793
|
+
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
1794
|
+
const current = () => {
|
|
1795
|
+
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options))
|
|
1796
|
+
return false;
|
|
1797
|
+
try {
|
|
1798
|
+
return digest(owner.sessionManager.getBranch()) === branchHash;
|
|
1799
|
+
} catch {
|
|
1800
|
+
retire();
|
|
1801
|
+
return false;
|
|
1802
|
+
}
|
|
1803
|
+
};
|
|
1751
1804
|
return async (payload) => {
|
|
1752
1805
|
if (!current())
|
|
1753
1806
|
return payload;
|
|
@@ -1811,21 +1864,26 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1811
1864
|
};
|
|
1812
1865
|
const install = (context) => {
|
|
1813
1866
|
ctx = context;
|
|
1814
|
-
if (
|
|
1867
|
+
if (observer?.provider === context.model?.provider && observer?.isInstalled())
|
|
1815
1868
|
return;
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1869
|
+
if (observer) {
|
|
1870
|
+
invalidate();
|
|
1871
|
+
observer.remove();
|
|
1872
|
+
observer = undefined;
|
|
1873
|
+
}
|
|
1819
1874
|
if (!supportsNativeCompaction(context.model))
|
|
1820
1875
|
return;
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
installedProvider = context.model.provider;
|
|
1824
|
-
captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
|
|
1876
|
+
observer = installObserver(pi, context, observe);
|
|
1877
|
+
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
1825
1878
|
};
|
|
1826
1879
|
pi.on("session_start", (_event, context) => {
|
|
1827
|
-
|
|
1880
|
+
retire();
|
|
1828
1881
|
install(context);
|
|
1882
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
1883
|
+
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
1884
|
+
if (disposedId === undefined || disposedId === sessionId)
|
|
1885
|
+
retire();
|
|
1886
|
+
});
|
|
1829
1887
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
1830
1888
|
if (latest?.summary === NATIVE_COMPACTION_SUMMARY)
|
|
1831
1889
|
context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
|
|
@@ -1840,13 +1898,7 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1840
1898
|
pi.on("session_tree", () => {
|
|
1841
1899
|
invalidate();
|
|
1842
1900
|
});
|
|
1843
|
-
pi.on("session_shutdown",
|
|
1844
|
-
invalidate();
|
|
1845
|
-
uninstall?.();
|
|
1846
|
-
uninstall = undefined;
|
|
1847
|
-
installedProvider = undefined;
|
|
1848
|
-
ctx = undefined;
|
|
1849
|
-
});
|
|
1901
|
+
pi.on("session_shutdown", retire);
|
|
1850
1902
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
|
1851
1903
|
pi.on("session_shutdown", () => {
|
|
1852
1904
|
if (typeof removeInvalidation === "function")
|
package/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
3
|
-
import { normalizeContext, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
import { normalizeContext, registerSessionResourceCleanup, type Model, type Provider, type ProviderHeaders, type ProviderStreamOptions, type SimpleStreamOptions, type TranscriptContext, type Usage } from "@earendil-works/pi-ai";
|
|
4
4
|
import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
6
|
-
import { installObserver, type ObserveRequest } from "./provider";
|
|
6
|
+
import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
7
7
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
8
8
|
import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
9
9
|
|
|
@@ -152,8 +152,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
152
152
|
let snapshot: Captured | undefined;
|
|
153
153
|
let active: Active | undefined;
|
|
154
154
|
let pending: { details: NativeCompactionDetails; capture: Captured; summary: string } | undefined;
|
|
155
|
-
let
|
|
156
|
-
let
|
|
155
|
+
let observer: InstalledObserver | undefined;
|
|
156
|
+
let unregisterCleanup: (() => void) | undefined;
|
|
157
157
|
let generation = 0;
|
|
158
158
|
let captureStatus = "no request observed";
|
|
159
159
|
let compactionStatus = "not attempted in this runtime";
|
|
@@ -162,17 +162,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
162
162
|
pi.registerCommand("provider-compaction-status", {
|
|
163
163
|
description: "Show content-free native compaction eligibility and last attempt",
|
|
164
164
|
handler: async (_args, context) => {
|
|
165
|
-
context.ui.notify(`Provider compaction\nObserver: ${
|
|
165
|
+
context.ui.notify(`Provider compaction\nObserver: ${observer?.isInstalled() ? "installed" : "unavailable for current registration"}\nCapture: ${captureStatus}\nLast attempt: ${compactionStatus}`, "info");
|
|
166
166
|
},
|
|
167
167
|
});
|
|
168
168
|
const invalidate = () => { generation++; runtimeId = randomUUID(); snapshot = undefined; active = undefined; pending = undefined; captureStatus = "invalidated by lifecycle/history/policy change"; };
|
|
169
|
+
const retire = () => {
|
|
170
|
+
invalidate();
|
|
171
|
+
const installed = observer;
|
|
172
|
+
observer = undefined; ctx = undefined;
|
|
173
|
+
unregisterCleanup?.(); unregisterCleanup = undefined;
|
|
174
|
+
installed?.remove();
|
|
175
|
+
};
|
|
169
176
|
const generic = (context: ExtensionContext) => resolveCompactionModelPolicy(pi, context).hasSelectors;
|
|
170
177
|
const route = (model: Model<any>, options: ProviderStreamOptions | SimpleStreamOptions) => routeDigest({ model, apiKey: options.apiKey, headers: options.headers, env: options.env });
|
|
171
178
|
const unchanged = (branch: readonly SessionEntry[], length: number, hash: string) => branch.length >= length && digest(branch.slice(0, length)) === hash && !branch.slice(length).some((entry) => entry.type === "context_edit");
|
|
172
179
|
|
|
173
180
|
const observe: ObserveRequest = (model, context, options) => {
|
|
174
181
|
const owner = ctx;
|
|
175
|
-
if (!owner
|
|
182
|
+
if (!owner) return;
|
|
183
|
+
let manager: ExtensionContext["sessionManager"];
|
|
184
|
+
try { manager = owner.sessionManager; }
|
|
185
|
+
catch {
|
|
186
|
+
// Bare SDK dispose() invalidates contexts without session_shutdown.
|
|
187
|
+
// Retire the owned registration before dispatching another session.
|
|
188
|
+
retire();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (options.sessionId !== manager.getSessionId()) return;
|
|
176
192
|
if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
|
|
177
193
|
if (!options.onPayload) { captureRejected("no payload callback"); return; }
|
|
178
194
|
if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
|
|
@@ -180,10 +196,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
180
196
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
181
197
|
if (!supportedRouteOptions(model, options)) { captureRejected("unsupported route overrides"); return; }
|
|
182
198
|
const ticket = generation;
|
|
183
|
-
const branch =
|
|
199
|
+
const branch = manager.getBranch();
|
|
184
200
|
const branchHash = digest(branch);
|
|
185
|
-
const canonical = transcript(
|
|
186
|
-
const current = () =>
|
|
201
|
+
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
202
|
+
const current = () => {
|
|
203
|
+
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
204
|
+
try { return digest(owner.sessionManager.getBranch()) === branchHash; }
|
|
205
|
+
catch { retire(); return false; }
|
|
206
|
+
};
|
|
187
207
|
return async (payload) => {
|
|
188
208
|
if (!current()) return payload;
|
|
189
209
|
snapshot = undefined;
|
|
@@ -234,22 +254,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
234
254
|
|
|
235
255
|
const install = (context: ExtensionContext) => {
|
|
236
256
|
ctx = context;
|
|
237
|
-
if (
|
|
238
|
-
|
|
257
|
+
if (observer?.provider === context.model?.provider && observer?.isInstalled()) return;
|
|
258
|
+
if (observer) { invalidate(); observer.remove(); observer = undefined; }
|
|
239
259
|
if (!supportsNativeCompaction(context.model)) return;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
captureStatus = uninstall ? "awaiting prepared request" : "unsupported provider registration";
|
|
260
|
+
observer = installObserver(pi, context, observe);
|
|
261
|
+
captureStatus = observer ? "awaiting prepared request" : "unsupported provider registration";
|
|
243
262
|
};
|
|
244
263
|
pi.on("session_start", (_event, context) => {
|
|
245
|
-
|
|
264
|
+
retire(); install(context);
|
|
265
|
+
const sessionId = context.sessionManager.getSessionId();
|
|
266
|
+
// AgentSession.dispose() cleans session resources even without emitting
|
|
267
|
+
// session_shutdown. The captured registry keeps owned cleanup usable.
|
|
268
|
+
unregisterCleanup = registerSessionResourceCleanup((disposedId) => {
|
|
269
|
+
if (disposedId === undefined || disposedId === sessionId) retire();
|
|
270
|
+
});
|
|
246
271
|
const latest = latestCompaction(context.sessionManager.getBranch());
|
|
247
272
|
if (latest?.summary === NATIVE_COMPACTION_SUMMARY) context.ui.notify("Legacy native compaction has no portable summary. Native replay is disabled; recover from earlier history or start a new session.", "warning");
|
|
248
273
|
});
|
|
249
274
|
pi.on("before_agent_start", (_event, context) => { install(context); });
|
|
250
275
|
pi.on("model_select", (_event, context) => { invalidate(); install(context); });
|
|
251
276
|
pi.on("session_tree", () => { invalidate(); });
|
|
252
|
-
pi.on("session_shutdown",
|
|
277
|
+
pi.on("session_shutdown", retire);
|
|
253
278
|
// Privacy-policy owners must invalidate before changing a policy that can affect
|
|
254
279
|
// hidden covered history. Arbitrary dynamic privacy compositions are unsupported.
|
|
255
280
|
const removeInvalidation = pi.events.on("pi-provider-compaction:invalidate", invalidate);
|
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.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"LICENSE"
|
|
45
45
|
],
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": "1.
|
|
48
|
-
"@earendil-works/pi-agent-core": "1.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
47
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
48
|
+
"@earendil-works/pi-agent-core": "1.3.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
50
50
|
"@types/bun": "latest",
|
|
51
51
|
"oxlint": "^1.81.0",
|
|
52
52
|
"typescript": "^6.0.3"
|
package/policy.ts
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* extension must not request a native compaction and must not replay a
|
|
8
|
-
* previously persisted native window.
|
|
9
|
-
*
|
|
10
|
-
* Pi scopes `getFlag` to flags the calling extension registered, and Pi
|
|
11
|
-
* rejects two extensions registering the same flag name. This extension
|
|
12
|
-
* therefore never registers `compaction-model` (owned by pi-compactor) and
|
|
13
|
-
* reads the shared CLI value from `process.argv` instead. The policy-file
|
|
14
|
-
* fallback mirrors pi-compactor exactly: project policy requires project
|
|
15
|
-
* trust; the agent-dir policy does not; a valid file with an empty `models`
|
|
16
|
-
* list disables the generic model. This module must stay in sync with
|
|
17
|
-
* `readModelSelectors` in pi-compactor's policy.ts (see
|
|
18
|
-
* tests/policy.test.ts, which always checks the safety contracts and also
|
|
19
|
-
* compares both implementations when the sibling checkout is present).
|
|
20
|
-
*
|
|
21
|
-
* Flag-source note (verified against Pi 0.85.1): extension flag values are
|
|
22
|
-
* populated solely from CLI unknown flags (parseArgs in cli/args.js feeds
|
|
23
|
-
* extensionFlagValues in main.js, applied by applyExtensionFlagValues in
|
|
24
|
-
* agent-session-services.js). There is no config-file flag source, so reading
|
|
25
|
-
* process.argv with Pi's exact parsing rules observes every flag value the
|
|
26
|
-
* host can deliver. A programmatic SDK host injecting extensionFlagValues
|
|
27
|
-
* without argv would not be seen here; flag scoping provides no shared read
|
|
28
|
-
* API for that path.
|
|
2
|
+
* An explicit generic model suppresses both native acquisition and replay.
|
|
3
|
+
* Ask pi-compactor for its effective policy first: only the flag's owner can
|
|
4
|
+
* read SDK-injected extensionFlagValues. The event reply is synchronous.
|
|
5
|
+
* Without a companion reply, argv and policy files retain standalone support;
|
|
6
|
+
* their trust, bounds, and empty-policy rules mirror pi-compactor's resolver.
|
|
29
7
|
*/
|
|
30
8
|
|
|
31
9
|
import {
|
|
@@ -49,7 +27,7 @@ type PolicyContext = Pick<ExtensionContext, "cwd" | "isProjectTrusted"> & {
|
|
|
49
27
|
sessionManager?: Pick<ExtensionContext["sessionManager"], "getSessionDir">;
|
|
50
28
|
};
|
|
51
29
|
|
|
52
|
-
export type CompactionModelSource = "flag" | "project-policy" | "agent-policy";
|
|
30
|
+
export type CompactionModelSource = "compactor" | "flag" | "project-policy" | "agent-policy";
|
|
53
31
|
|
|
54
32
|
export interface CompactionModelPolicy {
|
|
55
33
|
/** True when a generic compaction model takes precedence over native compaction. */
|
|
@@ -172,27 +150,24 @@ export function readCompactionModelFlagFromArgv(argv: readonly string[] = proces
|
|
|
172
150
|
|
|
173
151
|
/** Resolve whether pi-compactor's generic compaction model takes precedence. */
|
|
174
152
|
export function resolveCompactionModelPolicy(
|
|
175
|
-
pi: Pick<ExtensionAPI, "
|
|
153
|
+
pi: { events: Pick<ExtensionAPI["events"], "emit"> },
|
|
176
154
|
ctx: PolicyContext,
|
|
177
155
|
argv: readonly string[] = process.argv.slice(2),
|
|
178
156
|
): CompactionModelPolicy {
|
|
157
|
+
let effective: boolean | undefined;
|
|
158
|
+
pi.events.emit("pi-compactor:resolve-model-policy", {
|
|
159
|
+
context: ctx,
|
|
160
|
+
respond: (hasSelectors: boolean) => { if (typeof hasSelectors === "boolean") effective = hasSelectors; },
|
|
161
|
+
});
|
|
162
|
+
if (effective !== undefined) return effective ? { hasSelectors: true, source: "compactor" } : { hasSelectors: false };
|
|
163
|
+
|
|
179
164
|
const argvRaw = readCompactionModelFlagFromArgv(argv);
|
|
180
165
|
if (typeof argvRaw === "string" && argvRaw.trim()) {
|
|
181
166
|
const selector = normalizeSelector(argvRaw);
|
|
182
167
|
return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
|
|
183
168
|
}
|
|
184
|
-
// A bare
|
|
185
|
-
// pi-compactor: Pi reports "requires a value" and the policy files still apply.
|
|
169
|
+
// A bare or whitespace flag leaves policy files eligible, like pi-compactor.
|
|
186
170
|
// An oversized selector above already returned hasSelectors:false.
|
|
187
|
-
try {
|
|
188
|
-
const rawFlag = pi.getFlag(COMPACTION_MODEL_FLAG);
|
|
189
|
-
if (typeof rawFlag === "string" && rawFlag.trim()) {
|
|
190
|
-
const selector = normalizeSelector(rawFlag);
|
|
191
|
-
return selector ? { hasSelectors: true, source: "flag" } : { hasSelectors: false };
|
|
192
|
-
}
|
|
193
|
-
} catch {
|
|
194
|
-
// getFlag can throw on a stale context; fall through to policy files.
|
|
195
|
-
}
|
|
196
171
|
|
|
197
172
|
const agentDir = activeAgentDir(ctx);
|
|
198
173
|
const candidates: Array<{ path: string; source: CompactionModelSource }> = [];
|
package/provider.ts
CHANGED
|
@@ -3,8 +3,20 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
3
3
|
|
|
4
4
|
export type ObserveRequest = (model: Model<any>, context: TranscriptContext, options: StreamOptions) => ((payload: unknown) => unknown | Promise<unknown>) | undefined;
|
|
5
5
|
|
|
6
|
+
export interface InstalledObserver {
|
|
7
|
+
provider: string;
|
|
8
|
+
isInstalled(): boolean;
|
|
9
|
+
remove(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Explicit cooperation with pi-fast-mode, not permission to replace arbitrary
|
|
13
|
+
// streams: this observer delegates unchanged and can release only its own slot.
|
|
14
|
+
export const STREAM_OBSERVER = Symbol.for("@nijaru/pi-provider-compaction/stream-observer");
|
|
15
|
+
|
|
6
16
|
function optionsWithObserver<T extends StreamOptions>(model: Model<any>, context: TranscriptContext, options: T | undefined, observe: ObserveRequest): T | undefined {
|
|
7
|
-
|
|
17
|
+
let handler: ReturnType<ObserveRequest>;
|
|
18
|
+
try { handler = observe(model, context, options ?? {}); }
|
|
19
|
+
catch { return options; } // An expired or failing optional observer must not veto dispatch.
|
|
8
20
|
if (!handler) return options;
|
|
9
21
|
return {
|
|
10
22
|
...options,
|
|
@@ -40,21 +52,27 @@ export function decorateProvider(base: Provider, observe: ObserveRequest): Provi
|
|
|
40
52
|
* delegate their original callback (including /fast). Never register an effective
|
|
41
53
|
* composed provider as a native base: that loses model headers and freezes config.
|
|
42
54
|
*/
|
|
43
|
-
export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest):
|
|
55
|
+
export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe: ObserveRequest): InstalledObserver | undefined {
|
|
44
56
|
const model = ctx.model;
|
|
45
57
|
if (!model) return;
|
|
46
58
|
const registry = ctx.modelRegistry;
|
|
47
59
|
const id = model.provider;
|
|
48
60
|
const native = registry.getRegisteredNativeProvider(id);
|
|
49
61
|
if (native) {
|
|
62
|
+
const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
|
|
50
63
|
const guarded: ObserveRequest = (...args) => {
|
|
64
|
+
if (!isInstalled()) return;
|
|
51
65
|
const handler = observe(...args);
|
|
52
|
-
return handler ? (payload) =>
|
|
66
|
+
return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
|
|
53
67
|
};
|
|
54
68
|
const wrapper = decorateProvider(native, guarded);
|
|
55
69
|
pi.registerProvider(wrapper);
|
|
56
|
-
return
|
|
57
|
-
|
|
70
|
+
return {
|
|
71
|
+
provider: id, isInstalled,
|
|
72
|
+
remove() {
|
|
73
|
+
// Keep cleanup usable after bare SDK disposal invalidates the extension API.
|
|
74
|
+
if (isInstalled()) registry.registerProvider(native);
|
|
75
|
+
},
|
|
58
76
|
};
|
|
59
77
|
}
|
|
60
78
|
const original = registry.getRegisteredProviderConfig(id);
|
|
@@ -65,21 +83,21 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
|
|
|
65
83
|
// An injected legacy streamSimple overlay would also intercept full stream()
|
|
66
84
|
// calls and change reasoning semantics. Only decorate an existing stream owner.
|
|
67
85
|
const delegate = original.streamSimple.bind(original);
|
|
86
|
+
const isInstalled = () => registry.getRegisteredProviderConfig(id)?.streamSimple === wrapper && !registry.getRegisteredNativeProvider(id);
|
|
68
87
|
const guarded: ObserveRequest = (...args) => {
|
|
88
|
+
if (!isInstalled()) return;
|
|
69
89
|
const handler = observe(...args);
|
|
70
|
-
return handler ? (payload) =>
|
|
90
|
+
return handler ? (payload) => isInstalled() ? handler(payload) : payload : undefined;
|
|
71
91
|
};
|
|
72
92
|
const wrapper: Provider["streamSimple"] = (requestModel, context, options) => delegate(requestModel, context, optionsWithObserver(requestModel, context, options, guarded));
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const current = registry.getRegisteredProviderConfig(id);
|
|
76
|
-
if (current?.streamSimple !== wrapper || registry.getRegisteredNativeProvider(id)) return;
|
|
93
|
+
const remove = () => {
|
|
94
|
+
if (!isInstalled()) return;
|
|
77
95
|
// registerProvider merges undefined properties: remove our layer before restore.
|
|
78
|
-
const restored = { ...
|
|
79
|
-
|
|
80
|
-
|
|
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);
|
|
96
|
+
const restored = { ...registry.getRegisteredProviderConfig(id)!, streamSimple: original.streamSimple };
|
|
97
|
+
registry.unregisterProvider(id);
|
|
98
|
+
registry.registerProvider(id, restored);
|
|
84
99
|
};
|
|
100
|
+
Object.defineProperty(wrapper, STREAM_OBSERVER, { value: { delegate: original.streamSimple, release: remove } });
|
|
101
|
+
pi.registerProvider(id, { ...original, streamSimple: wrapper });
|
|
102
|
+
return { provider: id, isInstalled, remove };
|
|
85
103
|
}
|
package/replay.ts
CHANGED
|
@@ -14,7 +14,7 @@ export function equal(left: unknown, right: unknown): boolean {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** Validator only. No serialized item from this function is transmitted. */
|
|
17
|
-
export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
|
|
17
|
+
export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context, configurationUpdateEffort?: string): ResponseItem[] {
|
|
18
18
|
const compat = model.compat as Record<string, unknown> | undefined;
|
|
19
19
|
const flag = (name: string) => compat?.[name] === true;
|
|
20
20
|
const codex = model.api === "openai-codex-responses";
|
|
@@ -22,6 +22,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
|
|
|
22
22
|
const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
|
|
23
23
|
return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
|
|
24
24
|
includeSystemPrompt: !codex,
|
|
25
|
+
configurationUpdateEffort,
|
|
25
26
|
grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
|
|
26
27
|
supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
|
|
27
28
|
supportsAdditionalTools: flag("supportsAdditionalTools"),
|
|
@@ -40,6 +41,8 @@ export interface PreparedRequest {
|
|
|
40
41
|
payload: JsonObject & { input: ResponseItem[] };
|
|
41
42
|
/** The portable wire input before our sole summary substitution. */
|
|
42
43
|
portableInput: ResponseItem[];
|
|
44
|
+
/** The final request-only effort update; historical efforts come from the transcript. */
|
|
45
|
+
configurationUpdateEffort?: string;
|
|
43
46
|
replacement?: { index: number; count: number };
|
|
44
47
|
createdAt: number;
|
|
45
48
|
}
|
|
@@ -52,9 +55,16 @@ export function capturePreparedRequest(
|
|
|
52
55
|
// Reject stateful provider requests: coverage must be the complete visible input.
|
|
53
56
|
if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
|
|
54
57
|
if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
|
|
55
|
-
|
|
58
|
+
// Read the actual prepared request's mode, not provider/auth guesses. Exact conversion
|
|
59
|
+
// below still validates every historical update, its position and its complete shape.
|
|
60
|
+
const last = payload.input.at(-1);
|
|
61
|
+
const compat = model.compat as Record<string, unknown> | undefined;
|
|
62
|
+
const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true &&
|
|
63
|
+
last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string"
|
|
64
|
+
? last.reasoning.effort : undefined;
|
|
65
|
+
const expected = serializeInput(model, context, context, configurationUpdateEffort);
|
|
56
66
|
if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
|
|
57
|
-
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
|
|
67
|
+
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function promptCount(input: readonly ResponseItem[]): number {
|
|
@@ -63,6 +73,13 @@ function promptCount(input: readonly ResponseItem[]): number {
|
|
|
63
73
|
return count;
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
function serializePrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext): ResponseItem[] {
|
|
77
|
+
const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
|
|
78
|
+
// The converter appends the next reply's effort even to a partial transcript.
|
|
79
|
+
// It is not historical coverage and must remain in the live suffix on replay.
|
|
80
|
+
return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
/** No open/synthetic tool exchange may cross the coverage boundary. */
|
|
67
84
|
function closedTools(context: TranscriptContext): boolean {
|
|
68
85
|
const pending = new Set<string>();
|
|
@@ -94,7 +111,7 @@ export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest
|
|
|
94
111
|
if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
|
|
95
112
|
const mapped = mappedPrefix(snapshot, prefix);
|
|
96
113
|
if (!mapped) { reject("projected prefix differs from request context"); return; }
|
|
97
|
-
const expected =
|
|
114
|
+
const expected = serializePrefix(model, snapshot, mapped);
|
|
98
115
|
if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
|
|
99
116
|
const start = promptCount(expected);
|
|
100
117
|
let end = expected.length;
|
|
@@ -121,7 +138,7 @@ export function substituteSummary(
|
|
|
121
138
|
export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
|
|
122
139
|
const mapped = mappedPrefix(snapshot, summaryPrefix);
|
|
123
140
|
if (!mapped) return;
|
|
124
|
-
const prefix =
|
|
141
|
+
const prefix = serializePrefix(model, snapshot, mapped);
|
|
125
142
|
const index = promptCount(prefix);
|
|
126
143
|
if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
|
|
127
144
|
return index;
|