@linxiraos/pi-agent-core 1.1.20 → 1.1.23
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 +243 -2
- package/README.md +4 -3
- package/THIRD-PARTY-NOTICES.txt +3 -40
- package/dist/types/agent-loop.d.ts +14 -1
- package/dist/types/agent.d.ts +36 -0
- package/dist/types/compaction/anthropic.d.ts +22 -52
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction.d.ts +4 -2
- package/dist/types/compaction/messages.d.ts +9 -0
- package/dist/types/compaction/openai.d.ts +11 -0
- package/dist/types/compaction/pruning.d.ts +9 -0
- package/dist/types/compaction/transcript-tokens.d.ts +25 -2
- package/dist/types/index.d.ts +3 -0
- package/dist/types/live-steering.d.ts +34 -0
- package/dist/types/output-budget.d.ts +54 -0
- package/dist/types/sent-tool-definitions.d.ts +17 -0
- package/dist/types/speculative-execution.d.ts +2 -1
- package/dist/types/telemetry.d.ts +103 -60
- package/dist/types/tool-context.d.ts +32 -0
- package/dist/types/types.d.ts +105 -7
- package/package.json +10 -10
- package/src/agent-loop.ts +386 -90
- package/src/agent.ts +175 -26
- package/src/compaction/anthropic.ts +62 -105
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +13 -4
- package/src/compaction/compaction.ts +63 -51
- package/src/compaction/messages.ts +12 -2
- package/src/compaction/openai.ts +37 -9
- package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
- package/src/compaction/pruning.ts +1 -1
- package/src/compaction/transcript-tokens.ts +64 -3
- package/src/index.ts +6 -0
- package/src/live-steering.ts +89 -0
- package/src/output-budget.ts +142 -0
- package/src/sent-tool-definitions.ts +40 -0
- package/src/speculative-execution.ts +12 -0
- package/src/telemetry.ts +240 -117
- package/src/tokenizer.ts +45 -6
- package/src/tool-context.ts +55 -0
- package/src/types.ts +109 -7
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@linxiraos/pi-ai";
|
|
11
11
|
import * as AIError from "@linxiraos/pi-ai/error";
|
|
12
|
+
import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
|
|
12
13
|
import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
|
|
13
14
|
import {
|
|
14
|
-
createOpenAICodexCompactionRequestContext,
|
|
15
15
|
createOpenAICodexCompatibilityMetadata,
|
|
16
|
+
openCodexCompactionEventStream,
|
|
16
17
|
type OpenAICodexCompactionBody,
|
|
17
18
|
type OpenAICodexCompatibilityMetadata,
|
|
18
|
-
openCodexCompactionEventStream,
|
|
19
19
|
} from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
20
20
|
import {
|
|
21
21
|
getOpenAIPromptCacheKey,
|
|
@@ -597,6 +597,14 @@ function handleCompactionV2Event(
|
|
|
597
597
|
if (type === "response.failed" || type === "response.incomplete") {
|
|
598
598
|
throw new Error(formatCompactionV2Failure(event, type));
|
|
599
599
|
}
|
|
600
|
+
|
|
601
|
+
// A standalone `error` event terminates the stream. Keep its status so a
|
|
602
|
+
// deterministic 4xx (e.g. context_too_large) is not retried as a dropped stream.
|
|
603
|
+
if (type === "error") {
|
|
604
|
+
const message = formatCompactionV2Failure(event, type);
|
|
605
|
+
const status = numberField(event, "status");
|
|
606
|
+
throw status === undefined ? new Error(message) : new AIError.ProviderHttpError(message, status);
|
|
607
|
+
}
|
|
600
608
|
}
|
|
601
609
|
|
|
602
610
|
function parseCompactionV2Usage(event: Record<string, unknown>): CompactionV2Usage | undefined {
|
|
@@ -629,8 +637,9 @@ function formatCompactionV2Failure(event: Record<string, unknown>, type: string)
|
|
|
629
637
|
: response && isRecord(response.error)
|
|
630
638
|
? response.error
|
|
631
639
|
: undefined;
|
|
632
|
-
|
|
633
|
-
const
|
|
640
|
+
// Responses `error` events carry code/message at the top level.
|
|
641
|
+
const message = stringField(error ?? event, "message");
|
|
642
|
+
const code = error ? (stringField(error, "code") ?? stringField(error, "type")) : stringField(event, "code");
|
|
634
643
|
return `V2 compaction stream ${type}${code ? ` (${code})` : ""}${message ? `: ${message}` : ""}`;
|
|
635
644
|
}
|
|
636
645
|
|
|
@@ -25,10 +25,10 @@ import {
|
|
|
25
25
|
} from "@linxiraos/pi-ai";
|
|
26
26
|
import type { Dialect } from "@linxiraos/pi-ai/dialect";
|
|
27
27
|
import * as AIError from "@linxiraos/pi-ai/error";
|
|
28
|
+
import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
|
|
28
29
|
import type { InputItem as CodexInputItem } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
|
|
29
30
|
import {
|
|
30
31
|
buildTransformedCodexRequestBody,
|
|
31
|
-
createOpenAICodexCompactionRequestContext,
|
|
32
32
|
type OpenAICodexCompactionBody,
|
|
33
33
|
} from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
34
34
|
import { convertTools } from "@linxiraos/pi-ai/providers/openai-responses";
|
|
@@ -43,9 +43,8 @@ import { ThinkingLevel } from "../thinking";
|
|
|
43
43
|
import { Tokenizer } from "../tokenizer";
|
|
44
44
|
import type { AgentMessage } from "../types";
|
|
45
45
|
import {
|
|
46
|
-
ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS,
|
|
47
46
|
buildAnthropicCompactionInstructions,
|
|
48
|
-
|
|
47
|
+
findAnthropicCompactionCut,
|
|
49
48
|
getPreservedAnthropicCompactionData,
|
|
50
49
|
requestAnthropicNativeCompaction,
|
|
51
50
|
shouldUseAnthropicNativeCompaction,
|
|
@@ -70,6 +69,7 @@ import {
|
|
|
70
69
|
defaultConvertToLlm,
|
|
71
70
|
} from "./messages";
|
|
72
71
|
import {
|
|
72
|
+
assertRemoteCompactionInputFits,
|
|
73
73
|
buildOpenAiNativeHistory,
|
|
74
74
|
getPreservedOpenAiRemoteCompactionData,
|
|
75
75
|
isOpenAiRemoteCompactionApi,
|
|
@@ -679,7 +679,7 @@ export interface SummaryOptions {
|
|
|
679
679
|
/**
|
|
680
680
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
681
681
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
682
|
-
* `
|
|
682
|
+
* `omp.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
|
|
683
683
|
* or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
|
|
684
684
|
*/
|
|
685
685
|
telemetry?: AgentTelemetry;
|
|
@@ -1042,7 +1042,7 @@ export interface HandoffOptions {
|
|
|
1042
1042
|
metadata?: Record<string, unknown>;
|
|
1043
1043
|
/**
|
|
1044
1044
|
* Optional telemetry handle. When provided, the handoff LLM call is
|
|
1045
|
-
* wrapped in an OTEL chat span tagged with `
|
|
1045
|
+
* wrapped in an OTEL chat span tagged with `omp.gen_ai.oneshot.kind = "handoff"`.
|
|
1046
1046
|
*/
|
|
1047
1047
|
telemetry?: AgentTelemetry;
|
|
1048
1048
|
/**
|
|
@@ -1252,6 +1252,8 @@ export interface CompactionPreparation {
|
|
|
1252
1252
|
turnPrefixMessages: AgentMessage[];
|
|
1253
1253
|
/** Messages kept in full after compaction (recent history) */
|
|
1254
1254
|
recentMessages: AgentMessage[];
|
|
1255
|
+
/** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
|
|
1256
|
+
recentEntryIds?: string[];
|
|
1255
1257
|
/** Whether this is a split turn (cut point in middle of turn) */
|
|
1256
1258
|
isSplitTurn: boolean;
|
|
1257
1259
|
tokensBefore: number;
|
|
@@ -1380,16 +1382,22 @@ export function prepareCompaction(
|
|
|
1380
1382
|
}
|
|
1381
1383
|
}
|
|
1382
1384
|
} else if (previousCompaction) {
|
|
1383
|
-
// Local summaries exclude the retained tail, whose
|
|
1384
|
-
// the compaction record.
|
|
1385
|
-
// backwards: advisor snapshots put all retained messages after the summary
|
|
1386
|
-
// and may carry a keep ID from their previous, differently indexed snapshot.
|
|
1385
|
+
// Local and Anthropic summaries exclude the retained tail, whose
|
|
1386
|
+
// original entries precede the compaction record.
|
|
1387
1387
|
for (let i = resetBoundaryIndex + 1; i < prevCompactionIndex; i++) {
|
|
1388
1388
|
if (pathEntries[i].id === previousCompaction.firstKeptEntryId) {
|
|
1389
1389
|
boundaryStart = i;
|
|
1390
1390
|
break;
|
|
1391
1391
|
}
|
|
1392
1392
|
}
|
|
1393
|
+
if (previousCompaction.firstKeptEntryId === "" && previousCompaction.providerReplayThroughEntryId) {
|
|
1394
|
+
// An empty snapshot tail can still have turns appended during
|
|
1395
|
+
// background compaction; those start after the summarized snapshot.
|
|
1396
|
+
const snapshotIdx = pathEntries.findIndex(
|
|
1397
|
+
entry => entry.id === previousCompaction.providerReplayThroughEntryId,
|
|
1398
|
+
);
|
|
1399
|
+
if (snapshotIdx >= 0 && snapshotIdx < prevCompactionIndex) boundaryStart = snapshotIdx + 1;
|
|
1400
|
+
}
|
|
1393
1401
|
}
|
|
1394
1402
|
|
|
1395
1403
|
// Keep original IDs beside the converted messages so estimation, cutting,
|
|
@@ -1437,6 +1445,11 @@ export function prepareCompaction(
|
|
|
1437
1445
|
return undefined;
|
|
1438
1446
|
}
|
|
1439
1447
|
|
|
1448
|
+
const recentEntryIds: string[] = [];
|
|
1449
|
+
for (let i = cutPoint.firstKeptEntryIndex; i < compactionEntries.length; i++) {
|
|
1450
|
+
recentEntryIds.push(compactionEntries[i].id);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1440
1453
|
// Extract file operations from messages and previous compaction
|
|
1441
1454
|
const fileOps = extractFileOperations(messagesToSummarize, pathEntries, prevCompactionIndex);
|
|
1442
1455
|
|
|
@@ -1452,6 +1465,7 @@ export function prepareCompaction(
|
|
|
1452
1465
|
messagesToSummarize,
|
|
1453
1466
|
turnPrefixMessages,
|
|
1454
1467
|
recentMessages,
|
|
1468
|
+
recentEntryIds,
|
|
1455
1469
|
isSplitTurn: cutPoint.isSplitTurn,
|
|
1456
1470
|
tokensBefore,
|
|
1457
1471
|
previousSummary: previousCompaction?.summary,
|
|
@@ -1579,6 +1593,7 @@ export async function compact(
|
|
|
1579
1593
|
messagesToSummarize,
|
|
1580
1594
|
turnPrefixMessages,
|
|
1581
1595
|
recentMessages,
|
|
1596
|
+
recentEntryIds,
|
|
1582
1597
|
isSplitTurn,
|
|
1583
1598
|
tokensBefore,
|
|
1584
1599
|
previousSummary,
|
|
@@ -1734,6 +1749,7 @@ export async function compact(
|
|
|
1734
1749
|
contextWindow: model.contextWindow,
|
|
1735
1750
|
});
|
|
1736
1751
|
}
|
|
1752
|
+
assertRemoteCompactionInputFits(trimmed, model);
|
|
1737
1753
|
const requestOptions = {
|
|
1738
1754
|
sessionId: summaryOptions.sessionId,
|
|
1739
1755
|
promptCacheKey: summaryOptions.promptCacheKey,
|
|
@@ -1826,35 +1842,19 @@ export async function compact(
|
|
|
1826
1842
|
}
|
|
1827
1843
|
}
|
|
1828
1844
|
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
// real text, persisted both as the entry summary and as the native replay
|
|
1832
|
-
// payload. A context below the API's trigger floor cannot compact remotely
|
|
1833
|
-
// and takes the local summarizer instead — an eligibility boundary, not a
|
|
1834
|
-
// failure.
|
|
1845
|
+
// On-demand compaction summarizes only the prefix; the tail is never sent
|
|
1846
|
+
// to this request and is replayed after the returned signed block.
|
|
1835
1847
|
let nativeSummary: string | undefined;
|
|
1836
|
-
let
|
|
1848
|
+
let nativeSignature: string | undefined;
|
|
1849
|
+
let nativeFirstKeptEntryId = firstKeptEntryId;
|
|
1837
1850
|
let nativeUsedTokens: number | undefined;
|
|
1838
|
-
if (
|
|
1839
|
-
!usedRemoteCompaction &&
|
|
1840
|
-
settings.remoteEnabled !== false &&
|
|
1841
|
-
shouldUseAnthropicNativeCompaction(model) &&
|
|
1842
|
-
tokensBefore >= ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS
|
|
1843
|
-
) {
|
|
1851
|
+
if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
|
|
1844
1852
|
const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
|
|
1845
|
-
// Lead with the previous summary
|
|
1846
|
-
// natively when this provider wrote it, as text otherwise.
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
//
|
|
1850
|
-
// The rewrite marker must precede every message this request replays —
|
|
1851
|
-
// summarized history and retained tail alike — exactly like the live
|
|
1852
|
-
// context rebuild predates its tail. A previous compaction's commit
|
|
1853
|
-
// timestamp is newer than re-retained or re-summarized turns, so
|
|
1854
|
-
// reusing it would strip their bound thinking only in this request,
|
|
1855
|
-
// diverging from the cached live prefix (and possibly dropping below
|
|
1856
|
-
// the trigger). Manually built preparations with no input at all fall
|
|
1857
|
-
// back to the current time.
|
|
1853
|
+
// Lead with the previous summary as the live context renders it:
|
|
1854
|
+
// natively when this provider wrote it, as text otherwise.
|
|
1855
|
+
// A prior snapcompact archive is already merged into that summary
|
|
1856
|
+
// text. Predate the rewrite marker before all replayed messages so
|
|
1857
|
+
// retained thinking stays bound to the original prefix.
|
|
1858
1858
|
const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
|
|
1859
1859
|
const previousSummaryAt =
|
|
1860
1860
|
firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
|
|
@@ -1866,6 +1866,7 @@ export async function compact(
|
|
|
1866
1866
|
type: "anthropicCompaction",
|
|
1867
1867
|
provider: previousNative.provider,
|
|
1868
1868
|
content: previousNative.content,
|
|
1869
|
+
...(previousNative.signature ? { signature: previousNative.signature } : {}),
|
|
1869
1870
|
...(previousNative.encryptedContent
|
|
1870
1871
|
? { encryptedContent: previousNative.encryptedContent }
|
|
1871
1872
|
: {}),
|
|
@@ -1874,29 +1875,40 @@ export async function compact(
|
|
|
1874
1875
|
: undefined,
|
|
1875
1876
|
})
|
|
1876
1877
|
: undefined;
|
|
1877
|
-
const
|
|
1878
|
-
const
|
|
1879
|
-
const
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1878
|
+
const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
|
|
1879
|
+
const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
|
|
1880
|
+
const nativeCut = findAnthropicCompactionCut(allMessages, originalCut);
|
|
1881
|
+
// Hand-built preparations lacking entry IDs cannot move their persisted
|
|
1882
|
+
// boundary into the tail. Summarizing all is still safe.
|
|
1883
|
+
const safeCut =
|
|
1884
|
+
nativeCut > originalCut && nativeCut < allMessages.length && !recentEntryIds?.[nativeCut - originalCut]
|
|
1885
|
+
? allMessages.length
|
|
1886
|
+
: nativeCut;
|
|
1887
|
+
nativeFirstKeptEntryId =
|
|
1888
|
+
safeCut === allMessages.length
|
|
1889
|
+
? ""
|
|
1890
|
+
: safeCut === originalCut
|
|
1891
|
+
? firstKeptEntryId
|
|
1892
|
+
: (recentEntryIds?.[safeCut - originalCut] ?? "");
|
|
1893
|
+
for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
|
|
1894
|
+
const messages = (summaryOptions.convertToLlm ?? defaultConvertToLlm)([
|
|
1895
|
+
...(previousSummaryMessage ? [previousSummaryMessage] : []),
|
|
1896
|
+
...allMessages.slice(0, safeCut),
|
|
1897
|
+
]);
|
|
1887
1898
|
try {
|
|
1888
1899
|
const remote = await requestAnthropicNativeCompaction(
|
|
1889
1900
|
model,
|
|
1890
1901
|
apiKey,
|
|
1891
1902
|
{
|
|
1892
|
-
|
|
1903
|
+
// The live prompt, not the local summarizer's synthetic system
|
|
1904
|
+
// prompt: kept thinking remains valid only under identical controls.
|
|
1905
|
+
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1893
1906
|
messages,
|
|
1894
1907
|
tools: summaryOptions.tools,
|
|
1895
1908
|
instructions: buildAnthropicCompactionInstructions(
|
|
1896
1909
|
summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
|
|
1897
1910
|
customInstructions,
|
|
1898
1911
|
formatAdditionalContext(summaryOptions.extraContext).trim() || undefined,
|
|
1899
|
-
describeRetainedTail(retainedTail),
|
|
1900
1912
|
),
|
|
1901
1913
|
maxTokens: Math.min(Math.floor(0.8 * reserveTokens), MAX_SUMMARY_TOKENS),
|
|
1902
1914
|
reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
|
|
@@ -1915,7 +1927,7 @@ export async function compact(
|
|
|
1915
1927
|
},
|
|
1916
1928
|
);
|
|
1917
1929
|
nativeSummary = remote.content;
|
|
1918
|
-
|
|
1930
|
+
nativeSignature = remote.signature;
|
|
1919
1931
|
nativeUsedTokens = calculatePromptTokens(remote.usage);
|
|
1920
1932
|
usedRemoteCompaction = true;
|
|
1921
1933
|
} catch (err) {
|
|
@@ -2004,7 +2016,7 @@ export async function compact(
|
|
|
2004
2016
|
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
2005
2017
|
if (nativeSummary !== undefined) {
|
|
2006
2018
|
// The replayed block stays byte-identical to the API's summary so it
|
|
2007
|
-
// matches
|
|
2019
|
+
// matches its signature. The harness file lists above travel
|
|
2008
2020
|
// separately: the converter replaces the summary message with the
|
|
2009
2021
|
// block and skips its text, so they would otherwise be invisible to
|
|
2010
2022
|
// this provider. Every other provider keeps reading the entry text.
|
|
@@ -2012,7 +2024,7 @@ export async function compact(
|
|
|
2012
2024
|
preserveData = withAnthropicCompactionPreserveData(preserveData, {
|
|
2013
2025
|
provider: model.provider,
|
|
2014
2026
|
content: nativeSummary,
|
|
2015
|
-
...(
|
|
2027
|
+
...(nativeSignature ? { signature: nativeSignature } : {}),
|
|
2016
2028
|
...(filesText ? { filesText } : {}),
|
|
2017
2029
|
model: model.id,
|
|
2018
2030
|
usedTokens: nativeUsedTokens,
|
|
@@ -2034,7 +2046,7 @@ export async function compact(
|
|
|
2034
2046
|
return {
|
|
2035
2047
|
summary,
|
|
2036
2048
|
shortSummary,
|
|
2037
|
-
firstKeptEntryId,
|
|
2049
|
+
firstKeptEntryId: nativeSummary !== undefined ? nativeFirstKeptEntryId : firstKeptEntryId,
|
|
2038
2050
|
tokensBefore,
|
|
2039
2051
|
details: { readFiles, modifiedFiles } as CompactionDetails,
|
|
2040
2052
|
preserveData: finalPreserveData,
|
|
@@ -64,6 +64,13 @@ export interface CompactionSummaryMessage {
|
|
|
64
64
|
images?: ImageContent[];
|
|
65
65
|
/** Post-pass dead-end warning attached to this compaction (progress guard). */
|
|
66
66
|
warning?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Thinking-binding rewrite marker when it must differ from `timestamp`: a
|
|
69
|
+
* natively replayed summary predates it before the retained tail so that
|
|
70
|
+
* tail's bound thinking stays valid. `timestamp` remains the commit time,
|
|
71
|
+
* which is what invalidates the tail's pre-compaction usage reports.
|
|
72
|
+
*/
|
|
73
|
+
historyRewriteAt?: number;
|
|
67
74
|
timestamp: number;
|
|
68
75
|
}
|
|
69
76
|
|
|
@@ -135,6 +142,8 @@ export interface CompactionSummaryMessageOptions {
|
|
|
135
142
|
method?: string;
|
|
136
143
|
/** Estimated context tokens after the rewrite, for display alongside `tokensBefore`. */
|
|
137
144
|
tokensAfter?: number;
|
|
145
|
+
/** See {@link CompactionSummaryMessage.historyRewriteAt}. */
|
|
146
|
+
historyRewriteAt?: number;
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
export function createCompactionSummaryMessage(
|
|
@@ -143,7 +152,7 @@ export function createCompactionSummaryMessage(
|
|
|
143
152
|
timestamp: string,
|
|
144
153
|
options: CompactionSummaryMessageOptions = {},
|
|
145
154
|
): CompactionSummaryMessage {
|
|
146
|
-
const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter } = options;
|
|
155
|
+
const { shortSummary, providerPayload, images, blocks, warning, method, tokensAfter, historyRewriteAt } = options;
|
|
147
156
|
const imageBlocks =
|
|
148
157
|
blocks?.filter((block): block is ImageContent => block.type === "image") ??
|
|
149
158
|
(images && images.length > 0 ? images : undefined);
|
|
@@ -158,6 +167,7 @@ export function createCompactionSummaryMessage(
|
|
|
158
167
|
blocks: blocks && blocks.length > 0 ? blocks : undefined,
|
|
159
168
|
images: imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined,
|
|
160
169
|
warning,
|
|
170
|
+
historyRewriteAt,
|
|
161
171
|
timestamp: new Date(timestamp).getTime(),
|
|
162
172
|
};
|
|
163
173
|
}
|
|
@@ -246,7 +256,7 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
|
|
|
246
256
|
...(message.images ?? []),
|
|
247
257
|
],
|
|
248
258
|
attribution: "agent",
|
|
249
|
-
historyRewriteAt: message.timestamp,
|
|
259
|
+
historyRewriteAt: message.historyRewriteAt ?? message.timestamp,
|
|
250
260
|
providerPayload: message.providerPayload,
|
|
251
261
|
timestamp: message.timestamp,
|
|
252
262
|
};
|
package/src/compaction/openai.ts
CHANGED
|
@@ -15,13 +15,11 @@
|
|
|
15
15
|
* with `{ summary, shortSummary? }`.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { ProviderHttpError } from "@linxiraos/pi-ai/error";
|
|
18
|
+
import { attach, create, Flag, ProviderHttpError } from "@linxiraos/pi-ai/error";
|
|
19
|
+
import { getCodexAttestationHeader } from "@linxiraos/pi-ai/providers/openai-codex-attestation";
|
|
20
|
+
import { createOpenAICodexCompactionRequestContext } from "@linxiraos/pi-ai/providers/openai-codex-compaction";
|
|
19
21
|
import { applyCodexResponsesLiteShape } from "@linxiraos/pi-ai/providers/openai-codex/request-transformer";
|
|
20
|
-
import {
|
|
21
|
-
createOpenAICodexCompactionRequestContext,
|
|
22
|
-
createOpenAICodexCompatibilityMetadata,
|
|
23
|
-
getCodexAttestationHeader,
|
|
24
|
-
} from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
22
|
+
import { createOpenAICodexCompatibilityMetadata } from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
25
23
|
import {
|
|
26
24
|
encodeResponsesToolResultOutput,
|
|
27
25
|
hoistInterleavedResponsesToolBatchMessages,
|
|
@@ -112,6 +110,10 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
112
110
|
const normalized: Record<string, unknown> = {};
|
|
113
111
|
let imageTokens = 0;
|
|
114
112
|
for (const [key, item] of Object.entries(record)) {
|
|
113
|
+
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
114
|
+
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
115
|
+
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
116
|
+
if (key === "encrypted_content" && typeof item === "string") continue;
|
|
115
117
|
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
116
118
|
normalized[key] = result.value;
|
|
117
119
|
imageTokens += result.imageTokens;
|
|
@@ -124,6 +126,8 @@ export interface TrimRemoteCompactionInputResult {
|
|
|
124
126
|
rewrittenOutputs: number;
|
|
125
127
|
estimatedTokensBefore: number;
|
|
126
128
|
estimatedTokensAfter: number;
|
|
129
|
+
/** Whether `input` fits the model window; false means it must not be sent. */
|
|
130
|
+
fits: boolean;
|
|
127
131
|
}
|
|
128
132
|
|
|
129
133
|
/** Verdict for one remote-compaction request measured against the model window. */
|
|
@@ -137,9 +141,10 @@ interface RemoteCompactionBudgetProbe {
|
|
|
137
141
|
/**
|
|
138
142
|
* Cheap-first sizing of a remote-compaction request. Images and the request
|
|
139
143
|
* frame are charged flat, so they come off the budget rather than through the
|
|
140
|
-
* tokenizer;
|
|
141
|
-
* {@link Tokenizer.checkTokenBudget}, which only
|
|
142
|
-
* the byte bound cannot already prove the request
|
|
144
|
+
* tokenizer; opaque `encrypted_content` payloads are excluded. The serialized
|
|
145
|
+
* transcript is then probed with {@link Tokenizer.checkTokenBudget}, which only
|
|
146
|
+
* pays for an exact count when the byte bound cannot already prove the request
|
|
147
|
+
* fits.
|
|
143
148
|
*/
|
|
144
149
|
function probeRemoteCompactionInputBudget(
|
|
145
150
|
input: Array<Record<string, unknown>>,
|
|
@@ -201,6 +206,7 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
201
206
|
rewrittenOutputs: 0,
|
|
202
207
|
estimatedTokensBefore: before.tokens,
|
|
203
208
|
estimatedTokensAfter: before.tokens,
|
|
209
|
+
fits: true,
|
|
204
210
|
};
|
|
205
211
|
}
|
|
206
212
|
|
|
@@ -224,6 +230,7 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
224
230
|
rewrittenOutputs: 0,
|
|
225
231
|
estimatedTokensBefore: before.tokens,
|
|
226
232
|
estimatedTokensAfter: before.tokens,
|
|
233
|
+
fits: false,
|
|
227
234
|
};
|
|
228
235
|
}
|
|
229
236
|
|
|
@@ -232,9 +239,29 @@ export function trimRemoteCompactionInputToContextWindow(
|
|
|
232
239
|
rewrittenOutputs,
|
|
233
240
|
estimatedTokensBefore: before.tokens,
|
|
234
241
|
estimatedTokensAfter: after.tokens,
|
|
242
|
+
fits: true,
|
|
235
243
|
};
|
|
236
244
|
}
|
|
237
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Refuse a native compaction request whose prepared input cannot fit the model
|
|
248
|
+
* window, before any network I/O. Re-expanded history behind an unreadable
|
|
249
|
+
* native boundary can exceed the window even when live context does not.
|
|
250
|
+
*
|
|
251
|
+
* @throws Error flagged `ContextOverflow` when `trimmed.fits` is false, so
|
|
252
|
+
* compaction callers skip retries and advance to the next method.
|
|
253
|
+
*/
|
|
254
|
+
export function assertRemoteCompactionInputFits(trimmed: TrimRemoteCompactionInputResult, model: Model): void {
|
|
255
|
+
if (trimmed.fits) return;
|
|
256
|
+
throw attach(
|
|
257
|
+
new Error(
|
|
258
|
+
`Remote compaction input exceeds the context window of ${model.provider}/${model.id}: ` +
|
|
259
|
+
`estimated ${trimmed.estimatedTokensAfter} tokens > ${model.contextWindow}`,
|
|
260
|
+
),
|
|
261
|
+
create(Flag.ContextOverflow),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
238
265
|
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
|
239
266
|
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
240
267
|
if (timeoutMs <= 0) return signal;
|
|
@@ -796,6 +823,7 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
796
823
|
contextWindow: model.contextWindow,
|
|
797
824
|
});
|
|
798
825
|
}
|
|
826
|
+
assertRemoteCompactionInputFits(trimmed, model);
|
|
799
827
|
const request: OpenAiRemoteCompactionRequest = {
|
|
800
828
|
model: requestModel,
|
|
801
829
|
// Preserve the native transcript. Only oversized trailing tool outputs are
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
SCOPE: The conversation's final {{#when retainedTail.count "==" 1}}{{retainedTail.role}} message stays{{else}}{{retainedTail.count}} messages, starting with a {{retainedTail.role}} message, stay{{/when}} in context verbatim after your summary. Summarize ONLY the history before those messages. You MUST NOT restate anything from those final messages — the reader sees them right after the summary — and you MUST treat them as the most recent state when describing progress and next steps.
|
|
3
|
-
{{else}}
|
|
4
|
-
SCOPE: The conversation above is the transcript to summarize. The API replaces everything before your summary with it, so nothing you leave out survives into the next context window.
|
|
5
|
-
{{/if}}
|
|
1
|
+
The conversation above is the complete history to summarize. The API replaces every message in this request with your summary; no message you leave out survives.
|
|
6
2
|
|
|
7
3
|
{{#if extraContext}}
|
|
8
4
|
{{extraContext}}
|
|
@@ -14,4 +10,4 @@ SCOPE: The conversation above is the transcript to summarize. The API replaces e
|
|
|
14
10
|
Additional focus: {{customInstructions}}
|
|
15
11
|
{{/if}}
|
|
16
12
|
|
|
17
|
-
You MUST NOT call any tools while writing the summary; respond with the summary text only.
|
|
13
|
+
You MUST NOT call any tools while writing the summary; respond with the summary text only.
|
|
@@ -120,7 +120,7 @@ function createPrunedNotice(tokens: number): string {
|
|
|
120
120
|
* own rules: useless already drops no-savings candidates, superseded prunes for
|
|
121
121
|
* correctness regardless of size.
|
|
122
122
|
*/
|
|
123
|
-
const MIN_PRUNE_TOKENS = 50;
|
|
123
|
+
export const MIN_PRUNE_TOKENS = 50;
|
|
124
124
|
|
|
125
125
|
function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefined {
|
|
126
126
|
if (entry.type !== "message") return undefined;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* - `hasContextTokenUsage(usage)`: the report must carry usable context numbers.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { AssistantMessage } from "@linxiraos/pi-ai";
|
|
21
|
+
import type { AssistantMessage, Message } from "@linxiraos/pi-ai";
|
|
22
22
|
import type { MessageCountOptions, Tokenizer } from "../tokenizer";
|
|
23
23
|
import type { AgentMessage } from "../types";
|
|
24
24
|
import { calculateContextTokens, hasContextTokenUsage } from "./compaction";
|
|
@@ -47,6 +47,31 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
|
|
|
47
47
|
return assistant.usage !== undefined && hasContextTokenUsage(assistant.usage);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Newest `prunedAt` in `messages`, or `-Infinity` when nothing was pruned.
|
|
52
|
+
*
|
|
53
|
+
* A pruned tool result was rewritten in place, so every usage report made at
|
|
54
|
+
* or before this time still counts the removed bytes.
|
|
55
|
+
*/
|
|
56
|
+
function newestPrunedAt(messages: readonly AgentMessage[]): number {
|
|
57
|
+
let rewriteAt = Number.NEGATIVE_INFINITY;
|
|
58
|
+
for (const message of messages) {
|
|
59
|
+
if (message.role === "toolResult" && message.prunedAt !== undefined) {
|
|
60
|
+
rewriteAt = Math.max(rewriteAt, message.prunedAt);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return rewriteAt;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Options for {@link findTranscriptUsageAnchor}. */
|
|
67
|
+
export interface TranscriptUsageAnchorOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Also treat usage reported at or before the newest `prunedAt` as stale,
|
|
70
|
+
* the same rule {@link findRequestUsageAnchor} applies to request contexts.
|
|
71
|
+
*/
|
|
72
|
+
skipPrunedAnchors?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
50
75
|
/**
|
|
51
76
|
* Newest assistant turn in `messages[fromIndex..]` whose usage can anchor the
|
|
52
77
|
* transcript, or `undefined` when none qualifies (fresh context, or every
|
|
@@ -58,15 +83,47 @@ export function isTranscriptUsageAnchor(message: AgentMessage): message is Assis
|
|
|
58
83
|
export function findTranscriptUsageAnchor(
|
|
59
84
|
messages: readonly AgentMessage[],
|
|
60
85
|
fromIndex = 0,
|
|
86
|
+
options?: TranscriptUsageAnchorOptions,
|
|
61
87
|
): TranscriptUsageAnchor | undefined {
|
|
88
|
+
const rewriteAt = options?.skipPrunedAnchors === true ? newestPrunedAt(messages) : Number.NEGATIVE_INFINITY;
|
|
62
89
|
for (let index = messages.length - 1; index >= fromIndex; index--) {
|
|
63
90
|
const message = messages[index];
|
|
64
|
-
if (!isTranscriptUsageAnchor(message)) continue;
|
|
91
|
+
if (!isTranscriptUsageAnchor(message) || message.timestamp <= rewriteAt) continue;
|
|
65
92
|
return { index, message, tokens: calculateContextTokens(message.usage) };
|
|
66
93
|
}
|
|
67
94
|
return undefined;
|
|
68
95
|
}
|
|
69
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Newest assistant turn in a provider request's `messages` whose usage still
|
|
99
|
+
* describes the prefix it sits on, or `undefined` when none does.
|
|
100
|
+
*
|
|
101
|
+
* Request contexts carry no compaction index, so staleness is read from the
|
|
102
|
+
* rewrite markers themselves: a compaction/branch summary or pruned tool result
|
|
103
|
+
* (`prunedAt`) replaced text that every report made at or before the rewrite
|
|
104
|
+
* already counted. A summary's rewrite time is its `timestamp` (commit time);
|
|
105
|
+
* its `historyRewriteAt` may be predated before a natively replayed retained
|
|
106
|
+
* tail so that tail's bound thinking survives, but the tail's usage still
|
|
107
|
+
* counted the summarized prefix.
|
|
108
|
+
*/
|
|
109
|
+
export function findRequestUsageAnchor(messages: readonly Message[]): TranscriptUsageAnchor | undefined {
|
|
110
|
+
let rewriteAt = Number.NEGATIVE_INFINITY;
|
|
111
|
+
let anchorIndex = -1;
|
|
112
|
+
let anchor: AssistantMessage | undefined;
|
|
113
|
+
for (let index = 0; index < messages.length; index++) {
|
|
114
|
+
const message = messages[index];
|
|
115
|
+
if (message.role === "user" && message.historyRewriteAt !== undefined) {
|
|
116
|
+
rewriteAt = Math.max(rewriteAt, message.historyRewriteAt, message.timestamp);
|
|
117
|
+
} else if (message.role === "toolResult" && message.prunedAt !== undefined) {
|
|
118
|
+
rewriteAt = Math.max(rewriteAt, message.prunedAt);
|
|
119
|
+
} else if (isTranscriptUsageAnchor(message) && message.timestamp > rewriteAt) {
|
|
120
|
+
anchorIndex = index;
|
|
121
|
+
anchor = message;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return anchor && { index: anchorIndex, message: anchor, tokens: calculateContextTokens(anchor.usage) };
|
|
125
|
+
}
|
|
126
|
+
|
|
70
127
|
/** Options for {@link estimateTranscriptTokens}. */
|
|
71
128
|
export interface TranscriptTokenOptions {
|
|
72
129
|
/**
|
|
@@ -75,6 +132,8 @@ export interface TranscriptTokenOptions {
|
|
|
75
132
|
* accounting is governed separately by {@link countFromIndex}.
|
|
76
133
|
*/
|
|
77
134
|
anchorFromIndex?: number;
|
|
135
|
+
/** Forwarded to {@link findTranscriptUsageAnchor}. */
|
|
136
|
+
skipPrunedAnchors?: boolean;
|
|
78
137
|
/**
|
|
79
138
|
* First message whose content is counted locally when no anchor is found.
|
|
80
139
|
* Defaults to 0 (count the whole transcript), which is what a floor
|
|
@@ -102,7 +161,9 @@ export function estimateTranscriptTokens(
|
|
|
102
161
|
): number {
|
|
103
162
|
const estimateOptions: MessageCountOptions | undefined =
|
|
104
163
|
options?.excludeEncryptedReasoning === true ? { excludeEncryptedReasoning: true } : undefined;
|
|
105
|
-
const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0
|
|
164
|
+
const anchor = findTranscriptUsageAnchor(messages, options?.anchorFromIndex ?? 0, {
|
|
165
|
+
skipPrunedAnchors: options?.skipPrunedAnchors,
|
|
166
|
+
});
|
|
106
167
|
let total = anchor?.tokens ?? 0;
|
|
107
168
|
for (let index = anchor ? anchor.index + 1 : (options?.countFromIndex ?? 0); index < messages.length; index++) {
|
|
108
169
|
total += tokenizer.countMessage(messages[index], estimateOptions);
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,8 @@ export * from "./agent-loop";
|
|
|
6
6
|
export * from "./append-only-context";
|
|
7
7
|
// Compaction
|
|
8
8
|
export * from "./compaction";
|
|
9
|
+
// Output cap sized to the remaining context window
|
|
10
|
+
export * from "./output-budget";
|
|
9
11
|
// Process-global pause gate
|
|
10
12
|
export * from "./pause";
|
|
11
13
|
// Proxy utilities
|
|
@@ -14,12 +16,16 @@ export * from "./proxy";
|
|
|
14
16
|
export * from "./replay-policy";
|
|
15
17
|
// Run-level telemetry collector + aggregators
|
|
16
18
|
export * from "./run-collector";
|
|
19
|
+
// Tool definitions remembered for Anthropic inactive-tool re-declaration
|
|
20
|
+
export * from "./sent-tool-definitions";
|
|
17
21
|
// Speculative execution coordinator
|
|
18
22
|
export * from "./speculative-execution";
|
|
19
23
|
// Telemetry
|
|
20
24
|
export * from "./telemetry";
|
|
21
25
|
// Thinking selectors
|
|
22
26
|
export * from "./thinking";
|
|
27
|
+
// Tool-context augmentation
|
|
28
|
+
export * from "./tool-context";
|
|
23
29
|
// Tokenizer choice
|
|
24
30
|
export * from "./tokenizer";
|
|
25
31
|
// Types
|