@oh-my-pi/pi-agent-core 18.6.0 → 18.6.3
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 +29 -0
- package/dist/types/agent.d.ts +12 -5
- package/dist/types/compaction/anthropic.d.ts +14 -8
- package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
- package/dist/types/compaction/compaction.d.ts +14 -0
- package/dist/types/compaction/entries.d.ts +2 -2
- package/dist/types/compaction/openai.d.ts +14 -2
- package/dist/types/image-tokens.d.ts +29 -0
- package/dist/types/tokenizer.d.ts +9 -6
- package/package.json +8 -8
- package/src/agent.ts +77 -17
- package/src/compaction/anthropic.ts +30 -12
- package/src/compaction/compaction-v2-streaming.ts +13 -15
- package/src/compaction/compaction.ts +127 -31
- package/src/compaction/entries.ts +2 -2
- package/src/compaction/openai.ts +63 -28
- package/src/image-tokens.ts +77 -0
- package/src/tokenizer.ts +24 -17
|
@@ -77,6 +77,8 @@ export interface CompactionV2Usage {
|
|
|
77
77
|
export interface CompactionV2Request {
|
|
78
78
|
body: OpenAICodexCompactionBody;
|
|
79
79
|
input: unknown[];
|
|
80
|
+
/** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
|
|
81
|
+
retainedUserItems: unknown[];
|
|
80
82
|
retainedMessageBudget: number;
|
|
81
83
|
sessionId?: string;
|
|
82
84
|
promptCacheKey?: string;
|
|
@@ -190,6 +192,7 @@ export function buildCompactionV2Request(
|
|
|
190
192
|
sessionId?: string;
|
|
191
193
|
promptCacheKey?: string;
|
|
192
194
|
retainedMessageBudget?: number;
|
|
195
|
+
retainedUserItems?: unknown[];
|
|
193
196
|
},
|
|
194
197
|
): CompactionV2Request {
|
|
195
198
|
const cacheOptions = { sessionId: options?.sessionId, promptCacheKey: options?.promptCacheKey };
|
|
@@ -223,12 +226,14 @@ export function buildCompactionV2RequestFromBody(
|
|
|
223
226
|
sessionId?: string;
|
|
224
227
|
promptCacheKey?: string;
|
|
225
228
|
retainedMessageBudget?: number;
|
|
229
|
+
retainedUserItems?: unknown[];
|
|
226
230
|
},
|
|
227
231
|
): CompactionV2Request {
|
|
228
232
|
const input = Array.isArray(body.input) ? body.input : [];
|
|
229
233
|
return {
|
|
230
234
|
body: { ...body, model: resolveCompactionV2Model(model), input },
|
|
231
235
|
input,
|
|
236
|
+
retainedUserItems: options?.retainedUserItems ?? input,
|
|
232
237
|
retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
|
|
233
238
|
sessionId: options?.sessionId,
|
|
234
239
|
promptCacheKey: options?.promptCacheKey,
|
|
@@ -536,7 +541,7 @@ function finishCompactionV2Collection(
|
|
|
536
541
|
|
|
537
542
|
const compactionItem = state.compactionItems[0];
|
|
538
543
|
const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
|
|
539
|
-
request.
|
|
544
|
+
request.retainedUserItems,
|
|
540
545
|
compactionItem,
|
|
541
546
|
request.retainedMessageBudget,
|
|
542
547
|
);
|
|
@@ -665,15 +670,14 @@ function isRetryableCompactionError(error: Error): boolean {
|
|
|
665
670
|
// Replacement History
|
|
666
671
|
// ============================================================================
|
|
667
672
|
|
|
668
|
-
/** Build Codex-style V2 replacement history from
|
|
673
|
+
/** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
|
|
669
674
|
export function buildCompactionV2ReplacementHistory(
|
|
670
675
|
input: unknown[],
|
|
671
676
|
compactionItem: Record<string, unknown>,
|
|
672
677
|
retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
673
678
|
): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
|
|
674
679
|
const retained = input.filter(
|
|
675
|
-
(item): item is Record<string, unknown> =>
|
|
676
|
-
isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
|
|
680
|
+
(item): item is Record<string, unknown> => isRecord(item) && isRetainedUserMessageForCompactionV2(item),
|
|
677
681
|
);
|
|
678
682
|
const replacementHistory = truncateRetainedMessagesForCompactionV2(
|
|
679
683
|
retained,
|
|
@@ -684,17 +688,11 @@ export function buildCompactionV2ReplacementHistory(
|
|
|
684
688
|
return { replacementHistory, retainedImageCount };
|
|
685
689
|
}
|
|
686
690
|
|
|
687
|
-
function
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
function shouldKeepCompactionV2HistoryItem(item: Record<string, unknown>): boolean {
|
|
694
|
-
if (item.type !== "message") return item.type === "compaction";
|
|
695
|
-
const role = stringField(item, "role");
|
|
696
|
-
if (role !== "user") return false;
|
|
697
|
-
return !isContextualUserMessage(item);
|
|
691
|
+
function isRetainedUserMessageForCompactionV2(item: Record<string, unknown>): boolean {
|
|
692
|
+
// Responses input messages may omit `type`: omp serializes turns as
|
|
693
|
+
// `{ role, content }`, which the API reads as `type: "message"`.
|
|
694
|
+
const isMessage = item.type === "message" || (item.type === undefined && typeof item.role === "string");
|
|
695
|
+
return isMessage && item.role === "user" && !isContextualUserMessage(item);
|
|
698
696
|
}
|
|
699
697
|
|
|
700
698
|
function isContextualUserMessage(item: Record<string, unknown>): boolean {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
type AnthropicCompactionFiles,
|
|
9
10
|
type Api,
|
|
10
11
|
type ApiKey,
|
|
11
12
|
type AssistantMessage,
|
|
@@ -676,6 +677,24 @@ export interface SummaryOptions {
|
|
|
676
677
|
initiatorOverride?: MessageAttribution;
|
|
677
678
|
metadata?: Record<string, unknown>;
|
|
678
679
|
convertToLlm?: ConvertToLlm;
|
|
680
|
+
/**
|
|
681
|
+
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
682
|
+
* `summarized`. Anthropic on-demand compaction sends it so retained signed
|
|
683
|
+
* thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
|
|
684
|
+
* `tools` and `convertToLlm`.
|
|
685
|
+
*/
|
|
686
|
+
buildProviderContext?: (
|
|
687
|
+
summarized: AgentMessage[],
|
|
688
|
+
retained: AgentMessage[],
|
|
689
|
+
signal?: AbortSignal,
|
|
690
|
+
) => Promise<Context>;
|
|
691
|
+
/**
|
|
692
|
+
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
693
|
+
* next to the compaction item. Defaults to `role === "user"`; hosts whose
|
|
694
|
+
* user-initiated turns also arrive as custom messages (e.g. skill invocations)
|
|
695
|
+
* widen it.
|
|
696
|
+
*/
|
|
697
|
+
isUserAuthored?: (message: AgentMessage) => boolean;
|
|
679
698
|
/**
|
|
680
699
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
681
700
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
@@ -1612,6 +1631,8 @@ export async function compact(
|
|
|
1612
1631
|
initiatorOverride: options?.initiatorOverride,
|
|
1613
1632
|
metadata: options?.metadata,
|
|
1614
1633
|
convertToLlm: options?.convertToLlm,
|
|
1634
|
+
buildProviderContext: options?.buildProviderContext,
|
|
1635
|
+
isUserAuthored: options?.isUserAuthored,
|
|
1615
1636
|
telemetry: options?.telemetry,
|
|
1616
1637
|
// Honor /model thinking selection on every fan-out summarizer.
|
|
1617
1638
|
// Without this propagation, generateSummary / generateTurnPrefixSummary
|
|
@@ -1675,7 +1696,17 @@ export async function compact(
|
|
|
1675
1696
|
previousRemoteCompaction?.provider === model.provider
|
|
1676
1697
|
? previousRemoteCompaction.replacementHistory
|
|
1677
1698
|
: undefined;
|
|
1678
|
-
const
|
|
1699
|
+
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1700
|
+
const messages = convertToLlm(remoteMessages);
|
|
1701
|
+
// Replacement history keeps what the user wrote. Summaries, archive
|
|
1702
|
+
// migrations, and custom/hook messages can serialize as user-role items
|
|
1703
|
+
// too, so pick the user's own messages before serialization erases that,
|
|
1704
|
+
// then serialize each one exactly as the request does.
|
|
1705
|
+
const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
|
|
1706
|
+
const userMessages = convertToLlm(
|
|
1707
|
+
[...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
|
|
1708
|
+
);
|
|
1709
|
+
const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
|
|
1679
1710
|
const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
|
|
1680
1711
|
let codexBody: OpenAICodexCompactionBody | undefined;
|
|
1681
1712
|
let remoteHistory: Array<Record<string, unknown>>;
|
|
@@ -1715,8 +1746,19 @@ export async function compact(
|
|
|
1715
1746
|
}
|
|
1716
1747
|
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1717
1748
|
codexBody.input = remoteHistory;
|
|
1749
|
+
for (const message of userMessages) {
|
|
1750
|
+
const userBody = await buildTransformedCodexRequestBody(
|
|
1751
|
+
model,
|
|
1752
|
+
{ messages: [message] },
|
|
1753
|
+
{ responsesLite: model.useResponsesLite },
|
|
1754
|
+
);
|
|
1755
|
+
retainedUserItems.push(...(userBody.input ?? []));
|
|
1756
|
+
}
|
|
1718
1757
|
} else {
|
|
1719
1758
|
remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
|
|
1759
|
+
for (const message of userMessages) {
|
|
1760
|
+
retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
|
|
1761
|
+
}
|
|
1720
1762
|
}
|
|
1721
1763
|
if (remoteHistory.length > 0) {
|
|
1722
1764
|
try {
|
|
@@ -1754,6 +1796,7 @@ export async function compact(
|
|
|
1754
1796
|
sessionId: summaryOptions.sessionId,
|
|
1755
1797
|
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1756
1798
|
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1799
|
+
retainedUserItems,
|
|
1757
1800
|
};
|
|
1758
1801
|
const request = codexBody
|
|
1759
1802
|
? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
|
|
@@ -1781,11 +1824,17 @@ export async function compact(
|
|
|
1781
1824
|
// summarization" and keep compaction running on an aborted signal.
|
|
1782
1825
|
if (signal?.aborted) throw err;
|
|
1783
1826
|
nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
model
|
|
1787
|
-
|
|
1788
|
-
|
|
1827
|
+
// Claim the V1 fallback only when the V1 block below will run.
|
|
1828
|
+
logger.warn(
|
|
1829
|
+
shouldUseOpenAiRemoteCompaction(model)
|
|
1830
|
+
? "OpenAI V2 remote compaction failed, falling back to V1 remote compaction"
|
|
1831
|
+
: "OpenAI V2 remote compaction failed",
|
|
1832
|
+
{
|
|
1833
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1834
|
+
model: model.id,
|
|
1835
|
+
provider: model.provider,
|
|
1836
|
+
},
|
|
1837
|
+
);
|
|
1789
1838
|
}
|
|
1790
1839
|
}
|
|
1791
1840
|
}
|
|
@@ -1848,32 +1897,40 @@ export async function compact(
|
|
|
1848
1897
|
let nativeSignature: string | undefined;
|
|
1849
1898
|
let nativeFirstKeptEntryId = firstKeptEntryId;
|
|
1850
1899
|
let nativeUsedTokens: number | undefined;
|
|
1900
|
+
let nativeRetainedFiles: AnthropicCompactionFiles[] | undefined;
|
|
1851
1901
|
if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
|
|
1852
1902
|
const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
|
|
1853
1903
|
// Lead with the previous summary as the live context renders it:
|
|
1854
1904
|
// natively when this provider wrote it, as text otherwise.
|
|
1855
1905
|
// A prior snapcompact archive is already merged into that summary
|
|
1856
|
-
// text.
|
|
1906
|
+
// text. Like the live context, the summary keeps its commit time and
|
|
1907
|
+
// predates its rewrite marker before all replayed messages, so
|
|
1857
1908
|
// retained thinking stays bound to the original prefix.
|
|
1858
1909
|
const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
|
|
1859
|
-
const previousSummaryAt =
|
|
1860
|
-
firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
|
|
1861
1910
|
const previousSummaryMessage = previousSummaryForCompaction
|
|
1862
|
-
? createCompactionSummaryMessage(
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
:
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1911
|
+
? createCompactionSummaryMessage(
|
|
1912
|
+
previousSummaryForCompaction,
|
|
1913
|
+
tokensBefore,
|
|
1914
|
+
preparation.previousSummaryTimestamp ?? new Date().toISOString(),
|
|
1915
|
+
{
|
|
1916
|
+
historyRewriteAt: firstReplayed !== undefined ? firstReplayed.timestamp - 1 : undefined,
|
|
1917
|
+
providerPayload:
|
|
1918
|
+
previousNative?.provider === model.provider
|
|
1919
|
+
? {
|
|
1920
|
+
type: "anthropicCompaction",
|
|
1921
|
+
provider: previousNative.provider,
|
|
1922
|
+
content: previousNative.content,
|
|
1923
|
+
...(previousNative.signature ? { signature: previousNative.signature } : {}),
|
|
1924
|
+
...(previousNative.encryptedContent
|
|
1925
|
+
? { encryptedContent: previousNative.encryptedContent }
|
|
1926
|
+
: {}),
|
|
1927
|
+
...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
|
|
1928
|
+
...(previousNative.retainedFiles ? { retainedFiles: previousNative.retainedFiles } : {}),
|
|
1929
|
+
...(previousNative.exactTail ? { exactTail: true as const } : {}),
|
|
1930
|
+
}
|
|
1931
|
+
: undefined,
|
|
1932
|
+
},
|
|
1933
|
+
)
|
|
1877
1934
|
: undefined;
|
|
1878
1935
|
const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
|
|
1879
1936
|
const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
|
|
@@ -1891,20 +1948,57 @@ export async function compact(
|
|
|
1891
1948
|
? firstKeptEntryId
|
|
1892
1949
|
: (recentEntryIds?.[safeCut - originalCut] ?? "");
|
|
1893
1950
|
for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
|
|
1894
|
-
const
|
|
1951
|
+
const summarizedMessages = [
|
|
1895
1952
|
...(previousSummaryMessage ? [previousSummaryMessage] : []),
|
|
1896
1953
|
...allMessages.slice(0, safeCut),
|
|
1897
|
-
]
|
|
1954
|
+
];
|
|
1955
|
+
// Earlier file metadata replays before the first message created after
|
|
1956
|
+
// its summary. Metadata due inside the new retained tail stays out of the
|
|
1957
|
+
// request, and the new summary keeps replaying it. So does metadata due
|
|
1958
|
+
// right at the cut when the first kept message is a user-side turn: live
|
|
1959
|
+
// requests sent it merged into that turn, and ending the request with it
|
|
1960
|
+
// would merge the summarized range into the kept turn instead, which
|
|
1961
|
+
// invalidates the kept thinking.
|
|
1962
|
+
const firstRetained = allMessages[safeCut];
|
|
1963
|
+
const filesDueBefore =
|
|
1964
|
+
firstRetained === undefined
|
|
1965
|
+
? undefined
|
|
1966
|
+
: firstRetained.role === "assistant"
|
|
1967
|
+
? firstRetained.timestamp
|
|
1968
|
+
: (allMessages.slice(0, safeCut).findLast(message => message.role !== "toolResult")?.timestamp ??
|
|
1969
|
+
Number.NEGATIVE_INFINITY);
|
|
1970
|
+
const previousPayload = previousSummaryMessage?.providerPayload;
|
|
1971
|
+
if (
|
|
1972
|
+
filesDueBefore !== undefined &&
|
|
1973
|
+
previousSummaryMessage !== undefined &&
|
|
1974
|
+
previousPayload?.type === "anthropicCompaction" &&
|
|
1975
|
+
previousPayload.exactTail
|
|
1976
|
+
) {
|
|
1977
|
+
const carried = [
|
|
1978
|
+
...(previousPayload.retainedFiles ?? []),
|
|
1979
|
+
...(previousPayload.filesText
|
|
1980
|
+
? [{ text: previousPayload.filesText, after: previousSummaryMessage.timestamp }]
|
|
1981
|
+
: []),
|
|
1982
|
+
].filter(files => files.after >= filesDueBefore);
|
|
1983
|
+
if (carried.length > 0) nativeRetainedFiles = carried;
|
|
1984
|
+
}
|
|
1898
1985
|
try {
|
|
1986
|
+
// The live turn's own system prompt, wire tools and transformed
|
|
1987
|
+
// history when the host can build them: kept thinking remains valid
|
|
1988
|
+
// only when the summarized prefix matches what was sent byte for byte.
|
|
1989
|
+
const context = summaryOptions.buildProviderContext
|
|
1990
|
+
? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
|
|
1991
|
+
: {
|
|
1992
|
+
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1993
|
+
messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
|
|
1994
|
+
tools: summaryOptions.tools,
|
|
1995
|
+
};
|
|
1899
1996
|
const remote = await requestAnthropicNativeCompaction(
|
|
1900
1997
|
model,
|
|
1901
1998
|
apiKey,
|
|
1902
1999
|
{
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1906
|
-
messages,
|
|
1907
|
-
tools: summaryOptions.tools,
|
|
2000
|
+
context,
|
|
2001
|
+
filesDueBefore,
|
|
1908
2002
|
instructions: buildAnthropicCompactionInstructions(
|
|
1909
2003
|
summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
|
|
1910
2004
|
customInstructions,
|
|
@@ -2026,6 +2120,8 @@ export async function compact(
|
|
|
2026
2120
|
content: nativeSummary,
|
|
2027
2121
|
...(nativeSignature ? { signature: nativeSignature } : {}),
|
|
2028
2122
|
...(filesText ? { filesText } : {}),
|
|
2123
|
+
...(nativeRetainedFiles ? { retainedFiles: nativeRetainedFiles } : {}),
|
|
2124
|
+
exactTail: true,
|
|
2029
2125
|
model: model.id,
|
|
2030
2126
|
usedTokens: nativeUsedTokens,
|
|
2031
2127
|
});
|
|
@@ -101,8 +101,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
|
101
101
|
|
|
102
102
|
export interface SessionInitEntry extends SessionEntryBase {
|
|
103
103
|
type: "session_init";
|
|
104
|
-
/**
|
|
105
|
-
systemPrompt: string;
|
|
104
|
+
/** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
|
|
105
|
+
systemPrompt: string[] | string;
|
|
106
106
|
/** Initial task/user message */
|
|
107
107
|
task: string;
|
|
108
108
|
/** Tools available to the agent */
|
package/src/compaction/openai.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
OPENAI_HEADERS,
|
|
54
54
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
55
55
|
import { $env, isRecord, logger, prompt, ptree, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
56
|
+
import { dataUrlImageSize, estimateImageTokens } from "../image-tokens";
|
|
56
57
|
import { Tokenizer } from "../tokenizer";
|
|
57
58
|
import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
|
|
58
59
|
import { prepareBedrockCompactionRequest } from "./bedrock";
|
|
@@ -80,7 +81,8 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 300_000;
|
|
|
80
81
|
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
81
82
|
|
|
82
83
|
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
83
|
-
|
|
84
|
+
/** Per-`input_image` record estimates; trim re-probes share untouched items by reference. */
|
|
85
|
+
const remoteImageTokenCache = new WeakMap<Record<string, unknown>, number>();
|
|
84
86
|
const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
|
|
85
87
|
|
|
86
88
|
interface NormalizedEstimateValue {
|
|
@@ -88,12 +90,12 @@ interface NormalizedEstimateValue {
|
|
|
88
90
|
imageTokens: number;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
|
|
93
|
+
function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
|
|
92
94
|
if (Array.isArray(value)) {
|
|
93
95
|
const normalized: unknown[] = [];
|
|
94
96
|
let imageTokens = 0;
|
|
95
97
|
for (const item of value) {
|
|
96
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
98
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
97
99
|
normalized.push(result.value);
|
|
98
100
|
imageTokens += result.imageTokens;
|
|
99
101
|
}
|
|
@@ -103,10 +105,16 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
103
105
|
|
|
104
106
|
const record = value as Record<string, unknown>;
|
|
105
107
|
if (record.type === "input_image") {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
108
|
+
let imageTokens = remoteImageTokenCache.get(record);
|
|
109
|
+
if (imageTokens === undefined) {
|
|
110
|
+
const detail = record.detail;
|
|
111
|
+
imageTokens = estimateImageTokens(
|
|
112
|
+
typeof record.image_url === "string" ? dataUrlImageSize(record.image_url) : null,
|
|
113
|
+
detail === "low" || detail === "high" || detail === "original" || detail === "auto" ? detail : undefined,
|
|
114
|
+
);
|
|
115
|
+
remoteImageTokenCache.set(record, imageTokens);
|
|
116
|
+
}
|
|
117
|
+
return { value: { ...record, image_url: "<image>" }, imageTokens };
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
const normalized: Record<string, unknown> = {};
|
|
@@ -115,20 +123,35 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
115
123
|
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
116
124
|
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
117
125
|
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
118
|
-
if (key === "encrypted_content" && typeof item === "string") continue;
|
|
119
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
126
|
+
if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
|
|
127
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
120
128
|
normalized[key] = result.value;
|
|
121
129
|
imageTokens += result.imageTokens;
|
|
122
130
|
}
|
|
123
131
|
return { value: normalized, imageTokens };
|
|
124
132
|
}
|
|
125
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
136
|
+
* count at their image estimate rather than as base64 text; everything else,
|
|
137
|
+
* opaque compaction state included, counts as serialized.
|
|
138
|
+
*/
|
|
139
|
+
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
140
|
+
const normalized = normalizeRemoteCompactionEstimateValue(items, false);
|
|
141
|
+
const serialized = stringifyJson(normalized.value);
|
|
142
|
+
return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
export interface TrimRemoteCompactionInputResult {
|
|
127
146
|
input: Array<Record<string, unknown>>;
|
|
128
147
|
rewrittenOutputs: number;
|
|
129
148
|
estimatedTokensBefore: number;
|
|
130
149
|
estimatedTokensAfter: number;
|
|
131
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Whether `input` may be sent; false means it must not be. True when the
|
|
152
|
+
* full estimate fits, or when only the image estimate pushes it over (image
|
|
153
|
+
* pricing is a guess; the provider's own accounting decides).
|
|
154
|
+
*/
|
|
132
155
|
fits: boolean;
|
|
133
156
|
}
|
|
134
157
|
|
|
@@ -138,15 +161,17 @@ interface RemoteCompactionBudgetProbe {
|
|
|
138
161
|
tokens: number;
|
|
139
162
|
/** Whether the request fits the window. Always true when no window is known. */
|
|
140
163
|
fits: boolean;
|
|
164
|
+
/** Whether the request minus its image estimate fits the window. */
|
|
165
|
+
textFits: boolean;
|
|
141
166
|
}
|
|
142
167
|
|
|
143
168
|
/**
|
|
144
|
-
* Cheap-first sizing of a remote-compaction request. Images
|
|
145
|
-
*
|
|
146
|
-
* tokenizer; opaque `encrypted_content` payloads are excluded. The
|
|
147
|
-
* transcript is then probed with {@link Tokenizer.checkTokenBudget},
|
|
148
|
-
* pays for an exact count when the byte bound cannot already prove
|
|
149
|
-
* fits.
|
|
169
|
+
* Cheap-first sizing of a remote-compaction request. Images (priced from their
|
|
170
|
+
* dimensions) and the request frame come off the budget rather than through
|
|
171
|
+
* the tokenizer; opaque `encrypted_content` payloads are excluded. The
|
|
172
|
+
* serialized transcript is then probed with {@link Tokenizer.checkTokenBudget},
|
|
173
|
+
* which only pays for an exact count when the byte bound cannot already prove
|
|
174
|
+
* the request fits.
|
|
150
175
|
*/
|
|
151
176
|
function probeRemoteCompactionInputBudget(
|
|
152
177
|
input: Array<Record<string, unknown>>,
|
|
@@ -159,10 +184,15 @@ function probeRemoteCompactionInputBudget(
|
|
|
159
184
|
const serialized = stringifyJson(normalized.value) ?? "";
|
|
160
185
|
const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
161
186
|
if (!contextWindow || contextWindow <= 0) {
|
|
162
|
-
return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true };
|
|
187
|
+
return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true, textFits: true };
|
|
163
188
|
}
|
|
164
189
|
const budget = tokenizer.checkTokenBudget(serialized, Math.max(0, contextWindow - flatTokens));
|
|
165
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
tokens: budget.tokens + flatTokens,
|
|
192
|
+
fits: budget.fits,
|
|
193
|
+
// A failed check carries the exact text count (or its byte upper bound).
|
|
194
|
+
textFits: budget.fits || budget.tokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS <= contextWindow,
|
|
195
|
+
};
|
|
166
196
|
}
|
|
167
197
|
|
|
168
198
|
function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
@@ -192,7 +222,9 @@ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
|
|
|
192
222
|
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
193
223
|
* remote compaction request beyond the model window. Replacing only those
|
|
194
224
|
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
195
|
-
* matching Codex's recovery path for oversized tool turns.
|
|
225
|
+
* matching Codex's recovery path for oversized tool turns. A request whose text
|
|
226
|
+
* fits but whose image estimate does not is still sendable: refusing it on an
|
|
227
|
+
* image guess would skip native compaction the provider would have accepted.
|
|
196
228
|
*/
|
|
197
229
|
export function trimRemoteCompactionInputToContextWindow(
|
|
198
230
|
input: Array<Record<string, unknown>>,
|
|
@@ -226,22 +258,25 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
226
258
|
after = probeRemoteCompactionInputBudget(rewrittenInput, tokenizer, instructions, tools, contextWindow);
|
|
227
259
|
}
|
|
228
260
|
|
|
229
|
-
|
|
261
|
+
// Rewrites that reach a full fit are kept. When they cannot, and the text
|
|
262
|
+
// already fit, the overflow is image-only: rewriting cannot shrink the image
|
|
263
|
+
// estimate, so send the untouched input rather than discard tool output.
|
|
264
|
+
if (rewrittenInput && (after.fits || (!before.textFits && after.textFits))) {
|
|
230
265
|
return {
|
|
231
|
-
input,
|
|
232
|
-
rewrittenOutputs
|
|
266
|
+
input: rewrittenInput,
|
|
267
|
+
rewrittenOutputs,
|
|
233
268
|
estimatedTokensBefore: before.tokens,
|
|
234
|
-
estimatedTokensAfter:
|
|
235
|
-
fits:
|
|
269
|
+
estimatedTokensAfter: after.tokens,
|
|
270
|
+
fits: true,
|
|
236
271
|
};
|
|
237
272
|
}
|
|
238
273
|
|
|
239
274
|
return {
|
|
240
|
-
input
|
|
241
|
-
rewrittenOutputs,
|
|
275
|
+
input,
|
|
276
|
+
rewrittenOutputs: 0,
|
|
242
277
|
estimatedTokensBefore: before.tokens,
|
|
243
|
-
estimatedTokensAfter:
|
|
244
|
-
fits:
|
|
278
|
+
estimatedTokensAfter: before.tokens,
|
|
279
|
+
fits: before.textFits,
|
|
245
280
|
};
|
|
246
281
|
}
|
|
247
282
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
type ImageSize,
|
|
4
|
+
type ImageTokenization,
|
|
5
|
+
imageTokens,
|
|
6
|
+
resolveImageTokenization,
|
|
7
|
+
} from "@oh-my-pi/pi-catalog/compat/image-tokenization";
|
|
8
|
+
import { parseImageMetadata } from "@oh-my-pi/pi-utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dimension-based image token estimates shared by the local context counter
|
|
12
|
+
* ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
|
|
13
|
+
* Both sides must price the same image the same way: when they disagreed
|
|
14
|
+
* (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
|
|
15
|
+
* stayed under the local compaction trigger yet was refused as over-window by
|
|
16
|
+
* the remote probe.
|
|
17
|
+
*
|
|
18
|
+
* Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
|
|
19
|
+
* 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
|
|
20
|
+
* which is also a close upper estimate for other providers once omp has
|
|
21
|
+
* downscaled the image (≤1568px by default).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
|
|
25
|
+
export type ImageDetail = ImageContent["detail"];
|
|
26
|
+
|
|
27
|
+
export type { ImageSize };
|
|
28
|
+
|
|
29
|
+
// Larger than every detail level's pixel limit, so an unknown size charges
|
|
30
|
+
// the level's full patch budget.
|
|
31
|
+
const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
|
|
32
|
+
|
|
33
|
+
let wireRule: ImageTokenization | undefined;
|
|
34
|
+
|
|
35
|
+
/** The OpenAI Responses wire's image rule, resolved from the catalog once. */
|
|
36
|
+
function openAiWireRule(): ImageTokenization {
|
|
37
|
+
if (wireRule) return wireRule;
|
|
38
|
+
const rule = resolveImageTokenization({ api: "openai-responses" });
|
|
39
|
+
if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
|
|
40
|
+
wireRule = rule;
|
|
41
|
+
return rule;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Estimated input tokens for one image. Unknown dimensions (undecodable data,
|
|
46
|
+
* remote URLs, provider file ids) charge the detail level's full patch budget.
|
|
47
|
+
*/
|
|
48
|
+
export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
|
|
49
|
+
return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
|
|
53
|
+
// segments; PNG/GIF/WebP need only the first 30 bytes. Multiple of 4 so the
|
|
54
|
+
// base64 slice decodes cleanly.
|
|
55
|
+
const HEADER_BASE64_CHARS = 4 * Math.ceil((64 * 1024) / 3);
|
|
56
|
+
|
|
57
|
+
/** Pixel size read from the header of base64-encoded image data, or null. */
|
|
58
|
+
export function base64ImageSize(base64: string): ImageSize | null {
|
|
59
|
+
if (base64.length === 0) return null;
|
|
60
|
+
const header = Buffer.from(base64.slice(0, HEADER_BASE64_CHARS), "base64");
|
|
61
|
+
const metadata = parseImageMetadata(header);
|
|
62
|
+
if (!metadata?.width || !metadata.height) return null;
|
|
63
|
+
return { width: metadata.width, height: metadata.height };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
|
|
67
|
+
export function dataUrlImageSize(url: string): ImageSize | null {
|
|
68
|
+
if (!url.startsWith("data:")) return null;
|
|
69
|
+
const comma = url.indexOf(",");
|
|
70
|
+
if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
|
|
71
|
+
return base64ImageSize(url.slice(comma + 1));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** {@link estimateImageTokens} for an in-memory image block. */
|
|
75
|
+
export function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number {
|
|
76
|
+
return estimateImageTokens(base64ImageSize(image.data), image.detail);
|
|
77
|
+
}
|