@linxiraos/pi-agent-core 1.1.15 → 1.1.16
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 +194 -2
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/agent.d.ts +5 -2
- package/dist/types/append-only-context.d.ts +0 -4
- package/dist/types/compaction/compaction.d.ts +26 -17
- package/dist/types/compaction/entries.d.ts +2 -0
- package/dist/types/compaction/openai.d.ts +2 -1
- package/dist/types/thinking.d.ts +1 -1
- package/dist/types/types.d.ts +9 -1
- package/package.json +10 -10
- package/src/agent-loop.ts +127 -10
- package/src/agent.ts +142 -17
- package/src/append-only-context.ts +122 -1
- package/src/compaction/compaction-v2-streaming.ts +2 -1
- package/src/compaction/compaction.ts +98 -66
- package/src/compaction/entries.ts +2 -0
- package/src/compaction/openai.ts +10 -3
- package/src/proxy.ts +49 -4
- package/src/telemetry.ts +1 -0
- package/src/thinking.ts +1 -1
- package/src/tokenizer.ts +1 -0
- package/src/types.ts +14 -1
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* message delta is a cache miss each turn.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import type { AgentTool } from "./types";
|
|
17
18
|
import type { Context, Message, Tool } from "@linxiraos/pi-ai";
|
|
19
|
+
import { toolWireSchema } from "@linxiraos/pi-ai";
|
|
18
20
|
import { normalizeTools } from "./agent-loop";
|
|
19
21
|
import { messageEstimateVersion } from "./compaction/message-cache";
|
|
20
22
|
import type { AgentContext, AgentMessage } from "./types";
|
|
@@ -23,6 +25,52 @@ import type { AgentContext, AgentMessage } from "./types";
|
|
|
23
25
|
// StablePrefix (formerly ImmutablePrefix)
|
|
24
26
|
// ---------------------------------------------------------------------------
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Cheap per-tool wire-identity key: every field normalizeTools and
|
|
30
|
+
* computeFingerprint read. The resolved parameters contribute by object
|
|
31
|
+
* identity — the wire-schema memo is stamp-keyed on the parameters object,
|
|
32
|
+
* so identity equals normalized bytes (modulo intent injection, a pure
|
|
33
|
+
* function of the mode flags compared separately). `customFormat` and
|
|
34
|
+
* `examples` contribute by reference: both are treated as immutable config
|
|
35
|
+
* (a registry swap replaces the object), matching how the snapshot consumes
|
|
36
|
+
* them. `intent` functions are compared by reference — same closure means
|
|
37
|
+
* same mode resolution.
|
|
38
|
+
*/
|
|
39
|
+
function toolKeyForPrefix(tool: AgentTool): string {
|
|
40
|
+
const params = toolWireSchema(tool);
|
|
41
|
+
const customFormat = tool.customFormat;
|
|
42
|
+
const examples = (tool as { examples?: unknown }).examples;
|
|
43
|
+
// Identity strings for objects (Map-assigned ids, no string-build cost
|
|
44
|
+
// on the hot path beyond the first sighting per object).
|
|
45
|
+
return [
|
|
46
|
+
tool.name ?? "",
|
|
47
|
+
tool.description ?? "",
|
|
48
|
+
String(tool.strict ?? ""),
|
|
49
|
+
tool.customWireName ?? "",
|
|
50
|
+
typeof tool.intent === "function" ? `fn:${objectId(tool.intent)}` : `mode:${tool.intent ?? "require"}`,
|
|
51
|
+
objectId(params),
|
|
52
|
+
objectId(customFormat),
|
|
53
|
+
objectId(examples),
|
|
54
|
+
].join("\u0000");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const objectIds = new WeakMap<object, number>();
|
|
58
|
+
let nextObjectId = 1;
|
|
59
|
+
|
|
60
|
+
function objectId(obj: unknown): number {
|
|
61
|
+
if (obj === null || obj === undefined || (typeof obj !== "object" && typeof obj !== "function")) {
|
|
62
|
+
// Primitives contribute by value below via String(); the id path is
|
|
63
|
+
// only for reference-identity comparison of config objects.
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
let id = objectIds.get(obj);
|
|
67
|
+
if (id === undefined) {
|
|
68
|
+
id = nextObjectId++;
|
|
69
|
+
objectIds.set(obj, id);
|
|
70
|
+
}
|
|
71
|
+
return id;
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
/** Frozen system prompt + tool spec snapshot. */
|
|
27
75
|
export interface StablePrefixSnapshot {
|
|
28
76
|
systemPrompt: string[];
|
|
@@ -63,10 +111,42 @@ export class StablePrefix {
|
|
|
63
111
|
/**
|
|
64
112
|
* Build or rebuild from live context.
|
|
65
113
|
* Returns `true` if the prefix actually changed (cache miss imminent).
|
|
114
|
+
*
|
|
115
|
+
* Steady-state fast path: when the live prompt reference is unchanged
|
|
116
|
+
* AND every tool resolves to the same normalized parameters identity as
|
|
117
|
+
* last build, the fingerprint cannot have changed, so the full snapshot
|
|
118
|
+
* + stringify is skipped. Comparing resolved parameters (not the tool
|
|
119
|
+
* container) is load-bearing: tools like ReadTool expose `parameters` as
|
|
120
|
+
* a getter over live settings (`skillful`, `memory.backend`), so the
|
|
121
|
+
* schema can swap under stable tool references when a setting toggles.
|
|
122
|
+
* Any other in-place mutation must go through `invalidate()`.
|
|
66
123
|
*/
|
|
124
|
+
#lastPrompt: readonly string[] | undefined;
|
|
125
|
+
// Joined prompt bytes snapshot: the prompt array is caller-owned and
|
|
126
|
+
// mutable in place (Agent.setSystemPrompt stores the caller's array;
|
|
127
|
+
// anyone holding it can push/splice), so reference equality alone cannot
|
|
128
|
+
// prove the bytes are unchanged. Compared by value on the fast path.
|
|
129
|
+
#lastPromptText: string | undefined;
|
|
130
|
+
// Per-tool wire-identity snapshot: every field normalizeTools and
|
|
131
|
+
// computeFingerprint read (name, description, resolved parameters,
|
|
132
|
+
// strict, customFormat, customWireName, intent mode, examples
|
|
133
|
+
// reference). A registry swap that keeps name/description/parameters
|
|
134
|
+
// but changes any of these must miss the fast path.
|
|
135
|
+
#lastToolKey: readonly string[] | undefined;
|
|
136
|
+
#lastIntentTracing: boolean | undefined;
|
|
137
|
+
#lastPruneToolDescriptions: boolean | undefined;
|
|
138
|
+
|
|
67
139
|
build(context: AgentContext, options: BuildOptions): boolean {
|
|
140
|
+
const prev = this.#snapshot;
|
|
141
|
+
if (prev !== null && this.#fastPathHit(context, options)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
68
144
|
const snapshot = takeSnapshot(context, options);
|
|
69
|
-
|
|
145
|
+
this.#recordFastPathKey(context, options, snapshot.tools);
|
|
146
|
+
if (prev && prev.fingerprint === snapshot.fingerprint) {
|
|
147
|
+
// Identity changed but bytes did not (e.g. equivalent rebuild):
|
|
148
|
+
// keep serving the cached snapshot so downstream memo identity
|
|
149
|
+
// (stamp-keyed schema caches) stays stable.
|
|
70
150
|
return false;
|
|
71
151
|
}
|
|
72
152
|
this.#snapshot = snapshot;
|
|
@@ -74,9 +154,50 @@ export class StablePrefix {
|
|
|
74
154
|
return true;
|
|
75
155
|
}
|
|
76
156
|
|
|
157
|
+
/** True when the cheap key matches: prompt + per-tool resolved identity. */
|
|
158
|
+
#fastPathHit(context: AgentContext, options: BuildOptions): boolean {
|
|
159
|
+
if (
|
|
160
|
+
this.#lastIntentTracing !== options.intentTracing ||
|
|
161
|
+
this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
|
|
162
|
+
) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// Prompt by reference first (steady state), then by joined bytes so
|
|
166
|
+
// an in-place push/splice of the same array still misses.
|
|
167
|
+
if (this.#lastPrompt !== context.systemPrompt) return false;
|
|
168
|
+
if (this.#lastPromptText !== undefined) {
|
|
169
|
+
const text = context.systemPrompt.join("\u0000");
|
|
170
|
+
if (text !== this.#lastPromptText) return false;
|
|
171
|
+
}
|
|
172
|
+
const tools = context.tools ?? [];
|
|
173
|
+
if (this.#lastToolKey === undefined || this.#lastToolKey.length !== tools.length) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < tools.length; i++) {
|
|
177
|
+
if (this.#lastToolKey[i] !== toolKeyForPrefix(tools[i]!)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#recordFastPathKey(context: AgentContext, options: BuildOptions, normalized: Tool[]): void {
|
|
185
|
+
this.#lastPrompt = context.systemPrompt;
|
|
186
|
+
this.#lastPromptText = context.systemPrompt.join("\u0000");
|
|
187
|
+
this.#lastIntentTracing = options.intentTracing;
|
|
188
|
+
this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
|
|
189
|
+
this.#lastToolKey = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
|
|
190
|
+
void normalized;
|
|
191
|
+
}
|
|
192
|
+
|
|
77
193
|
/** Force rebuild on the next `build()` call. */
|
|
78
194
|
invalidate(): void {
|
|
79
195
|
this.#snapshot = null;
|
|
196
|
+
this.#lastPrompt = undefined;
|
|
197
|
+
this.#lastPromptText = undefined;
|
|
198
|
+
this.#lastToolKey = undefined;
|
|
199
|
+
this.#lastIntentTracing = undefined;
|
|
200
|
+
this.#lastPruneToolDescriptions = undefined;
|
|
80
201
|
}
|
|
81
202
|
|
|
82
203
|
/**
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
OPENAI_HEADER_VALUES,
|
|
33
33
|
OPENAI_HEADERS,
|
|
34
34
|
} from "@linxiraos/pi-catalog/wire/codex";
|
|
35
|
-
import { $env, logger, stringifyJson } from "@linxiraos/pi-utils";
|
|
35
|
+
import { $env, isUnexpectedSocketCloseMessage, logger, stringifyJson } from "@linxiraos/pi-utils";
|
|
36
36
|
|
|
37
37
|
// ============================================================================
|
|
38
38
|
// Types & Configuration
|
|
@@ -651,6 +651,7 @@ function isRetryableCompactionError(error: Error): boolean {
|
|
|
651
651
|
}
|
|
652
652
|
const message = error.message.toLowerCase();
|
|
653
653
|
return (
|
|
654
|
+
isUnexpectedSocketCloseMessage(message) ||
|
|
654
655
|
message.includes("stream closed before response.completed") ||
|
|
655
656
|
message.includes("stream parse failed") ||
|
|
656
657
|
message.includes("server_error") ||
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
import {
|
|
73
73
|
buildOpenAiNativeHistory,
|
|
74
74
|
getPreservedOpenAiRemoteCompactionData,
|
|
75
|
+
isOpenAiRemoteCompactionApi,
|
|
75
76
|
requestOpenAiRemoteCompaction,
|
|
76
77
|
requestRemoteCompaction,
|
|
77
78
|
shouldUseOpenAiRemoteCompaction,
|
|
@@ -459,6 +460,23 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
|
|
|
459
460
|
return cutPoints;
|
|
460
461
|
}
|
|
461
462
|
|
|
463
|
+
/**
|
|
464
|
+
* True for entries that begin a conversational turn (a user request, a
|
|
465
|
+
* bash-execution card, a branch summary, or a custom user-role message).
|
|
466
|
+
* Compaction cut alignment, turn discovery, and the collapsed display
|
|
467
|
+
* transcript's orphan-head trim all share this boundary definition.
|
|
468
|
+
*/
|
|
469
|
+
export function isTurnStartEntry(entry: SessionEntry): boolean {
|
|
470
|
+
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
if (entry.type === "message") {
|
|
474
|
+
const role = entry.message.role as string;
|
|
475
|
+
return role === "user" || role === "bashExecution";
|
|
476
|
+
}
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
462
480
|
/**
|
|
463
481
|
* Find the user message (or bashExecution) that starts the turn containing the given entry index.
|
|
464
482
|
* Returns -1 if no turn start found before the index.
|
|
@@ -466,17 +484,9 @@ function findValidCutPoints(entries: SessionEntry[], startIndex: number, endInde
|
|
|
466
484
|
*/
|
|
467
485
|
export function findTurnStartIndex(entries: SessionEntry[], entryIndex: number, startIndex: number): number {
|
|
468
486
|
for (let i = entryIndex; i >= startIndex; i--) {
|
|
469
|
-
|
|
470
|
-
// branch_summary and custom_message are user-role messages, can start a turn
|
|
471
|
-
if (entry.type === "branch_summary" || entry.type === "custom_message") {
|
|
487
|
+
if (isTurnStartEntry(entries[i])) {
|
|
472
488
|
return i;
|
|
473
489
|
}
|
|
474
|
-
if (entry.type === "message") {
|
|
475
|
-
const role = entry.message.role as string;
|
|
476
|
-
if (role === "user" || role === "bashExecution") {
|
|
477
|
-
return i;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
490
|
}
|
|
481
491
|
return -1;
|
|
482
492
|
}
|
|
@@ -491,10 +501,11 @@ export interface CutPointResult {
|
|
|
491
501
|
}
|
|
492
502
|
|
|
493
503
|
/**
|
|
494
|
-
* Find the
|
|
504
|
+
* Find the oldest complete recent-history suffix that fits `keepRecentTokens`.
|
|
495
505
|
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
506
|
+
* Walk backwards by valid cut points, measuring whole assistant/tool groups.
|
|
507
|
+
* Keep the newest group even when it alone exceeds the budget; never retain
|
|
508
|
+
* an additional older group that would push an otherwise fitting suffix over.
|
|
498
509
|
*
|
|
499
510
|
* Can cut at user OR assistant messages (never tool results). When cutting at an
|
|
500
511
|
* assistant message with tool calls, its tool results come after and will be kept.
|
|
@@ -519,55 +530,45 @@ export function findCutPoint(
|
|
|
519
530
|
return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
|
|
520
531
|
}
|
|
521
532
|
|
|
522
|
-
//
|
|
533
|
+
// Evaluate the budget only at valid boundaries, after counting all results
|
|
534
|
+
// belonging to an assistant. Checking individual messages can either retain
|
|
535
|
+
// the oversized older assistant or miss its boundary and retain all history.
|
|
523
536
|
let accumulatedTokens = 0;
|
|
524
|
-
let
|
|
537
|
+
let cutPointIndex = cutPoints.length - 1;
|
|
538
|
+
let cutIndex = cutPoints[cutPointIndex];
|
|
525
539
|
|
|
526
540
|
for (let i = endIndex - 1; i >= startIndex; i--) {
|
|
527
541
|
const entry = entries[i];
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Check if we've exceeded the budget
|
|
535
|
-
if (accumulatedTokens >= keepRecentTokens) {
|
|
536
|
-
// Find the closest valid cut point at or after this entry
|
|
537
|
-
for (let c = 0; c < cutPoints.length; c++) {
|
|
538
|
-
if (cutPoints[c] >= i) {
|
|
539
|
-
cutIndex = cutPoints[c];
|
|
540
|
-
break;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
break;
|
|
544
|
-
}
|
|
542
|
+
const message = getMessageFromEntry(entry);
|
|
543
|
+
if (message) accumulatedTokens += tokenizer.countMessage(message);
|
|
544
|
+
if (i !== cutPoints[cutPointIndex]) continue;
|
|
545
|
+
if (accumulatedTokens > keepRecentTokens) break;
|
|
546
|
+
cutIndex = i;
|
|
547
|
+
cutPointIndex--;
|
|
545
548
|
}
|
|
546
549
|
|
|
547
|
-
|
|
550
|
+
const isTurnStart = isTurnStartEntry(entries[cutIndex]);
|
|
551
|
+
const turnStartIndex = isTurnStart ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
|
552
|
+
|
|
553
|
+
// Scan backwards from cutIndex to include any non-message entries (settings changes, etc.)
|
|
548
554
|
while (cutIndex > startIndex) {
|
|
549
555
|
const prevEntry = entries[cutIndex - 1];
|
|
550
|
-
// Stop at session header or
|
|
551
|
-
if (prevEntry.type === "compaction") {
|
|
556
|
+
// Stop at session header, compaction, or reset boundaries
|
|
557
|
+
if (prevEntry.type === "compaction" || prevEntry.type === "reset_boundary") {
|
|
552
558
|
break;
|
|
553
559
|
}
|
|
554
|
-
if (prevEntry
|
|
555
|
-
// Stop if we hit any message
|
|
560
|
+
if (getMessageFromEntry(prevEntry)) {
|
|
561
|
+
// Stop if we hit any entry that contributes a message
|
|
556
562
|
break;
|
|
557
563
|
}
|
|
558
|
-
// Include this non-message entry (
|
|
564
|
+
// Include this non-message entry (settings change, label, etc.)
|
|
559
565
|
cutIndex--;
|
|
560
566
|
}
|
|
561
567
|
|
|
562
|
-
// Determine if this is a split turn
|
|
563
|
-
const cutEntry = entries[cutIndex];
|
|
564
|
-
const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
|
|
565
|
-
const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);
|
|
566
|
-
|
|
567
568
|
return {
|
|
568
569
|
firstKeptEntryIndex: cutIndex,
|
|
569
570
|
turnStartIndex,
|
|
570
|
-
isSplitTurn: !
|
|
571
|
+
isSplitTurn: !isTurnStart && turnStartIndex !== -1,
|
|
571
572
|
};
|
|
572
573
|
}
|
|
573
574
|
|
|
@@ -1267,19 +1268,27 @@ export interface CompactionPreparation {
|
|
|
1267
1268
|
}
|
|
1268
1269
|
|
|
1269
1270
|
/**
|
|
1270
|
-
* Whether
|
|
1271
|
-
*
|
|
1272
|
-
*
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1271
|
+
* Whether the active model's normal encoder can consume stored native history.
|
|
1272
|
+
* Creating future compactions is a separate policy: disabling it does not disable
|
|
1273
|
+
* Responses-family replay. Local summaries have no provider restriction.
|
|
1274
|
+
*/
|
|
1275
|
+
export function canReplayRemoteCompaction(
|
|
1276
|
+
preserveData: Record<string, unknown> | undefined,
|
|
1277
|
+
activeModel: Model,
|
|
1278
|
+
): boolean {
|
|
1279
|
+
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1280
|
+
return !remote || (remote.provider === activeModel.provider && isOpenAiRemoteCompactionApi(activeModel.api));
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/**
|
|
1284
|
+
* Whether compaction preparation may reuse a native boundary instead of
|
|
1285
|
+
* re-expanding its original messages. This is deliberately stricter than normal
|
|
1286
|
+
* replay: the active model must both read the payload and remain eligible for
|
|
1287
|
+
* native compaction under the current settings. Otherwise local summarization
|
|
1288
|
+
* needs the originals, not an opaque placeholder.
|
|
1278
1289
|
*
|
|
1279
|
-
*
|
|
1280
|
-
*
|
|
1281
|
-
* let the active model replay it, so keying reuse on "any candidate shares the
|
|
1282
|
-
* provider" left a provider-switched session permanently context-less (#6343).
|
|
1290
|
+
* Main-session preparation is judged against the active model, not any role
|
|
1291
|
+
* candidate, so a provider switch cannot strand the original history (#6343).
|
|
1283
1292
|
*/
|
|
1284
1293
|
export function remotePreserveReusable(
|
|
1285
1294
|
preserveData: Record<string, unknown> | undefined,
|
|
@@ -1288,15 +1297,16 @@ export function remotePreserveReusable(
|
|
|
1288
1297
|
): boolean {
|
|
1289
1298
|
const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
|
|
1290
1299
|
if (!remote) return true;
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1300
|
+
return (
|
|
1301
|
+
remote.provider === activeModel.provider &&
|
|
1302
|
+
isOpenAiRemoteCompactionApi(activeModel.api) &&
|
|
1303
|
+
shouldUseProviderNativeCompaction(activeModel, settings)
|
|
1304
|
+
);
|
|
1295
1305
|
}
|
|
1296
1306
|
|
|
1297
1307
|
/**
|
|
1298
|
-
* Index of the newest compaction
|
|
1299
|
-
* `-1` when none can.
|
|
1308
|
+
* Index of the newest compaction boundary reusable under preparation policy,
|
|
1309
|
+
* or `-1` when none can be reused (see {@link remotePreserveReusable}).
|
|
1300
1310
|
*
|
|
1301
1311
|
* A provider-native remote compaction (V2 or V1) stores an opaque replay payload
|
|
1302
1312
|
* and only a placeholder summary, so for any OTHER provider that entry
|
|
@@ -1331,14 +1341,16 @@ export function prepareCompaction(
|
|
|
1331
1341
|
activeModel?: Model,
|
|
1332
1342
|
tokenizer: Tokenizer = new Tokenizer(activeModel),
|
|
1333
1343
|
): CompactionPreparation | undefined {
|
|
1334
|
-
|
|
1344
|
+
const lastEntry = pathEntries[pathEntries.length - 1];
|
|
1345
|
+
// A speculative native record may leave uncovered messages before the record.
|
|
1346
|
+
if (lastEntry?.type === "compaction" && !lastEntry.providerReplayThroughEntryId) {
|
|
1335
1347
|
return undefined;
|
|
1336
1348
|
}
|
|
1337
1349
|
|
|
1338
1350
|
let prevCompactionIndex = findReadableCompactionIndex(pathEntries, settings, activeModel);
|
|
1339
1351
|
|
|
1340
|
-
// A reset
|
|
1341
|
-
//
|
|
1352
|
+
// A newer reset clears the previous summary. An older reset bounds both
|
|
1353
|
+
// the local retained tail and the native snapshot-to-commit interval.
|
|
1342
1354
|
let resetBoundaryIndex = -1;
|
|
1343
1355
|
for (let i = pathEntries.length - 1; i >= 0; i--) {
|
|
1344
1356
|
if (pathEntries[i].type === "reset_boundary") {
|
|
@@ -1354,9 +1366,20 @@ export function prepareCompaction(
|
|
|
1354
1366
|
let boundaryStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
|
|
1355
1367
|
if (
|
|
1356
1368
|
previousCompaction &&
|
|
1357
|
-
|
|
1358
|
-
|
|
1369
|
+
(getCompactionV2PreserveData(previousCompaction.preserveData) ||
|
|
1370
|
+
getPreservedOpenAiRemoteCompactionData(previousCompaction.preserveData))
|
|
1359
1371
|
) {
|
|
1372
|
+
if (previousCompaction.providerReplayThroughEntryId) {
|
|
1373
|
+
const replayThroughIndex = pathEntries.findIndex(
|
|
1374
|
+
entry => entry.id === previousCompaction.providerReplayThroughEntryId,
|
|
1375
|
+
);
|
|
1376
|
+
if (replayThroughIndex >= 0 && replayThroughIndex < prevCompactionIndex) {
|
|
1377
|
+
// Native replay covers the snapshot, not messages appended while the
|
|
1378
|
+
// request was running. Include that interval in the next preparation.
|
|
1379
|
+
boundaryStart = Math.max(replayThroughIndex, resetBoundaryIndex) + 1;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
} else if (previousCompaction) {
|
|
1360
1383
|
// Local summaries exclude the retained tail, whose original entries precede
|
|
1361
1384
|
// the compaction record. Native replay already carries that tail. Only look
|
|
1362
1385
|
// backwards: advisor snapshots put all retained messages after the summary
|
|
@@ -1605,12 +1628,21 @@ export async function compact(
|
|
|
1605
1628
|
const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
|
|
1606
1629
|
? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText)
|
|
1607
1630
|
: undefined;
|
|
1631
|
+
const previousNativeHistory =
|
|
1632
|
+
getCompactionV2PreserveData(previousPreserveData) ?? getPreservedOpenAiRemoteCompactionData(previousPreserveData);
|
|
1633
|
+
// A local summary has no native payload to carry it into the first remote
|
|
1634
|
+
// request. Encode it as history; do not resend opaque native placeholders.
|
|
1635
|
+
const previousSummaryMigrationMessage =
|
|
1636
|
+
settings.remoteEnabled !== false && previousSummary && !previousNativeHistory
|
|
1637
|
+
? createCompactionSummaryMessage(previousSummary, tokensBefore, new Date().toISOString())
|
|
1638
|
+
: undefined;
|
|
1608
1639
|
|
|
1609
1640
|
let preserveData = withAnthropicCompactionPreserveData(
|
|
1610
1641
|
withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
|
|
1611
1642
|
undefined,
|
|
1612
1643
|
);
|
|
1613
1644
|
const remoteMessages: AgentMessage[] = [
|
|
1645
|
+
...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
|
|
1614
1646
|
...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
|
|
1615
1647
|
...messagesToSummarize,
|
|
1616
1648
|
...turnPrefixMessages,
|
|
@@ -37,6 +37,8 @@ export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
|
37
37
|
shortSummary?: string;
|
|
38
38
|
firstKeptEntryId: string;
|
|
39
39
|
tokensBefore: number;
|
|
40
|
+
/** Last entry covered by native replay; later entries may precede the compaction record. */
|
|
41
|
+
providerReplayThroughEntryId?: string;
|
|
40
42
|
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
41
43
|
details?: T;
|
|
42
44
|
/** Hook-provided data to persist across compaction */
|
package/src/compaction/openai.ts
CHANGED
|
@@ -282,15 +282,22 @@ export interface RemoteCompactionResponse {
|
|
|
282
282
|
// OpenAI provider gating + endpoint resolution
|
|
283
283
|
// ============================================================================
|
|
284
284
|
|
|
285
|
-
function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
285
|
+
export function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean {
|
|
286
286
|
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
export function shouldUseOpenAiRemoteCompaction(model: Model): boolean {
|
|
290
290
|
if (model.remoteCompaction?.enabled === false) return false;
|
|
291
|
-
|
|
291
|
+
const compactionApi = model.remoteCompaction?.api ?? model.api;
|
|
292
|
+
// ChatGPT's Codex backend exposes V2 compaction on /codex/responses, but
|
|
293
|
+
// does not expose the OpenAI V1 /responses/compact endpoint. Only use the
|
|
294
|
+
// V1 path for Codex when an explicit compatible endpoint was configured.
|
|
295
|
+
if (model.provider === "openai-codex") {
|
|
296
|
+
return (model.remoteCompaction?.endpoint?.trim().length ?? 0) > 0;
|
|
297
|
+
}
|
|
298
|
+
if (model.provider === "openai") return true;
|
|
292
299
|
if (model.remoteCompaction?.enabled !== true) return false;
|
|
293
|
-
return isOpenAiRemoteCompactionApi(
|
|
300
|
+
return isOpenAiRemoteCompactionApi(compactionApi);
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
function resolveOpenAiCompactEndpoint(model: Model): string {
|
package/src/proxy.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type StreamingPartialJsonCarrier,
|
|
21
21
|
setStreamingPartialJson,
|
|
22
22
|
} from "@linxiraos/pi-ai/utils/block-symbols";
|
|
23
|
-
import { parseStreamingJson, readSseJson } from "@linxiraos/pi-utils";
|
|
23
|
+
import { parseStreamingJson, parseStreamingJsonThrottled, readSseJson } from "@linxiraos/pi-utils";
|
|
24
24
|
|
|
25
25
|
// Event stream adapter for proxy SSE events
|
|
26
26
|
export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
|
|
@@ -117,6 +117,10 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
|
|
117
117
|
};
|
|
118
118
|
|
|
119
119
|
let response: Response | null = null;
|
|
120
|
+
// Declared outside try so the disconnect path (catch) can finalize
|
|
121
|
+
// still-buffered tool-call arguments before emitting the error event.
|
|
122
|
+
const partialJsonByIndex = new Map<number, string>();
|
|
123
|
+
const parsedLenByIndex = new Map<number, number>();
|
|
120
124
|
const abortHandler = () => {
|
|
121
125
|
const body = response?.body;
|
|
122
126
|
if (body) {
|
|
@@ -165,12 +169,11 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
|
|
165
169
|
}
|
|
166
170
|
|
|
167
171
|
let sawTerminalEvent = false;
|
|
168
|
-
const partialJsonByIndex = new Map<number, string>();
|
|
169
172
|
for await (const event of readSseJson<ProxyAssistantMessageEvent>(
|
|
170
173
|
response.body as ReadableStream<Uint8Array>,
|
|
171
174
|
options.signal,
|
|
172
175
|
)) {
|
|
173
|
-
const parsedEvent = processProxyEvent(event, partial, partialJsonByIndex);
|
|
176
|
+
const parsedEvent = processProxyEvent(event, partial, partialJsonByIndex, parsedLenByIndex);
|
|
174
177
|
if (parsedEvent) {
|
|
175
178
|
if (parsedEvent.type === "done" || parsedEvent.type === "error") {
|
|
176
179
|
sawTerminalEvent = true;
|
|
@@ -193,6 +196,10 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
|
|
|
193
196
|
const reason = options.signal?.aborted ? "aborted" : "error";
|
|
194
197
|
partial.stopReason = reason;
|
|
195
198
|
partial.errorMessage = errorMessage;
|
|
199
|
+
// Disconnect/abort path: no terminal event ran, so trailing
|
|
200
|
+
// throttled deltas never reached content.arguments. Finalize
|
|
201
|
+
// them here so the persisted/retried transcript is complete.
|
|
202
|
+
finalizeBufferedArguments(partial, partialJsonByIndex);
|
|
196
203
|
scrubPartialJson(partial);
|
|
197
204
|
stream.push({
|
|
198
205
|
type: "error",
|
|
@@ -235,6 +242,7 @@ function processProxyEvent(
|
|
|
235
242
|
proxyEvent: ProxyAssistantMessageEvent,
|
|
236
243
|
partial: AssistantMessage,
|
|
237
244
|
partialJsonByIndex: Map<number, string>,
|
|
245
|
+
parsedLenByIndex: Map<number, number>,
|
|
238
246
|
): AssistantMessageEvent | undefined {
|
|
239
247
|
switch (proxyEvent.type) {
|
|
240
248
|
case "start":
|
|
@@ -335,13 +343,22 @@ function processProxyEvent(
|
|
|
335
343
|
[kStreamingPartialJson]: "",
|
|
336
344
|
} as ToolCall & StreamingPartialJsonCarrier;
|
|
337
345
|
partialJsonByIndex.set(proxyEvent.contentIndex, "");
|
|
346
|
+
parsedLenByIndex.set(proxyEvent.contentIndex, 0);
|
|
338
347
|
return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
|
|
339
348
|
case "toolcall_delta": {
|
|
340
349
|
const content = partial.content[proxyEvent.contentIndex];
|
|
341
350
|
if (content?.type === "toolCall") {
|
|
342
351
|
const acc = (partialJsonByIndex.get(proxyEvent.contentIndex) ?? "") + proxyEvent.delta;
|
|
343
352
|
partialJsonByIndex.set(proxyEvent.contentIndex, acc);
|
|
344
|
-
|
|
353
|
+
// Geometric throttle (same contract as native providers): the
|
|
354
|
+
// authoritative parse lands at toolcall_end; mid-stream
|
|
355
|
+
// previews refresh at most ~3% late on large buffers.
|
|
356
|
+
const lastLen = parsedLenByIndex.get(proxyEvent.contentIndex) ?? 0;
|
|
357
|
+
const parsed = parseStreamingJsonThrottled(acc, lastLen);
|
|
358
|
+
if (parsed !== null) {
|
|
359
|
+
content.arguments = parsed.value || {};
|
|
360
|
+
parsedLenByIndex.set(proxyEvent.contentIndex, parsed.parsedLen);
|
|
361
|
+
}
|
|
345
362
|
setStreamingPartialJson(content, acc);
|
|
346
363
|
partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
|
|
347
364
|
return {
|
|
@@ -357,7 +374,13 @@ function processProxyEvent(
|
|
|
357
374
|
case "toolcall_end": {
|
|
358
375
|
const content = partial.content[proxyEvent.contentIndex];
|
|
359
376
|
if (content?.type === "toolCall") {
|
|
377
|
+
// Authoritative final parse (mirrors native providers): the
|
|
378
|
+
// throttle may have skipped trailing deltas, so the last
|
|
379
|
+
// parsed arguments can lag the accumulated buffer.
|
|
380
|
+
const acc = partialJsonByIndex.get(proxyEvent.contentIndex);
|
|
381
|
+
if (acc !== undefined && acc.length > 0) content.arguments = parseStreamingJson(acc) || {};
|
|
360
382
|
partialJsonByIndex.delete(proxyEvent.contentIndex);
|
|
383
|
+
parsedLenByIndex.delete(proxyEvent.contentIndex);
|
|
361
384
|
clearStreamingPartialJson(content);
|
|
362
385
|
return {
|
|
363
386
|
type: "toolcall_end",
|
|
@@ -373,7 +396,10 @@ function processProxyEvent(
|
|
|
373
396
|
partial.stopReason = proxyEvent.reason;
|
|
374
397
|
partial.usage = proxyEvent.usage;
|
|
375
398
|
if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
|
|
399
|
+
else finalizeBufferedArguments(partial, partialJsonByIndex);
|
|
376
400
|
scrubPartialJson(partial);
|
|
401
|
+
partialJsonByIndex.clear();
|
|
402
|
+
parsedLenByIndex.clear();
|
|
377
403
|
return { type: "done", reason: proxyEvent.reason, message: partial };
|
|
378
404
|
|
|
379
405
|
case "error":
|
|
@@ -381,7 +407,26 @@ function processProxyEvent(
|
|
|
381
407
|
partial.errorMessage = proxyEvent.errorMessage;
|
|
382
408
|
partial.usage = proxyEvent.usage;
|
|
383
409
|
if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
|
|
410
|
+
else finalizeBufferedArguments(partial, partialJsonByIndex);
|
|
384
411
|
scrubPartialJson(partial);
|
|
412
|
+
partialJsonByIndex.clear();
|
|
413
|
+
parsedLenByIndex.clear();
|
|
385
414
|
return { type: "error", reason: proxyEvent.reason, error: partial };
|
|
386
415
|
}
|
|
387
416
|
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Authoritatively parse every still-buffered tool-call argument accumulator
|
|
420
|
+
* into its content block. Terminal `done`/`error` events without a preceding
|
|
421
|
+
* `toolcall_end` (and with `content` omitted) finalize the partial message as
|
|
422
|
+
*-is; without this, trailing deltas below the throttle gate never reach
|
|
423
|
+
* `content.arguments` and the finalized call carries stale arguments.
|
|
424
|
+
*/
|
|
425
|
+
function finalizeBufferedArguments(partial: AssistantMessage, partialJsonByIndex: Map<number, string>): void {
|
|
426
|
+
if (partialJsonByIndex.size === 0) return;
|
|
427
|
+
for (const [index, acc] of partialJsonByIndex) {
|
|
428
|
+
if (acc.length === 0) continue;
|
|
429
|
+
const content = partial.content[index];
|
|
430
|
+
if (content?.type === "toolCall") content.arguments = parseStreamingJson(acc) || {};
|
|
431
|
+
}
|
|
432
|
+
}
|
package/src/telemetry.ts
CHANGED
|
@@ -1756,6 +1756,7 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
|
|
|
1756
1756
|
const message = span.retry
|
|
1757
1757
|
? await retryTransientCompletion(runOnce, {
|
|
1758
1758
|
...span.retry,
|
|
1759
|
+
provider: model.provider,
|
|
1759
1760
|
// Framework-owned: the caller must not be able to detach the
|
|
1760
1761
|
// abort signal or the header source by passing them itself.
|
|
1761
1762
|
signal: options.signal,
|
package/src/thinking.ts
CHANGED
package/src/tokenizer.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -31,6 +31,18 @@ export type StreamFn = (
|
|
|
31
31
|
...args: Parameters<typeof streamSimple>
|
|
32
32
|
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
|
|
33
33
|
|
|
34
|
+
/** Staged queue preparation; commit synchronously only while the batch is still owned. */
|
|
35
|
+
export interface QueuedMessagePreparation {
|
|
36
|
+
/** Append context after the originals; undefined stops this attempt, retaining originals unless explicitly removed. */
|
|
37
|
+
commit(): readonly AgentMessage[] | undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Prepare an exclusively claimed batch. Undefined delivers unchanged; the signal also aborts when the claim is cancelled. */
|
|
41
|
+
export type PrepareQueuedMessages = (
|
|
42
|
+
messages: readonly AgentMessage[],
|
|
43
|
+
signal: AbortSignal,
|
|
44
|
+
) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
|
|
45
|
+
|
|
34
46
|
/** Called once an aside has been inserted into the agent's live context. */
|
|
35
47
|
export const ASIDE_MESSAGE_COMMIT = Symbol("aside-message-commit");
|
|
36
48
|
/** Symbol-keyed handoff for one finalized, tool-owned stream speculation session. */
|
|
@@ -1000,8 +1012,9 @@ export interface AgentTool<
|
|
|
1000
1012
|
* Called at `toolcall_start`, before any argument delta. Return `undefined` to opt out.
|
|
1001
1013
|
*/
|
|
1002
1014
|
openArgStream?: (init: AgentToolArgStreamInit) => AgentToolArgStream | undefined;
|
|
1003
|
-
/** If true, tool is excluded unless explicitly listed in --tools or agent's tools field */
|
|
1004
1015
|
hidden?: boolean;
|
|
1016
|
+
/** If true, the tool can read `skill://<name>` instruction content; prompt builders gate skill guidance on it. */
|
|
1017
|
+
readsSkillUris?: boolean;
|
|
1005
1018
|
/** If true, tool can stage a pending action that requires explicit resolution via the resolve tool. */
|
|
1006
1019
|
deferrable?: boolean;
|
|
1007
1020
|
/** How an enabled tool is presented. See {@link ToolLoadMode}. Omitted is treated as `"essential"` for built-ins; custom-tool adapters normalize omission to `"discoverable"`. */
|