@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.
@@ -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
- if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
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
- const entry = entries[i];
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 cut point in session entries that keeps approximately `keepRecentTokens`.
504
+ * Find the oldest complete recent-history suffix that fits `keepRecentTokens`.
495
505
  *
496
- * Algorithm: Walk backwards from newest, accumulating estimated message sizes.
497
- * Stop when we've accumulated >= keepRecentTokens. Cut at that point.
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
- // Walk backwards from newest, accumulating estimated message sizes
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 cutIndex = cutPoints[0]; // Default: keep from first message (not header)
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
- if (entry.type !== "message") continue;
529
-
530
- // Estimate this message's size
531
- const messageTokens = tokenizer.countMessage(entry.message);
532
- accumulatedTokens += messageTokens;
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
- // Scan backwards from cutIndex to include any non-message entries (bash, settings, etc.)
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 compaction boundaries
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.type === "message") {
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 (bash, settings change, etc.)
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: !isUserMessage && turnStartIndex !== -1,
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 a prior remote compaction's provider-native replay can still be read
1271
- * by the active model — the model that assembles the request context on every
1272
- * turn. A local compaction (no remote preserve) always can: it holds a real
1273
- * textual summary. A remote compaction (V2 or V1) only can when the active model
1274
- * shares the blob's provider AND remote replay is still enabled; otherwise the
1275
- * active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
1276
- * and only the opaque placeholder summary survives, so the caller must re-expand
1277
- * the originals into a portable local summary rather than strand that history.
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
- * Judged against the ACTIVE model, not the compaction candidate set: a role
1280
- * model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
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
- if (settings.remoteEnabled === false) return false;
1292
- if (remote.provider !== activeModel.provider) return false;
1293
- const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
1294
- return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
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 entry the active model can actually read, or
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
- if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
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 after the reusable compaction clears its summary too. An older
1341
- // reset still bounds how far we may recover that compaction's kept messages.
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
- !getCompactionV2PreserveData(previousCompaction.preserveData) &&
1358
- !getPreservedOpenAiRemoteCompactionData(previousCompaction.preserveData)
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 */
@@ -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
- if (model.provider === "openai" || model.provider === "openai-codex") return true;
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(model.remoteCompaction.api ?? model.api);
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
- content.arguments = parseStreamingJson(acc) || {};
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
@@ -1,4 +1,4 @@
1
- import { Effort } from "@linxiraos/pi-ai";
1
+ import { Effort } from "@linxiraos/pi-catalog/effort";
2
2
 
3
3
  /**
4
4
  * Agent-local thinking selector.
package/src/tokenizer.ts CHANGED
@@ -274,6 +274,7 @@ export class Tokenizer {
274
274
  }
275
275
  break;
276
276
  }
277
+ case "custom":
277
278
  case "hookMessage":
278
279
  case "toolResult": {
279
280
  if (typeof message.content === "string") {
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"`. */