@oh-my-pi/pi-agent-core 18.10.4 → 18.11.0

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 CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.0] - 2026-10-14
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed `ApprovalDetailsOptions`; `formatApprovalDetails` takes only `args` again, since the compact approval prompt no longer shows tool details ([#15642](https://github.com/can1357/oh-my-pi/pull/15642) by [@H4vC](https://github.com/H4vC))
10
+
11
+ ### Added
12
+
13
+ - `AgentTool.delegationGuidance`: instructions a same-name extension replacement inherits in its description when it delegates to the native tool ([#15477](https://github.com/can1357/oh-my-pi/pull/15477) by [@iliaal](https://github.com/iliaal))
14
+ - `CompactionPreparation.lastSummarizedEntryId` and `CompactionResult.providerReplayThroughEntryId`: a remote compaction that could only fit the history before the kept tail reports the last entry its native history covers; later entries replay normally ([#15712](https://github.com/can1357/oh-my-pi/pull/15712) by [@H4vC](https://github.com/H4vC))
15
+
16
+ ### Fixed
17
+
18
+ - Fixed native (OpenAI/Codex) remote compaction refusing or truncating sessions that fit the window: its preflight counted the JSON-escaped request, inflating quote-, backslash- and newline-heavy tool output such as disassembly by ~10–20%; it now counts text as the model reads it ([#15712](https://github.com/can1357/oh-my-pi/pull/15712) by [@H4vC](https://github.com/H4vC))
19
+ - Fixed OpenAI V1/V2 remote compaction failing outright when the history, kept tail included, exceeds the context window (a large final response or tool output after the last check); it retries on the history before the kept tail ([#15712](https://github.com/can1357/oh-my-pi/pull/15712) by [@H4vC](https://github.com/H4vC))
20
+ - Fixed fitted output caps overflowing the context window on strict hosts (llama.cpp-family) after turns with many parallel tool results; chat-template framing is now charged per locally counted message ([#15699](https://github.com/can1357/oh-my-pi/issues/15699))
21
+ - Fixed leaked DeepSeek DSML markup reaching saved history from reasoning blocks and from turns that also made a valid tool call ([#15485](https://github.com/can1357/oh-my-pi/issues/15485))
22
+
5
23
  ## [18.10.4] - 2026-10-13
6
24
 
7
25
  ### Added
@@ -28,6 +28,12 @@ export interface CompactionResult<T = unknown> {
28
28
  details?: T;
29
29
  /** Hook-provided data to persist alongside compaction entry. */
30
30
  preserveData?: Record<string, unknown>;
31
+ /**
32
+ * Last entry the provider-native replay history covers. Set when a remote
33
+ * compaction could only fit the history before the kept tail; later entries
34
+ * replay normally after the native history.
35
+ */
36
+ providerReplayThroughEntryId?: string;
31
37
  }
32
38
  export interface CompactionSettings {
33
39
  enabled: boolean;
@@ -321,6 +327,8 @@ export interface CompactionPreparation {
321
327
  recentMessages: AgentMessage[];
322
328
  /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
323
329
  recentEntryIds?: string[];
330
+ /** Last entry before the kept tail; a remote compaction that cannot fit the tail covers history through it. */
331
+ lastSummarizedEntryId?: string;
324
332
  /** Whether this is a split turn (cut point in middle of turn) */
325
333
  isSplitTurn: boolean;
326
334
  tokensBefore: number;
@@ -31,7 +31,7 @@ export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
31
31
  /**
32
32
  * Tokens of stored Responses replacement history. Retained `input_image` parts
33
33
  * count at their image estimate rather than as base64 text; everything else,
34
- * opaque compaction state included, counts as serialized.
34
+ * opaque compaction state included, counts as decoded text.
35
35
  */
36
36
  export declare function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number;
37
37
  export interface TrimRemoteCompactionInputResult {
@@ -46,6 +46,15 @@ export interface TrimRemoteCompactionInputResult {
46
46
  */
47
47
  fits: boolean;
48
48
  }
49
+ /** Options for {@link trimRemoteCompactionInputToContextWindow}. */
50
+ export interface TrimRemoteCompactionOptions {
51
+ /**
52
+ * Whether oversized trailing tool outputs may be rewritten (default true).
53
+ * False when a caller can compact a shorter prefix instead, so a kept tool
54
+ * result replays intact rather than being replaced.
55
+ */
56
+ rewriteOutputs?: boolean;
57
+ }
49
58
  /**
50
59
  * Preserve the full native transcript unless trailing tool outputs alone push a
51
60
  * remote compaction request beyond the model window. Replacing only those
@@ -54,7 +63,7 @@ export interface TrimRemoteCompactionInputResult {
54
63
  * fits but whose image estimate does not is still sendable: refusing it on an
55
64
  * image guess would skip native compaction the provider would have accepted.
56
65
  */
57
- export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, tokenizer: Tokenizer, contextWindow: number | null | undefined, instructions: string, tools?: unknown[]): TrimRemoteCompactionInputResult;
66
+ export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, tokenizer: Tokenizer, contextWindow: number | null | undefined, instructions: string, tools?: unknown[], options?: TrimRemoteCompactionOptions): TrimRemoteCompactionInputResult;
58
67
  /**
59
68
  * Refuse a native compaction request whose prepared input cannot fit the model
60
69
  * window, before any network I/O. Re-expanded history behind an unreadable
@@ -117,6 +126,8 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
117
126
  sessionId?: string;
118
127
  providerSessionState?: Map<string, ProviderSessionState>;
119
128
  codexCompaction?: CodexCompactionContext;
129
+ /** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
130
+ rewriteOutputs?: boolean;
120
131
  }): Promise<OpenAiRemoteCompactionResponse>;
121
132
  /**
122
133
  * Generic remote-compaction POST. Two wire shapes are auto-selected by
@@ -2,14 +2,22 @@ import type { Context, Model } from "@oh-my-pi/pi-ai";
2
2
  import type { Tokenizer } from "./tokenizer.js";
3
3
  /** Smallest output cap {@link fitOutputTokensToContextWindow} will request. */
4
4
  export declare const MIN_FITTED_OUTPUT_TOKENS = 1024;
5
+ /**
6
+ * Chat-template framing charged per locally counted message. `Tokenizer.countMessage`
7
+ * counts content only, but every template wraps each message in role markers and
8
+ * separators, and tool results in response wrappers: about 5 tokens per merged
9
+ * Qwen3 `<tool_response>` or Llama 3 turn, 10–15 for harmony or Mistral tool results.
10
+ * The proportional margin scales with content, so many short messages (a turn's
11
+ * parallel tool results) leave this uncovered. Provider-anchored messages need no
12
+ * charge: the host's own report already includes their framing.
13
+ */
14
+ export declare const MESSAGE_FRAMING_TOKENS = 16;
5
15
  /**
6
16
  * Absolute headway subtracted from the remaining room (unconditionally, on anchored and
7
- * fully-local counts alike). Proportional padding covers
8
- * tokenizer drift that scales with the prompt, but a host can still count a few tokens more
9
- * than any local estimate can see (chat-template framing, reasoning wrappers). Measured
10
- * against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were rejected
11
- * with 400s. 64 tokens covers the observed drift with margin to spare; the
12
- * {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
17
+ * fully-local counts alike). Covers per-request framing no message count sees: the
18
+ * generation prompt, the anchoring turn's end marker, reasoning wrappers. Measured
19
+ * against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were
20
+ * rejected with 400s. The {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
13
21
  */
14
22
  export declare const OUTPUT_FIT_HEADWAY_TOKENS = 64;
15
23
  /**
@@ -27,7 +35,9 @@ export declare const OUTPUT_FIT_HEADWAY_TOKENS = 64;
27
35
  * The prompt size is the provider's own report from the newest trustworthy
28
36
  * assistant turn (see {@link findRequestUsageAnchor}) plus a local count of
29
37
  * only the messages appended after it; the whole context is counted locally
30
- * only when no turn can anchor (fresh or freshly rewritten context).
38
+ * only when no turn can anchor (fresh or freshly rewritten context). Local
39
+ * counts carry a proportional tokenizer margin plus
40
+ * {@link MESSAGE_FRAMING_TOKENS} per message.
31
41
  *
32
42
  * Returns `maxTokens` unchanged when the requested cap already fits, the
33
43
  * model declares no window, the host ends generation at the window itself
@@ -637,19 +637,10 @@ export interface SpeculativePhysicalOutcome {
637
637
  /** Opaque evidence binding the result to the local bytes actually consumed. */
638
638
  evidence?: unknown;
639
639
  }
640
- /** How an approval prompt will show what `formatApprovalDetails` returns. */
641
- export interface ApprovalDetailsOptions {
642
- /**
643
- * The prompt keeps only each detail's first line plus a count of the rest (the transcript
644
- * shows the call in full): return bodies untruncated so the count is the whole value's.
645
- * The host truncates the rows it keeps. Otherwise truncate long values as usual.
646
- */
647
- compact?: boolean;
648
- }
649
640
  export interface SpeculativeToolReference {
650
641
  name: string;
651
642
  approval?: ToolApproval;
652
- formatApprovalDetails?: (args: unknown, options?: ApprovalDetailsOptions) => string | string[] | undefined;
643
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
653
644
  }
654
645
  export interface SpeculativeOperationContext extends ToolSpeculationExecutionContext {
655
646
  tool: SpeculativeToolReference;
@@ -1018,6 +1009,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
1018
1009
  loadMode?: ToolLoadMode;
1019
1010
  /** Short one-line summary used for tool discovery indexes. */
1020
1011
  summary?: string;
1012
+ /**
1013
+ * Instructions a same-name extension replacement inherits in its description, because results it
1014
+ * obtains by delegating to this native tool through `ctx.invokeTool` follow this tool's contract.
1015
+ */
1016
+ readonly delegationGuidance?: string;
1021
1017
  /**
1022
1018
  * On-demand documentation topics (`topic → markdown`), readable as
1023
1019
  * `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
@@ -1091,7 +1087,7 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
1091
1087
  /** Capability tier declaration used by approval gates. Omitted means "exec". */
1092
1088
  approval?: ToolApproval;
1093
1089
  /** Lines appended after the standard approval prompt header. */
1094
- formatApprovalDetails?: (args: unknown, options?: ApprovalDetailsOptions) => string | string[] | undefined;
1090
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
1095
1091
  /** The main execution callback for this tool. */
1096
1092
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
1097
1093
  /** Optional custom rendering for tool call display (returns UI component) */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.10.4",
4
+ "version": "18.11.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.10.4",
42
- "@oh-my-pi/pi-catalog": "18.10.4",
43
- "@oh-my-pi/pi-natives": "18.10.4",
44
- "@oh-my-pi/pi-utils": "18.10.4",
45
- "@oh-my-pi/pi-wire": "18.10.4",
46
- "@oh-my-pi/snapcompact": "18.10.4",
41
+ "@oh-my-pi/pi-ai": "18.11.0",
42
+ "@oh-my-pi/pi-catalog": "18.11.0",
43
+ "@oh-my-pi/pi-natives": "18.11.0",
44
+ "@oh-my-pi/pi-utils": "18.11.0",
45
+ "@oh-my-pi/pi-wire": "18.11.0",
46
+ "@oh-my-pi/snapcompact": "18.11.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.10.4",
50
+ "@oh-my-pi/omptype": "18.11.0",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -2228,17 +2228,15 @@ async function streamAssistantResponse(
2228
2228
  }
2229
2229
  }
2230
2230
  finalMessage = snapshotAssistantMessage(finalMessage);
2231
- // Unhealed DSML tool-call markup must never reach history: replaying
2232
- // it teaches the model to keep writing calls as text (#10556). Strip
2233
- // it before the context, the UI, and the session see the message.
2234
- // Only DSML-speaking models qualify; from others it is prose.
2235
- if (
2236
- dsmlLeakRecoveryEnabled &&
2237
- finalMessage.stopReason === "stop" &&
2238
- !finalMessage.content.some(block => block.type === "toolCall") &&
2239
- removeDsmlToolMarkupLeak(finalMessage)
2240
- ) {
2241
- dsmlLeakMessages.add(finalMessage);
2231
+ // Unhealed DSML markup must not reach history, including reasoning
2232
+ // and turns with a valid structured call. Only a failed text-only
2233
+ // call needs the recovery nudge.
2234
+ if (dsmlLeakRecoveryEnabled) {
2235
+ const hasToolCall = finalMessage.content.some(block => block.type === "toolCall");
2236
+ const removed = removeDsmlToolMarkupLeak(finalMessage);
2237
+ if (removed && finalMessage.stopReason === "stop" && !hasToolCall) {
2238
+ dsmlLeakMessages.add(finalMessage);
2239
+ }
2242
2240
  }
2243
2241
  // Expand inline macros (and any other registered rewrite) on the
2244
2242
  // finalized message before it reaches the context, the UI, or tool
@@ -152,6 +152,12 @@ export interface CompactionResult<T = unknown> {
152
152
  details?: T;
153
153
  /** Hook-provided data to persist alongside compaction entry. */
154
154
  preserveData?: Record<string, unknown>;
155
+ /**
156
+ * Last entry the provider-native replay history covers. Set when a remote
157
+ * compaction could only fit the history before the kept tail; later entries
158
+ * replay normally after the native history.
159
+ */
160
+ providerReplayThroughEntryId?: string;
155
161
  }
156
162
 
157
163
  // ============================================================================
@@ -1268,6 +1274,8 @@ export interface CompactionPreparation {
1268
1274
  recentMessages: AgentMessage[];
1269
1275
  /** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
1270
1276
  recentEntryIds?: string[];
1277
+ /** Last entry before the kept tail; a remote compaction that cannot fit the tail covers history through it. */
1278
+ lastSummarizedEntryId?: string;
1271
1279
  /** Whether this is a split turn (cut point in middle of turn) */
1272
1280
  isSplitTurn: boolean;
1273
1281
  tokensBefore: number;
@@ -1480,6 +1488,7 @@ export function prepareCompaction(
1480
1488
  turnPrefixMessages,
1481
1489
  recentMessages,
1482
1490
  recentEntryIds,
1491
+ lastSummarizedEntryId: compactionEntries[cutPoint.firstKeptEntryIndex - 1]?.id,
1483
1492
  isSplitTurn: cutPoint.isSplitTurn,
1484
1493
  tokensBefore,
1485
1494
  previousSummary: previousCompaction?.summary,
@@ -1587,6 +1596,13 @@ function formatRemoteCompactionSummary(inputTokens: number): string {
1587
1596
  );
1588
1597
  }
1589
1598
 
1599
+ /** A V2 remote-compaction request's history and the user items its replacement history retains. */
1600
+ interface RemoteCompactionV2Input {
1601
+ codexBody: OpenAICodexCompactionBody | undefined;
1602
+ remoteHistory: Array<Record<string, unknown>>;
1603
+ retainedUserItems: unknown[];
1604
+ }
1605
+
1590
1606
  /**
1591
1607
  * Generate summaries for compaction using prepared data.
1592
1608
  * Returns CompactionResult - SessionManager adds id/parentId when saving.
@@ -1608,6 +1624,7 @@ export async function compact(
1608
1624
  turnPrefixMessages,
1609
1625
  recentMessages,
1610
1626
  recentEntryIds,
1627
+ lastSummarizedEntryId,
1611
1628
  isSplitTurn,
1612
1629
  tokensBefore,
1613
1630
  previousSummary,
@@ -1688,15 +1705,50 @@ export async function compact(
1688
1705
  withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
1689
1706
  undefined,
1690
1707
  );
1691
- const remoteMessages: AgentMessage[] = [
1708
+ const remotePrefixMessages: AgentMessage[] = [
1692
1709
  ...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
1693
1710
  ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1694
1711
  ...messagesToSummarize,
1695
1712
  ...turnPrefixMessages,
1696
- ...recentMessages,
1697
1713
  ];
1714
+ const remoteMessages: AgentMessage[] = [...remotePrefixMessages, ...recentMessages];
1698
1715
  let usedRemoteCompaction = false;
1716
+ let remoteReplayThroughEntryId: string | undefined;
1699
1717
  let nativeCompactionError: unknown;
1718
+ /**
1719
+ * Remote compaction replaces the whole history, so its request carries the
1720
+ * kept tail too. A turn can leave that history over the window (a large
1721
+ * response or tool output after the last check), which no request can
1722
+ * send. Then compact the history before the tail instead; the tail replays
1723
+ * normally after the native history, as a speculative snapshot's later
1724
+ * turns do. While that fallback exists, the whole-history attempt must not
1725
+ * rewrite the tail's trailing tool outputs: the prefix keeps them intact.
1726
+ */
1727
+ const canCompactBeforeTail = lastSummarizedEntryId !== undefined && recentMessages.length > 0;
1728
+ const sendRemoteCompaction = async (
1729
+ sendWhole: () => Promise<void>,
1730
+ sendPrefix: () => Promise<boolean>,
1731
+ ): Promise<void> => {
1732
+ try {
1733
+ await sendWhole();
1734
+ } catch (err) {
1735
+ if (
1736
+ signal?.aborted ||
1737
+ !canCompactBeforeTail ||
1738
+ !AIError.is(AIError.classify(err), AIError.Flag.ContextOverflow)
1739
+ ) {
1740
+ throw err;
1741
+ }
1742
+ logger.info("Remote compaction history overflows the context window; compacting before the kept tail", {
1743
+ model: model.id,
1744
+ provider: model.provider,
1745
+ keptMessages: recentMessages.length,
1746
+ error: err instanceof Error ? err.message : String(err),
1747
+ });
1748
+ if (!(await sendPrefix())) throw err;
1749
+ remoteReplayThroughEntryId = lastSummarizedEntryId;
1750
+ }
1751
+ };
1700
1752
  if (
1701
1753
  settings.remoteEnabled !== false &&
1702
1754
  settings.remoteStreamingV2Enabled !== false &&
@@ -1708,126 +1760,147 @@ export async function compact(
1708
1760
  ? previousRemoteCompaction.replacementHistory
1709
1761
  : undefined;
1710
1762
  const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1711
- const messages = convertToLlm(remoteMessages);
1712
1763
  // Replacement history keeps what the user wrote. Summaries, archive
1713
1764
  // migrations, and custom/hook messages can serialize as user-role items
1714
1765
  // too, so pick the user's own messages before serialization erases that,
1715
1766
  // then serialize each one exactly as the request does.
1716
1767
  const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
1717
- const userMessages = convertToLlm(
1718
- [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
1719
- );
1720
- const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
1721
1768
  const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
1722
- let codexBody: OpenAICodexCompactionBody | undefined;
1723
- let remoteHistory: Array<Record<string, unknown>>;
1724
- if (isCodexResponsesModel(model)) {
1725
- const previousCodexInput: CodexInputItem[] = [];
1726
- for (const item of previousReplacementHistory ?? []) {
1727
- if (!isCodexInputItem(item)) {
1728
- throw new Error("Stored Codex V2 compaction history contains an invalid input item");
1729
- }
1730
- previousCodexInput.push(item);
1731
- }
1732
- codexBody = await buildTransformedCodexRequestBody(
1733
- model,
1734
- { systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
1735
- {
1736
- reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1737
- forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1738
- responsesLite: model.useResponsesLite,
1739
- sessionId: summaryOptions.sessionId,
1740
- promptCacheKey: summaryOptions.promptCacheKey,
1741
- providerSessionState: summaryOptions.providerSessionState,
1742
- codexCompaction: createOpenAICodexCompactionRequestContext({
1743
- context: summaryOptions.codexCompaction,
1744
- implementation: "responses_compaction_v2",
1745
- }),
1746
- },
1747
- undefined,
1748
- previousCodexInput,
1769
+ const buildV2Input = async (includeTail: boolean): Promise<RemoteCompactionV2Input> => {
1770
+ const messages = convertToLlm(includeTail ? remoteMessages : remotePrefixMessages);
1771
+ const userMessages = convertToLlm(
1772
+ [...messagesToSummarize, ...turnPrefixMessages, ...(includeTail ? recentMessages : [])].filter(
1773
+ isUserAuthored,
1774
+ ),
1749
1775
  );
1750
- const input = Array.isArray(codexBody.input) ? codexBody.input : [];
1751
- const nativeInput: Array<Record<string, unknown>> = [];
1752
- for (const item of input) {
1753
- if (!isRecord(item)) {
1754
- throw new Error("Codex V2 compaction input contains a non-object item");
1776
+ const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
1777
+ let codexBody: OpenAICodexCompactionBody | undefined;
1778
+ let remoteHistory: Array<Record<string, unknown>>;
1779
+ if (isCodexResponsesModel(model)) {
1780
+ const previousCodexInput: CodexInputItem[] = [];
1781
+ for (const item of previousReplacementHistory ?? []) {
1782
+ if (!isCodexInputItem(item)) {
1783
+ throw new Error("Stored Codex V2 compaction history contains an invalid input item");
1784
+ }
1785
+ previousCodexInput.push(item);
1755
1786
  }
1756
- nativeInput.push(item);
1757
- }
1758
- remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1759
- codexBody.input = remoteHistory;
1760
- for (const message of userMessages) {
1761
- const userBody = await buildTransformedCodexRequestBody(
1787
+ codexBody = await buildTransformedCodexRequestBody(
1762
1788
  model,
1763
- { messages: [message] },
1764
- { responsesLite: model.useResponsesLite },
1789
+ { systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
1790
+ {
1791
+ reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
1792
+ forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1793
+ responsesLite: model.useResponsesLite,
1794
+ sessionId: summaryOptions.sessionId,
1795
+ promptCacheKey: summaryOptions.promptCacheKey,
1796
+ providerSessionState: summaryOptions.providerSessionState,
1797
+ codexCompaction: createOpenAICodexCompactionRequestContext({
1798
+ context: summaryOptions.codexCompaction,
1799
+ implementation: "responses_compaction_v2",
1800
+ }),
1801
+ },
1802
+ undefined,
1803
+ previousCodexInput,
1765
1804
  );
1766
- retainedUserItems.push(...(userBody.input ?? []));
1805
+ const input = Array.isArray(codexBody.input) ? codexBody.input : [];
1806
+ const nativeInput: Array<Record<string, unknown>> = [];
1807
+ for (const item of input) {
1808
+ if (!isRecord(item)) {
1809
+ throw new Error("Codex V2 compaction input contains a non-object item");
1810
+ }
1811
+ nativeInput.push(item);
1812
+ }
1813
+ remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
1814
+ codexBody.input = remoteHistory;
1815
+ for (const message of userMessages) {
1816
+ const userBody = await buildTransformedCodexRequestBody(
1817
+ model,
1818
+ { messages: [message] },
1819
+ { responsesLite: model.useResponsesLite },
1820
+ );
1821
+ retainedUserItems.push(...(userBody.input ?? []));
1822
+ }
1823
+ } else {
1824
+ remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1825
+ for (const message of userMessages) {
1826
+ retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1827
+ }
1767
1828
  }
1768
- } else {
1769
- remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
1770
- for (const message of userMessages) {
1771
- retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
1829
+ return { codexBody, remoteHistory, retainedUserItems };
1830
+ };
1831
+ const sendV2 = async (
1832
+ { codexBody, remoteHistory, retainedUserItems }: RemoteCompactionV2Input,
1833
+ rewriteOutputs: boolean,
1834
+ ): Promise<void> => {
1835
+ const instructions = codexBody
1836
+ ? typeof codexBody.instructions === "string"
1837
+ ? codexBody.instructions
1838
+ : ""
1839
+ : remoteSystemPrompt.join("\n\n");
1840
+ const tools = codexBody
1841
+ ? Array.isArray(codexBody.tools)
1842
+ ? codexBody.tools
1843
+ : undefined
1844
+ : summaryOptions.tools
1845
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1846
+ : undefined;
1847
+ const trimmed = trimRemoteCompactionInputToContextWindow(
1848
+ remoteHistory,
1849
+ new Tokenizer(model),
1850
+ model.contextWindow,
1851
+ instructions,
1852
+ tools,
1853
+ { rewriteOutputs },
1854
+ );
1855
+ if (trimmed.rewrittenOutputs > 0) {
1856
+ logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1857
+ model: model.id,
1858
+ provider: model.provider,
1859
+ rewrittenOutputs: trimmed.rewrittenOutputs,
1860
+ estimatedTokensBefore: trimmed.estimatedTokensBefore,
1861
+ estimatedTokensAfter: trimmed.estimatedTokensAfter,
1862
+ contextWindow: model.contextWindow,
1863
+ });
1772
1864
  }
1773
- }
1774
- if (remoteHistory.length > 0) {
1775
- try {
1776
- const instructions = codexBody
1777
- ? typeof codexBody.instructions === "string"
1778
- ? codexBody.instructions
1779
- : ""
1780
- : remoteSystemPrompt.join("\n\n");
1781
- const tools = codexBody
1782
- ? Array.isArray(codexBody.tools)
1783
- ? codexBody.tools
1784
- : undefined
1785
- : summaryOptions.tools
1786
- ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1787
- : undefined;
1788
- const trimmed = trimRemoteCompactionInputToContextWindow(
1789
- remoteHistory,
1790
- new Tokenizer(model),
1791
- model.contextWindow,
1792
- instructions,
1793
- tools,
1794
- );
1795
- if (trimmed.rewrittenOutputs > 0) {
1796
- logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
1797
- model: model.id,
1798
- provider: model.provider,
1799
- rewrittenOutputs: trimmed.rewrittenOutputs,
1800
- estimatedTokensBefore: trimmed.estimatedTokensBefore,
1801
- estimatedTokensAfter: trimmed.estimatedTokensAfter,
1802
- contextWindow: model.contextWindow,
1865
+ assertRemoteCompactionInputFits(trimmed, model);
1866
+ const requestOptions = {
1867
+ sessionId: summaryOptions.sessionId,
1868
+ promptCacheKey: summaryOptions.promptCacheKey,
1869
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1870
+ retainedUserItems,
1871
+ };
1872
+ const request = codexBody
1873
+ ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
1874
+ : buildCompactionV2Request(model, trimmed.input, instructions, {
1875
+ ...requestOptions,
1876
+ tools,
1877
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1803
1878
  });
1804
- }
1805
- assertRemoteCompactionInputFits(trimmed, model);
1806
- const requestOptions = {
1807
- sessionId: summaryOptions.sessionId,
1808
- promptCacheKey: summaryOptions.promptCacheKey,
1809
- retainedMessageBudget: settings.v2RetainedMessageBudget,
1810
- retainedUserItems,
1811
- };
1812
- const request = codexBody
1813
- ? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
1814
- : buildCompactionV2Request(model, trimmed.input, instructions, {
1815
- ...requestOptions,
1816
- tools,
1817
- reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1818
- });
1819
- const remote = await withAuth(
1820
- apiKey,
1821
- key =>
1822
- requestCompactionV2Streaming(model, key, request, signal, {
1823
- fetch: summaryOptions.fetch,
1824
- providerSessionState: summaryOptions.providerSessionState,
1825
- preferWebsockets: summaryOptions.preferWebsockets,
1826
- codexCompaction: summaryOptions.codexCompaction,
1827
- }),
1828
- { signal },
1879
+ const remote = await withAuth(
1880
+ apiKey,
1881
+ key =>
1882
+ requestCompactionV2Streaming(model, key, request, signal, {
1883
+ fetch: summaryOptions.fetch,
1884
+ providerSessionState: summaryOptions.providerSessionState,
1885
+ preferWebsockets: summaryOptions.preferWebsockets,
1886
+ codexCompaction: summaryOptions.codexCompaction,
1887
+ }),
1888
+ { signal },
1889
+ );
1890
+ preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
1891
+ };
1892
+ const whole = await buildV2Input(true);
1893
+ if (whole.remoteHistory.length > 0) {
1894
+ try {
1895
+ await sendRemoteCompaction(
1896
+ () => sendV2(whole, !canCompactBeforeTail),
1897
+ async () => {
1898
+ const prefix = await buildV2Input(false);
1899
+ if (prefix.remoteHistory.length === 0) return false;
1900
+ await sendV2(prefix, true);
1901
+ return true;
1902
+ },
1829
1903
  );
1830
- preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
1831
1904
  usedRemoteCompaction = true;
1832
1905
  } catch (err) {
1833
1906
  // A user/session abort is a cancellation, not a remote failure —
@@ -1859,33 +1932,52 @@ export async function compact(
1859
1932
  : previousV2Compaction?.provider === model.provider
1860
1933
  ? previousV2Compaction.replacementHistory
1861
1934
  : undefined;
1862
- const remoteHistory = buildOpenAiNativeHistory(
1863
- (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1935
+ const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
1936
+ const supportsOriginalDetail = openAiCompatSupportsImageDetailOriginal(model);
1937
+ const sendV1 = async (remoteHistory: Array<Record<string, unknown>>, rewriteOutputs: boolean): Promise<void> => {
1938
+ const remote = await withAuth(
1939
+ apiKey,
1940
+ key =>
1941
+ requestOpenAiRemoteCompaction(
1942
+ model,
1943
+ key,
1944
+ remoteHistory,
1945
+ summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
1946
+ signal,
1947
+ {
1948
+ fetch: summaryOptions.fetch,
1949
+ sessionId: summaryOptions.sessionId,
1950
+ providerSessionState: summaryOptions.providerSessionState,
1951
+ codexCompaction: summaryOptions.codexCompaction,
1952
+ rewriteOutputs,
1953
+ },
1954
+ ),
1955
+ { signal },
1956
+ );
1957
+ preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
1958
+ };
1959
+ const whole = buildOpenAiNativeHistory(
1960
+ convertToLlm(remoteMessages),
1864
1961
  model,
1865
1962
  previousReplacementHistory,
1866
- openAiCompatSupportsImageDetailOriginal(model),
1963
+ supportsOriginalDetail,
1867
1964
  );
1868
- if (remoteHistory.length > 0) {
1965
+ if (whole.length > 0) {
1869
1966
  try {
1870
- const remote = await withAuth(
1871
- apiKey,
1872
- key =>
1873
- requestOpenAiRemoteCompaction(
1967
+ await sendRemoteCompaction(
1968
+ () => sendV1(whole, !canCompactBeforeTail),
1969
+ async () => {
1970
+ const prefix = buildOpenAiNativeHistory(
1971
+ convertToLlm(remotePrefixMessages),
1874
1972
  model,
1875
- key,
1876
- remoteHistory,
1877
- summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
1878
- signal,
1879
- {
1880
- fetch: summaryOptions.fetch,
1881
- sessionId: summaryOptions.sessionId,
1882
- providerSessionState: summaryOptions.providerSessionState,
1883
- codexCompaction: summaryOptions.codexCompaction,
1884
- },
1885
- ),
1886
- { signal },
1973
+ previousReplacementHistory,
1974
+ supportsOriginalDetail,
1975
+ );
1976
+ if (prefix.length === 0) return false;
1977
+ await sendV1(prefix, true);
1978
+ return true;
1979
+ },
1887
1980
  );
1888
- preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
1889
1981
  usedRemoteCompaction = true;
1890
1982
  } catch (err) {
1891
1983
  // A user/session abort is a cancellation, not a remote failure —
@@ -2157,6 +2249,7 @@ export async function compact(
2157
2249
  tokensBefore,
2158
2250
  details: { readFiles, modifiedFiles } as CompactionDetails,
2159
2251
  preserveData: finalPreserveData,
2252
+ ...(remoteReplayThroughEntryId ? { providerReplayThroughEntryId: remoteReplayThroughEntryId } : {}),
2160
2253
  };
2161
2254
  }
2162
2255
 
@@ -89,27 +89,34 @@ const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
89
89
  const remoteImageTokenCache = new WeakMap<Record<string, unknown>, { billingKey: string; tokens: number }>();
90
90
  const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
91
91
 
92
- interface NormalizedEstimateValue {
93
- value: unknown;
92
+ /**
93
+ * A request as the model reads it: every string value decoded (no quoting or
94
+ * escapes), with keys and scalar values kept as one framing fragment.
95
+ */
96
+ interface RemoteEstimateParts {
97
+ fragments: string[];
98
+ framing: string[];
94
99
  imageTokens: number;
95
100
  }
96
101
 
97
- function normalizeRemoteCompactionEstimateValue(
102
+ function collectRemoteCompactionEstimate(
98
103
  value: unknown,
99
104
  tokenizer: Tokenizer,
100
- dropEncrypted = true,
101
- ): NormalizedEstimateValue {
105
+ dropEncrypted: boolean,
106
+ parts: RemoteEstimateParts,
107
+ ): void {
108
+ if (typeof value === "string") {
109
+ if (value) parts.fragments.push(value);
110
+ return;
111
+ }
102
112
  if (Array.isArray(value)) {
103
- const normalized: unknown[] = [];
104
- let imageTokens = 0;
105
- for (const item of value) {
106
- const result = normalizeRemoteCompactionEstimateValue(item, tokenizer, dropEncrypted);
107
- normalized.push(result.value);
108
- imageTokens += result.imageTokens;
109
- }
110
- return { value: normalized, imageTokens };
113
+ for (const item of value) collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
114
+ return;
115
+ }
116
+ if (value === null || typeof value !== "object") {
117
+ if (value !== undefined) parts.framing.push(String(value));
118
+ return;
111
119
  }
112
- if (!value || typeof value !== "object") return { value, imageTokens: 0 };
113
120
 
114
121
  const record = value as Record<string, unknown>;
115
122
  if (record.type === "input_image") {
@@ -125,31 +132,44 @@ function normalizeRemoteCompactionEstimateValue(
125
132
  };
126
133
  remoteImageTokenCache.set(record, cached);
127
134
  }
128
- return { value: { ...record, image_url: "<image>" }, imageTokens: cached.tokens };
135
+ parts.imageTokens += cached.tokens;
136
+ return;
129
137
  }
130
- const normalized: Record<string, unknown> = {};
131
- let imageTokens = 0;
132
- for (const [key, item] of Object.entries(record)) {
138
+ for (const key in record) {
139
+ const item = record[key];
133
140
  // Opaque encrypted reasoning/compaction state: its local base64 size far
134
141
  // exceeds what the provider bills, so it stays out of the fit estimate
135
142
  // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
136
- if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
137
- const result = normalizeRemoteCompactionEstimateValue(item, tokenizer, dropEncrypted);
138
- normalized[key] = result.value;
139
- imageTokens += result.imageTokens;
143
+ if (item === undefined || (dropEncrypted && key === "encrypted_content" && typeof item === "string")) continue;
144
+ parts.framing.push(key);
145
+ collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
140
146
  }
141
- return { value: normalized, imageTokens };
147
+ }
148
+
149
+ /**
150
+ * Text fragments and image tokens for `value`. Strings count decoded: JSON
151
+ * escaping inflates quote-, backslash- and newline-dense text (code,
152
+ * disassembly) well past what the model reads.
153
+ */
154
+ function remoteCompactionEstimateParts(
155
+ value: unknown,
156
+ tokenizer: Tokenizer,
157
+ dropEncrypted: boolean,
158
+ ): { fragments: string[]; imageTokens: number } {
159
+ const parts: RemoteEstimateParts = { fragments: [], framing: [], imageTokens: 0 };
160
+ collectRemoteCompactionEstimate(value, tokenizer, dropEncrypted, parts);
161
+ if (parts.framing.length > 0) parts.fragments.push(parts.framing.join(" "));
162
+ return { fragments: parts.fragments, imageTokens: parts.imageTokens };
142
163
  }
143
164
 
144
165
  /**
145
166
  * Tokens of stored Responses replacement history. Retained `input_image` parts
146
167
  * count at their image estimate rather than as base64 text; everything else,
147
- * opaque compaction state included, counts as serialized.
168
+ * opaque compaction state included, counts as decoded text.
148
169
  */
149
170
  export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
150
- const normalized = normalizeRemoteCompactionEstimateValue(items, tokenizer, false);
151
- const serialized = stringifyJson(normalized.value);
152
- return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
171
+ const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer, false);
172
+ return (fragments.length === 0 ? 0 : tokenizer.countTokens(fragments)) + imageTokens;
153
173
  }
154
174
 
155
175
  export interface TrimRemoteCompactionInputResult {
@@ -178,10 +198,10 @@ interface RemoteCompactionBudgetProbe {
178
198
  /**
179
199
  * Cheap-first sizing of a remote-compaction request. Images (priced from their
180
200
  * dimensions) and the request frame come off the budget rather than through
181
- * the tokenizer; opaque `encrypted_content` payloads are excluded. The
182
- * serialized transcript is then probed with {@link Tokenizer.checkTokenBudget},
183
- * which only pays for an exact count when the byte bound cannot already prove
184
- * the request fits.
201
+ * the tokenizer; opaque `encrypted_content` payloads are excluded. The decoded
202
+ * request text is then probed with {@link Tokenizer.checkTokenBudget}, which
203
+ * only pays for an exact count when the byte bound cannot already prove the
204
+ * request fits.
185
205
  */
186
206
  function probeRemoteCompactionInputBudget(
187
207
  input: Array<Record<string, unknown>>,
@@ -190,16 +210,16 @@ function probeRemoteCompactionInputBudget(
190
210
  tools: unknown[] | undefined,
191
211
  contextWindow: number | null | undefined,
192
212
  ): RemoteCompactionBudgetProbe {
193
- const normalized = normalizeRemoteCompactionEstimateValue(
213
+ const { fragments, imageTokens } = remoteCompactionEstimateParts(
194
214
  { instructions, input, ...(tools ? { tools } : {}) },
195
215
  tokenizer,
216
+ true,
196
217
  );
197
- const serialized = stringifyJson(normalized.value) ?? "";
198
- const flatTokens = normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
218
+ const flatTokens = imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
199
219
  if (!contextWindow || contextWindow <= 0) {
200
- return { tokens: tokenizer.countTokens(serialized, "upperbound") + flatTokens, fits: true, textFits: true };
220
+ return { tokens: tokenizer.countTokens(fragments, "upperbound") + flatTokens, fits: true, textFits: true };
201
221
  }
202
- const budget = tokenizer.checkTokenBudget(serialized, Math.max(0, contextWindow - flatTokens));
222
+ const budget = tokenizer.checkTokenBudget(fragments, Math.max(0, contextWindow - flatTokens));
203
223
  return {
204
224
  tokens: budget.tokens + flatTokens,
205
225
  fits: budget.fits,
@@ -231,6 +251,16 @@ function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
231
251
  return hasLabel && hasImage;
232
252
  }
233
253
 
254
+ /** Options for {@link trimRemoteCompactionInputToContextWindow}. */
255
+ export interface TrimRemoteCompactionOptions {
256
+ /**
257
+ * Whether oversized trailing tool outputs may be rewritten (default true).
258
+ * False when a caller can compact a shorter prefix instead, so a kept tool
259
+ * result replays intact rather than being replaced.
260
+ */
261
+ rewriteOutputs?: boolean;
262
+ }
263
+
234
264
  /**
235
265
  * Preserve the full native transcript unless trailing tool outputs alone push a
236
266
  * remote compaction request beyond the model window. Replacing only those
@@ -245,15 +275,16 @@ export function trimRemoteCompactionInputToContextWindow(
245
275
  contextWindow: number | null | undefined,
246
276
  instructions: string,
247
277
  tools?: unknown[],
278
+ options?: TrimRemoteCompactionOptions,
248
279
  ): TrimRemoteCompactionInputResult {
249
280
  const before = probeRemoteCompactionInputBudget(input, tokenizer, instructions, tools, contextWindow);
250
- if (before.fits) {
281
+ if (before.fits || options?.rewriteOutputs === false) {
251
282
  return {
252
283
  input,
253
284
  rewrittenOutputs: 0,
254
285
  estimatedTokensBefore: before.tokens,
255
286
  estimatedTokensAfter: before.tokens,
256
- fits: true,
287
+ fits: before.fits || before.textFits,
257
288
  };
258
289
  }
259
290
 
@@ -828,6 +859,8 @@ export async function requestOpenAiRemoteCompaction(
828
859
  sessionId?: string;
829
860
  providerSessionState?: Map<string, ProviderSessionState>;
830
861
  codexCompaction?: CodexCompactionContext;
862
+ /** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
863
+ rewriteOutputs?: boolean;
831
864
  },
832
865
  ): Promise<OpenAiRemoteCompactionResponse> {
833
866
  let fetchImpl: FetchImpl = opts?.fetch ?? fetch;
@@ -841,6 +874,8 @@ export async function requestOpenAiRemoteCompaction(
841
874
  new Tokenizer(model),
842
875
  model.contextWindow,
843
876
  instructions,
877
+ undefined,
878
+ { rewriteOutputs: opts?.rewriteOutputs },
844
879
  );
845
880
  if (trimmed.rewrittenOutputs > 0) {
846
881
  logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
@@ -17,14 +17,23 @@ export const MIN_FITTED_OUTPUT_TOKENS = 1024;
17
17
  */
18
18
  const PROMPT_ESTIMATE_MARGIN_DIVISOR = 10;
19
19
 
20
+ /**
21
+ * Chat-template framing charged per locally counted message. `Tokenizer.countMessage`
22
+ * counts content only, but every template wraps each message in role markers and
23
+ * separators, and tool results in response wrappers: about 5 tokens per merged
24
+ * Qwen3 `<tool_response>` or Llama 3 turn, 10–15 for harmony or Mistral tool results.
25
+ * The proportional margin scales with content, so many short messages (a turn's
26
+ * parallel tool results) leave this uncovered. Provider-anchored messages need no
27
+ * charge: the host's own report already includes their framing.
28
+ */
29
+ export const MESSAGE_FRAMING_TOKENS = 16;
30
+
20
31
  /**
21
32
  * Absolute headway subtracted from the remaining room (unconditionally, on anchored and
22
- * fully-local counts alike). Proportional padding covers
23
- * tokenizer drift that scales with the prompt, but a host can still count a few tokens more
24
- * than any local estimate can see (chat-template framing, reasoning wrappers). Measured
25
- * against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were rejected
26
- * with 400s. 64 tokens covers the observed drift with margin to spare; the
27
- * {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
33
+ * fully-local counts alike). Covers per-request framing no message count sees: the
34
+ * generation prompt, the anchoring turn's end marker, reasoning wrappers. Measured
35
+ * against a 262,144-token host: fitted caps landed +11 to +42 tokens over and were
36
+ * rejected with 400s. The {@link MIN_FITTED_OUTPUT_TOKENS} floor still applies.
28
37
  */
29
38
  export const OUTPUT_FIT_HEADWAY_TOKENS = 64;
30
39
 
@@ -43,7 +52,9 @@ export const OUTPUT_FIT_HEADWAY_TOKENS = 64;
43
52
  * The prompt size is the provider's own report from the newest trustworthy
44
53
  * assistant turn (see {@link findRequestUsageAnchor}) plus a local count of
45
54
  * only the messages appended after it; the whole context is counted locally
46
- * only when no turn can anchor (fresh or freshly rewritten context).
55
+ * only when no turn can anchor (fresh or freshly rewritten context). Local
56
+ * counts carry a proportional tokenizer margin plus
57
+ * {@link MESSAGE_FRAMING_TOKENS} per message.
47
58
  *
48
59
  * Returns `maxTokens` unchanged when the requested cap already fits, the
49
60
  * model declares no window, the host ends generation at the window itself
@@ -122,12 +133,14 @@ function withMargin(localTokens: number): number {
122
133
  function countPromptTokens(context: Context, tokenizer: Tokenizer): number {
123
134
  const { messages } = context;
124
135
  const anchor = findRequestUsageAnchor(messages);
125
- if (!anchor) return withMargin(countContextTokens(context, tokenizer));
136
+ if (!anchor) {
137
+ return withMargin(countContextTokens(context, tokenizer)) + messages.length * MESSAGE_FRAMING_TOKENS;
138
+ }
126
139
  let tail = 0;
127
140
  for (let index = anchor.index + 1; index < messages.length; index++) {
128
141
  tail += tokenizer.countMessage(messages[index]);
129
142
  }
130
- return anchor.tokens + withMargin(tail);
143
+ return anchor.tokens + withMargin(tail) + (messages.length - anchor.index - 1) * MESSAGE_FRAMING_TOKENS;
131
144
  }
132
145
 
133
146
  function countContextTokens(context: Context, tokenizer: Tokenizer): number {
package/src/types.ts CHANGED
@@ -718,20 +718,10 @@ export interface SpeculativePhysicalOutcome {
718
718
  evidence?: unknown;
719
719
  }
720
720
 
721
- /** How an approval prompt will show what `formatApprovalDetails` returns. */
722
- export interface ApprovalDetailsOptions {
723
- /**
724
- * The prompt keeps only each detail's first line plus a count of the rest (the transcript
725
- * shows the call in full): return bodies untruncated so the count is the whole value's.
726
- * The host truncates the rows it keeps. Otherwise truncate long values as usual.
727
- */
728
- compact?: boolean;
729
- }
730
-
731
721
  export interface SpeculativeToolReference {
732
722
  name: string;
733
723
  approval?: ToolApproval;
734
- formatApprovalDetails?: (args: unknown, options?: ApprovalDetailsOptions) => string | string[] | undefined;
724
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
735
725
  }
736
726
 
737
727
  export interface SpeculativeOperationContext extends ToolSpeculationExecutionContext {
@@ -1152,6 +1142,11 @@ export interface AgentTool<
1152
1142
  loadMode?: ToolLoadMode;
1153
1143
  /** Short one-line summary used for tool discovery indexes. */
1154
1144
  summary?: string;
1145
+ /**
1146
+ * Instructions a same-name extension replacement inherits in its description, because results it
1147
+ * obtains by delegating to this native tool through `ctx.invokeTool` follow this tool's contract.
1148
+ */
1149
+ readonly delegationGuidance?: string;
1155
1150
  /**
1156
1151
  * On-demand documentation topics (`topic → markdown`), readable as
1157
1152
  * `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
@@ -1228,7 +1223,7 @@ export interface AgentTool<
1228
1223
  approval?: ToolApproval;
1229
1224
 
1230
1225
  /** Lines appended after the standard approval prompt header. */
1231
- formatApprovalDetails?: (args: unknown, options?: ApprovalDetailsOptions) => string | string[] | undefined;
1226
+ formatApprovalDetails?: (args: unknown) => string | string[] | undefined;
1232
1227
 
1233
1228
  /** The main execution callback for this tool. */
1234
1229
  execute: AgentToolExecFn<TParameters, TDetails, TTheme>;