@oh-my-pi/pi-agent-core 18.10.4 → 18.11.1
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 +30 -0
- package/dist/types/agent.d.ts +7 -0
- package/dist/types/compaction/compaction.d.ts +16 -0
- package/dist/types/compaction/openai.d.ts +13 -2
- package/dist/types/output-budget.d.ts +17 -7
- package/dist/types/tokenizer.d.ts +10 -0
- package/dist/types/types.d.ts +7 -11
- package/package.json +8 -8
- package/src/agent-loop.ts +9 -11
- package/src/agent.ts +23 -0
- package/src/compaction/compaction.ts +238 -132
- package/src/compaction/openai.ts +73 -38
- package/src/output-budget.ts +22 -9
- package/src/tokenizer.ts +27 -8
- package/src/types.ts +7 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.11.1] - 2026-10-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `Tokenizer.countLatestTurn`: counts a message as the transcript's latest assistant turn, which an Anthropic Messages replay keeps byte-for-byte ([#15791](https://github.com/can1357/oh-my-pi/pull/15791) by [@will-bogusz](https://github.com/will-bogusz))
|
|
10
|
+
- `Agent.prioritizeSteering`: moves given pending steering messages ahead of older ones so one-at-a-time delivery takes them first ([#15642](https://github.com/can1357/oh-my-pi/pull/15642))
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed a Cursor tool call re-sent by the server persisting two results under one id; the re-run's result replaces the first ([#15749](https://github.com/can1357/oh-my-pi/issues/15749))
|
|
15
|
+
- Fixed compaction keeping more recent history than `keepRecentTokens` when the latest Claude turn on the Anthropic Messages API ended its tool calls on `stop` or `length`: the replay keeps that turn's signed reasoning, but the cut point counted only its visible blocks ([#15791](https://github.com/can1357/oh-my-pi/pull/15791) by [@will-bogusz](https://github.com/will-bogusz))
|
|
16
|
+
|
|
17
|
+
## [18.11.0] - 2026-10-14
|
|
18
|
+
|
|
19
|
+
### Breaking Changes
|
|
20
|
+
|
|
21
|
+
- 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))
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- `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))
|
|
26
|
+
- `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))
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- 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))
|
|
31
|
+
- 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))
|
|
32
|
+
- 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))
|
|
33
|
+
- 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))
|
|
34
|
+
|
|
5
35
|
## [18.10.4] - 2026-10-13
|
|
6
36
|
|
|
7
37
|
### Added
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -514,6 +514,13 @@ export declare class Agent {
|
|
|
514
514
|
* Delivered after current tool execution, skips remaining tools.
|
|
515
515
|
*/
|
|
516
516
|
steer(m: AgentMessage): void;
|
|
517
|
+
/**
|
|
518
|
+
* Move the given pending steering messages (by identity) ahead of the rest,
|
|
519
|
+
* keeping both groups' relative order, so one-at-a-time delivery takes them
|
|
520
|
+
* first. Messages no longer pending, including a claimed batch already
|
|
521
|
+
* being delivered, are ignored.
|
|
522
|
+
*/
|
|
523
|
+
prioritizeSteering(messages: readonly AgentMessage[]): void;
|
|
517
524
|
/**
|
|
518
525
|
* Queue a follow-up message to be processed after the agent finishes.
|
|
519
526
|
* Delivered only when agent has no more tool calls or steering messages.
|
|
@@ -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;
|
|
@@ -131,6 +137,14 @@ export declare function shouldCompact(contextTokens: number, contextWindow: numb
|
|
|
131
137
|
* takes the floor.)
|
|
132
138
|
*/
|
|
133
139
|
export declare function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number;
|
|
140
|
+
/**
|
|
141
|
+
* The prompt budget below `contextWindow`: the window less
|
|
142
|
+
* {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
|
|
143
|
+
* reaches the whole window even when the reserve resolves to 0. Independent of
|
|
144
|
+
* any configured trigger; the default {@link resolveThresholdTokens} and
|
|
145
|
+
* length-stop recovery's "did the window run out" check both use it.
|
|
146
|
+
*/
|
|
147
|
+
export declare function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number;
|
|
134
148
|
export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number;
|
|
135
149
|
/**
|
|
136
150
|
* True for entries that begin a conversational turn (a user request, a
|
|
@@ -321,6 +335,8 @@ export interface CompactionPreparation {
|
|
|
321
335
|
recentMessages: AgentMessage[];
|
|
322
336
|
/** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
|
|
323
337
|
recentEntryIds?: string[];
|
|
338
|
+
/** Last entry before the kept tail; a remote compaction that cannot fit the tail covers history through it. */
|
|
339
|
+
lastSummarizedEntryId?: string;
|
|
324
340
|
/** Whether this is a split turn (cut point in middle of turn) */
|
|
325
341
|
isSplitTurn: boolean;
|
|
326
342
|
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
|
|
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).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
@@ -111,6 +111,16 @@ export declare class Tokenizer {
|
|
|
111
111
|
* the native remote-compaction probe applies.
|
|
112
112
|
*/
|
|
113
113
|
countMessage(message: AgentMessage, options?: MessageCountOptions): number;
|
|
114
|
+
/**
|
|
115
|
+
* {@link countMessage} for the transcript's latest surviving assistant turn
|
|
116
|
+
* (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
|
|
117
|
+
* byte-for-byte (`transformMessages`): a turn whose tool calls ended without
|
|
118
|
+
* a tool-use stop keeps its signatures then, so its reasoning is charged. A
|
|
119
|
+
* count of one message cannot see its position; a caller walking back from
|
|
120
|
+
* the end of the transcript counts that turn here. Not memoized: it differs
|
|
121
|
+
* from {@link countMessage} only for that one turn.
|
|
122
|
+
*/
|
|
123
|
+
countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number;
|
|
114
124
|
/** Sum of {@link countMessage} over `messages`. */
|
|
115
125
|
countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number;
|
|
116
126
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
4
|
+
"version": "18.11.1",
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.11.1",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.11.1",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.11.1",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.11.1",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.11.1",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.11.1",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.
|
|
50
|
+
"@oh-my-pi/omptype": "18.11.1",
|
|
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
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
finalMessage.stopReason === "stop" &&
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
package/src/agent.ts
CHANGED
|
@@ -1390,6 +1390,20 @@ export class Agent {
|
|
|
1390
1390
|
this.#emitQueueChanged();
|
|
1391
1391
|
}
|
|
1392
1392
|
|
|
1393
|
+
/**
|
|
1394
|
+
* Move the given pending steering messages (by identity) ahead of the rest,
|
|
1395
|
+
* keeping both groups' relative order, so one-at-a-time delivery takes them
|
|
1396
|
+
* first. Messages no longer pending, including a claimed batch already
|
|
1397
|
+
* being delivered, are ignored.
|
|
1398
|
+
*/
|
|
1399
|
+
prioritizeSteering(messages: readonly AgentMessage[]): void {
|
|
1400
|
+
const wanted = new Set(messages);
|
|
1401
|
+
const front = this.#steeringQueue.filter(message => wanted.has(message));
|
|
1402
|
+
if (front.every((message, index) => this.#steeringQueue[index] === message)) return;
|
|
1403
|
+
this.#steeringQueue = [...front, ...this.#steeringQueue.filter(message => !wanted.has(message))];
|
|
1404
|
+
this.#emitQueueChanged();
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1393
1407
|
/**
|
|
1394
1408
|
* Queue a follow-up message to be processed after the agent finishes.
|
|
1395
1409
|
* Delivered only when agent has no more tool calls or steering messages.
|
|
@@ -1808,10 +1822,19 @@ export class Agent {
|
|
|
1808
1822
|
// that, a transformer resolving after the swap would patch a detached
|
|
1809
1823
|
// object while the persisted result kept the original payload — the
|
|
1810
1824
|
// rewrite silently lost.
|
|
1825
|
+
//
|
|
1826
|
+
// A result for a call already buffered is the server re-dispatching
|
|
1827
|
+
// that call; the provider keeps one block for it, so the re-run's
|
|
1828
|
+
// result replaces the first rather than pairing a second result with
|
|
1829
|
+
// the same id (issue #15749).
|
|
1811
1830
|
const entry: CursorToolResultEntry = {
|
|
1812
1831
|
toolResult: message,
|
|
1813
1832
|
additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
|
|
1814
1833
|
};
|
|
1834
|
+
const superseded = this.#cursorToolResultBuffer.findIndex(
|
|
1835
|
+
({ toolResult }) => toolResult.toolCallId === message.toolCallId,
|
|
1836
|
+
);
|
|
1837
|
+
if (superseded >= 0) this.#cursorToolResultBuffer.splice(superseded, 1);
|
|
1815
1838
|
this.#cursorToolResultBuffer.push(entry);
|
|
1816
1839
|
const transform = this.#cursorOnToolResult;
|
|
1817
1840
|
if (transform) {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import type { InputItem as CodexInputItem } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
|
|
35
35
|
import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
|
|
36
36
|
import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
37
|
+
import { isSurvivingAssistant } from "@oh-my-pi/pi-ai/providers/transform-messages";
|
|
37
38
|
import { stripOpenAIResponsesOutputOnlyStatusesForReplay } from "@oh-my-pi/pi-ai/utils";
|
|
38
39
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
39
40
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
@@ -152,6 +153,12 @@ export interface CompactionResult<T = unknown> {
|
|
|
152
153
|
details?: T;
|
|
153
154
|
/** Hook-provided data to persist alongside compaction entry. */
|
|
154
155
|
preserveData?: Record<string, unknown>;
|
|
156
|
+
/**
|
|
157
|
+
* Last entry the provider-native replay history covers. Set when a remote
|
|
158
|
+
* compaction could only fit the history before the kept tail; later entries
|
|
159
|
+
* replay normally after the native history.
|
|
160
|
+
*/
|
|
161
|
+
providerReplayThroughEntryId?: string;
|
|
155
162
|
}
|
|
156
163
|
|
|
157
164
|
// ============================================================================
|
|
@@ -369,9 +376,11 @@ export function compactionContextTokens(providerContextTokens: number, storedCon
|
|
|
369
376
|
/**
|
|
370
377
|
* The prompt budget below `contextWindow`: the window less
|
|
371
378
|
* {@link resolveBudgetReserveTokens}, capped at `contextWindow - 1` so it never
|
|
372
|
-
* reaches the whole window even when the reserve resolves to 0.
|
|
379
|
+
* reaches the whole window even when the reserve resolves to 0. Independent of
|
|
380
|
+
* any configured trigger; the default {@link resolveThresholdTokens} and
|
|
381
|
+
* length-stop recovery's "did the window run out" check both use it.
|
|
373
382
|
*/
|
|
374
|
-
function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
383
|
+
export function promptBudgetTokens(contextWindow: number, settings: CompactionSettings): number {
|
|
375
384
|
return Math.max(0, Math.min(contextWindow - 1, contextWindow - resolveBudgetReserveTokens(contextWindow, settings)));
|
|
376
385
|
}
|
|
377
386
|
|
|
@@ -529,13 +538,23 @@ export function findCutPoint(
|
|
|
529
538
|
// belonging to an assistant. Checking individual messages can either retain
|
|
530
539
|
// the oversized older assistant or miss its boundary and retain all history.
|
|
531
540
|
let accumulatedTokens = 0;
|
|
541
|
+
// The latest surviving assistant turn replays byte-for-byte, signatures of
|
|
542
|
+
// abandoned tool calls included, so it is counted as the latest.
|
|
543
|
+
let latestTurnIndex = -1;
|
|
544
|
+
for (let i = entries.length - 1; i >= 0 && latestTurnIndex < 0; i--) {
|
|
545
|
+
const message = getMessageFromEntry(entries[i]);
|
|
546
|
+
if (message?.role === "assistant" && isSurvivingAssistant(message)) latestTurnIndex = i;
|
|
547
|
+
}
|
|
532
548
|
let cutPointIndex = cutPoints.length - 1;
|
|
533
549
|
let cutIndex = cutPoints[cutPointIndex];
|
|
534
550
|
|
|
535
551
|
for (let i = endIndex - 1; i >= startIndex; i--) {
|
|
536
552
|
const entry = entries[i];
|
|
537
553
|
const message = getMessageFromEntry(entry);
|
|
538
|
-
if (message)
|
|
554
|
+
if (message) {
|
|
555
|
+
accumulatedTokens +=
|
|
556
|
+
i === latestTurnIndex ? tokenizer.countLatestTurn(message) : tokenizer.countMessage(message);
|
|
557
|
+
}
|
|
539
558
|
if (i !== cutPoints[cutPointIndex]) continue;
|
|
540
559
|
if (accumulatedTokens > keepRecentTokens) break;
|
|
541
560
|
cutIndex = i;
|
|
@@ -1268,6 +1287,8 @@ export interface CompactionPreparation {
|
|
|
1268
1287
|
recentMessages: AgentMessage[];
|
|
1269
1288
|
/** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
|
|
1270
1289
|
recentEntryIds?: string[];
|
|
1290
|
+
/** Last entry before the kept tail; a remote compaction that cannot fit the tail covers history through it. */
|
|
1291
|
+
lastSummarizedEntryId?: string;
|
|
1271
1292
|
/** Whether this is a split turn (cut point in middle of turn) */
|
|
1272
1293
|
isSplitTurn: boolean;
|
|
1273
1294
|
tokensBefore: number;
|
|
@@ -1480,6 +1501,7 @@ export function prepareCompaction(
|
|
|
1480
1501
|
turnPrefixMessages,
|
|
1481
1502
|
recentMessages,
|
|
1482
1503
|
recentEntryIds,
|
|
1504
|
+
lastSummarizedEntryId: compactionEntries[cutPoint.firstKeptEntryIndex - 1]?.id,
|
|
1483
1505
|
isSplitTurn: cutPoint.isSplitTurn,
|
|
1484
1506
|
tokensBefore,
|
|
1485
1507
|
previousSummary: previousCompaction?.summary,
|
|
@@ -1587,6 +1609,13 @@ function formatRemoteCompactionSummary(inputTokens: number): string {
|
|
|
1587
1609
|
);
|
|
1588
1610
|
}
|
|
1589
1611
|
|
|
1612
|
+
/** A V2 remote-compaction request's history and the user items its replacement history retains. */
|
|
1613
|
+
interface RemoteCompactionV2Input {
|
|
1614
|
+
codexBody: OpenAICodexCompactionBody | undefined;
|
|
1615
|
+
remoteHistory: Array<Record<string, unknown>>;
|
|
1616
|
+
retainedUserItems: unknown[];
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1590
1619
|
/**
|
|
1591
1620
|
* Generate summaries for compaction using prepared data.
|
|
1592
1621
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -1608,6 +1637,7 @@ export async function compact(
|
|
|
1608
1637
|
turnPrefixMessages,
|
|
1609
1638
|
recentMessages,
|
|
1610
1639
|
recentEntryIds,
|
|
1640
|
+
lastSummarizedEntryId,
|
|
1611
1641
|
isSplitTurn,
|
|
1612
1642
|
tokensBefore,
|
|
1613
1643
|
previousSummary,
|
|
@@ -1688,15 +1718,50 @@ export async function compact(
|
|
|
1688
1718
|
withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
|
|
1689
1719
|
undefined,
|
|
1690
1720
|
);
|
|
1691
|
-
const
|
|
1721
|
+
const remotePrefixMessages: AgentMessage[] = [
|
|
1692
1722
|
...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
|
|
1693
1723
|
...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
|
|
1694
1724
|
...messagesToSummarize,
|
|
1695
1725
|
...turnPrefixMessages,
|
|
1696
|
-
...recentMessages,
|
|
1697
1726
|
];
|
|
1727
|
+
const remoteMessages: AgentMessage[] = [...remotePrefixMessages, ...recentMessages];
|
|
1698
1728
|
let usedRemoteCompaction = false;
|
|
1729
|
+
let remoteReplayThroughEntryId: string | undefined;
|
|
1699
1730
|
let nativeCompactionError: unknown;
|
|
1731
|
+
/**
|
|
1732
|
+
* Remote compaction replaces the whole history, so its request carries the
|
|
1733
|
+
* kept tail too. A turn can leave that history over the window (a large
|
|
1734
|
+
* response or tool output after the last check), which no request can
|
|
1735
|
+
* send. Then compact the history before the tail instead; the tail replays
|
|
1736
|
+
* normally after the native history, as a speculative snapshot's later
|
|
1737
|
+
* turns do. While that fallback exists, the whole-history attempt must not
|
|
1738
|
+
* rewrite the tail's trailing tool outputs: the prefix keeps them intact.
|
|
1739
|
+
*/
|
|
1740
|
+
const canCompactBeforeTail = lastSummarizedEntryId !== undefined && recentMessages.length > 0;
|
|
1741
|
+
const sendRemoteCompaction = async (
|
|
1742
|
+
sendWhole: () => Promise<void>,
|
|
1743
|
+
sendPrefix: () => Promise<boolean>,
|
|
1744
|
+
): Promise<void> => {
|
|
1745
|
+
try {
|
|
1746
|
+
await sendWhole();
|
|
1747
|
+
} catch (err) {
|
|
1748
|
+
if (
|
|
1749
|
+
signal?.aborted ||
|
|
1750
|
+
!canCompactBeforeTail ||
|
|
1751
|
+
!AIError.is(AIError.classify(err), AIError.Flag.ContextOverflow)
|
|
1752
|
+
) {
|
|
1753
|
+
throw err;
|
|
1754
|
+
}
|
|
1755
|
+
logger.info("Remote compaction history overflows the context window; compacting before the kept tail", {
|
|
1756
|
+
model: model.id,
|
|
1757
|
+
provider: model.provider,
|
|
1758
|
+
keptMessages: recentMessages.length,
|
|
1759
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1760
|
+
});
|
|
1761
|
+
if (!(await sendPrefix())) throw err;
|
|
1762
|
+
remoteReplayThroughEntryId = lastSummarizedEntryId;
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1700
1765
|
if (
|
|
1701
1766
|
settings.remoteEnabled !== false &&
|
|
1702
1767
|
settings.remoteStreamingV2Enabled !== false &&
|
|
@@ -1708,126 +1773,147 @@ export async function compact(
|
|
|
1708
1773
|
? previousRemoteCompaction.replacementHistory
|
|
1709
1774
|
: undefined;
|
|
1710
1775
|
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1711
|
-
const messages = convertToLlm(remoteMessages);
|
|
1712
1776
|
// Replacement history keeps what the user wrote. Summaries, archive
|
|
1713
1777
|
// migrations, and custom/hook messages can serialize as user-role items
|
|
1714
1778
|
// too, so pick the user's own messages before serialization erases that,
|
|
1715
1779
|
// then serialize each one exactly as the request does.
|
|
1716
1780
|
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
1781
|
const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
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,
|
|
1782
|
+
const buildV2Input = async (includeTail: boolean): Promise<RemoteCompactionV2Input> => {
|
|
1783
|
+
const messages = convertToLlm(includeTail ? remoteMessages : remotePrefixMessages);
|
|
1784
|
+
const userMessages = convertToLlm(
|
|
1785
|
+
[...messagesToSummarize, ...turnPrefixMessages, ...(includeTail ? recentMessages : [])].filter(
|
|
1786
|
+
isUserAuthored,
|
|
1787
|
+
),
|
|
1749
1788
|
);
|
|
1750
|
-
const
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1789
|
+
const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
|
|
1790
|
+
let codexBody: OpenAICodexCompactionBody | undefined;
|
|
1791
|
+
let remoteHistory: Array<Record<string, unknown>>;
|
|
1792
|
+
if (isCodexResponsesModel(model)) {
|
|
1793
|
+
const previousCodexInput: CodexInputItem[] = [];
|
|
1794
|
+
for (const item of previousReplacementHistory ?? []) {
|
|
1795
|
+
if (!isCodexInputItem(item)) {
|
|
1796
|
+
throw new Error("Stored Codex V2 compaction history contains an invalid input item");
|
|
1797
|
+
}
|
|
1798
|
+
previousCodexInput.push(item);
|
|
1755
1799
|
}
|
|
1756
|
-
|
|
1757
|
-
}
|
|
1758
|
-
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1759
|
-
codexBody.input = remoteHistory;
|
|
1760
|
-
for (const message of userMessages) {
|
|
1761
|
-
const userBody = await buildTransformedCodexRequestBody(
|
|
1800
|
+
codexBody = await buildTransformedCodexRequestBody(
|
|
1762
1801
|
model,
|
|
1763
|
-
{ messages:
|
|
1764
|
-
{
|
|
1802
|
+
{ systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
|
|
1803
|
+
{
|
|
1804
|
+
reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
|
|
1805
|
+
forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
|
|
1806
|
+
responsesLite: model.useResponsesLite,
|
|
1807
|
+
sessionId: summaryOptions.sessionId,
|
|
1808
|
+
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1809
|
+
providerSessionState: summaryOptions.providerSessionState,
|
|
1810
|
+
codexCompaction: createOpenAICodexCompactionRequestContext({
|
|
1811
|
+
context: summaryOptions.codexCompaction,
|
|
1812
|
+
implementation: "responses_compaction_v2",
|
|
1813
|
+
}),
|
|
1814
|
+
},
|
|
1815
|
+
undefined,
|
|
1816
|
+
previousCodexInput,
|
|
1765
1817
|
);
|
|
1766
|
-
|
|
1818
|
+
const input = Array.isArray(codexBody.input) ? codexBody.input : [];
|
|
1819
|
+
const nativeInput: Array<Record<string, unknown>> = [];
|
|
1820
|
+
for (const item of input) {
|
|
1821
|
+
if (!isRecord(item)) {
|
|
1822
|
+
throw new Error("Codex V2 compaction input contains a non-object item");
|
|
1823
|
+
}
|
|
1824
|
+
nativeInput.push(item);
|
|
1825
|
+
}
|
|
1826
|
+
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1827
|
+
codexBody.input = remoteHistory;
|
|
1828
|
+
for (const message of userMessages) {
|
|
1829
|
+
const userBody = await buildTransformedCodexRequestBody(
|
|
1830
|
+
model,
|
|
1831
|
+
{ messages: [message] },
|
|
1832
|
+
{ responsesLite: model.useResponsesLite },
|
|
1833
|
+
);
|
|
1834
|
+
retainedUserItems.push(...(userBody.input ?? []));
|
|
1835
|
+
}
|
|
1836
|
+
} else {
|
|
1837
|
+
remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
|
|
1838
|
+
for (const message of userMessages) {
|
|
1839
|
+
retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
|
|
1840
|
+
}
|
|
1767
1841
|
}
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1842
|
+
return { codexBody, remoteHistory, retainedUserItems };
|
|
1843
|
+
};
|
|
1844
|
+
const sendV2 = async (
|
|
1845
|
+
{ codexBody, remoteHistory, retainedUserItems }: RemoteCompactionV2Input,
|
|
1846
|
+
rewriteOutputs: boolean,
|
|
1847
|
+
): Promise<void> => {
|
|
1848
|
+
const instructions = codexBody
|
|
1849
|
+
? typeof codexBody.instructions === "string"
|
|
1850
|
+
? codexBody.instructions
|
|
1851
|
+
: ""
|
|
1852
|
+
: remoteSystemPrompt.join("\n\n");
|
|
1853
|
+
const tools = codexBody
|
|
1854
|
+
? Array.isArray(codexBody.tools)
|
|
1855
|
+
? codexBody.tools
|
|
1856
|
+
: undefined
|
|
1857
|
+
: summaryOptions.tools
|
|
1858
|
+
? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
|
|
1859
|
+
: undefined;
|
|
1860
|
+
const trimmed = trimRemoteCompactionInputToContextWindow(
|
|
1861
|
+
remoteHistory,
|
|
1862
|
+
new Tokenizer(model),
|
|
1863
|
+
model.contextWindow,
|
|
1864
|
+
instructions,
|
|
1865
|
+
tools,
|
|
1866
|
+
{ rewriteOutputs },
|
|
1867
|
+
);
|
|
1868
|
+
if (trimmed.rewrittenOutputs > 0) {
|
|
1869
|
+
logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
|
|
1870
|
+
model: model.id,
|
|
1871
|
+
provider: model.provider,
|
|
1872
|
+
rewrittenOutputs: trimmed.rewrittenOutputs,
|
|
1873
|
+
estimatedTokensBefore: trimmed.estimatedTokensBefore,
|
|
1874
|
+
estimatedTokensAfter: trimmed.estimatedTokensAfter,
|
|
1875
|
+
contextWindow: model.contextWindow,
|
|
1876
|
+
});
|
|
1772
1877
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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,
|
|
1878
|
+
assertRemoteCompactionInputFits(trimmed, model);
|
|
1879
|
+
const requestOptions = {
|
|
1880
|
+
sessionId: summaryOptions.sessionId,
|
|
1881
|
+
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1882
|
+
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1883
|
+
retainedUserItems,
|
|
1884
|
+
};
|
|
1885
|
+
const request = codexBody
|
|
1886
|
+
? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
|
|
1887
|
+
: buildCompactionV2Request(model, trimmed.input, instructions, {
|
|
1888
|
+
...requestOptions,
|
|
1889
|
+
tools,
|
|
1890
|
+
reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
|
|
1803
1891
|
});
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
{ signal },
|
|
1892
|
+
const remote = await withAuth(
|
|
1893
|
+
apiKey,
|
|
1894
|
+
key =>
|
|
1895
|
+
requestCompactionV2Streaming(model, key, request, signal, {
|
|
1896
|
+
fetch: summaryOptions.fetch,
|
|
1897
|
+
providerSessionState: summaryOptions.providerSessionState,
|
|
1898
|
+
preferWebsockets: summaryOptions.preferWebsockets,
|
|
1899
|
+
codexCompaction: summaryOptions.codexCompaction,
|
|
1900
|
+
}),
|
|
1901
|
+
{ signal },
|
|
1902
|
+
);
|
|
1903
|
+
preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
|
|
1904
|
+
};
|
|
1905
|
+
const whole = await buildV2Input(true);
|
|
1906
|
+
if (whole.remoteHistory.length > 0) {
|
|
1907
|
+
try {
|
|
1908
|
+
await sendRemoteCompaction(
|
|
1909
|
+
() => sendV2(whole, !canCompactBeforeTail),
|
|
1910
|
+
async () => {
|
|
1911
|
+
const prefix = await buildV2Input(false);
|
|
1912
|
+
if (prefix.remoteHistory.length === 0) return false;
|
|
1913
|
+
await sendV2(prefix, true);
|
|
1914
|
+
return true;
|
|
1915
|
+
},
|
|
1829
1916
|
);
|
|
1830
|
-
preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
|
|
1831
1917
|
usedRemoteCompaction = true;
|
|
1832
1918
|
} catch (err) {
|
|
1833
1919
|
// A user/session abort is a cancellation, not a remote failure —
|
|
@@ -1859,33 +1945,52 @@ export async function compact(
|
|
|
1859
1945
|
: previousV2Compaction?.provider === model.provider
|
|
1860
1946
|
? previousV2Compaction.replacementHistory
|
|
1861
1947
|
: undefined;
|
|
1862
|
-
const
|
|
1863
|
-
|
|
1948
|
+
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1949
|
+
const supportsOriginalDetail = openAiCompatSupportsImageDetailOriginal(model);
|
|
1950
|
+
const sendV1 = async (remoteHistory: Array<Record<string, unknown>>, rewriteOutputs: boolean): Promise<void> => {
|
|
1951
|
+
const remote = await withAuth(
|
|
1952
|
+
apiKey,
|
|
1953
|
+
key =>
|
|
1954
|
+
requestOpenAiRemoteCompaction(
|
|
1955
|
+
model,
|
|
1956
|
+
key,
|
|
1957
|
+
remoteHistory,
|
|
1958
|
+
summaryOptions.remoteSystemPrompt?.join("\n\n") ?? SUMMARIZATION_SYSTEM_PROMPT,
|
|
1959
|
+
signal,
|
|
1960
|
+
{
|
|
1961
|
+
fetch: summaryOptions.fetch,
|
|
1962
|
+
sessionId: summaryOptions.sessionId,
|
|
1963
|
+
providerSessionState: summaryOptions.providerSessionState,
|
|
1964
|
+
codexCompaction: summaryOptions.codexCompaction,
|
|
1965
|
+
rewriteOutputs,
|
|
1966
|
+
},
|
|
1967
|
+
),
|
|
1968
|
+
{ signal },
|
|
1969
|
+
);
|
|
1970
|
+
preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
|
|
1971
|
+
};
|
|
1972
|
+
const whole = buildOpenAiNativeHistory(
|
|
1973
|
+
convertToLlm(remoteMessages),
|
|
1864
1974
|
model,
|
|
1865
1975
|
previousReplacementHistory,
|
|
1866
|
-
|
|
1976
|
+
supportsOriginalDetail,
|
|
1867
1977
|
);
|
|
1868
|
-
if (
|
|
1978
|
+
if (whole.length > 0) {
|
|
1869
1979
|
try {
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1980
|
+
await sendRemoteCompaction(
|
|
1981
|
+
() => sendV1(whole, !canCompactBeforeTail),
|
|
1982
|
+
async () => {
|
|
1983
|
+
const prefix = buildOpenAiNativeHistory(
|
|
1984
|
+
convertToLlm(remotePrefixMessages),
|
|
1874
1985
|
model,
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
providerSessionState: summaryOptions.providerSessionState,
|
|
1883
|
-
codexCompaction: summaryOptions.codexCompaction,
|
|
1884
|
-
},
|
|
1885
|
-
),
|
|
1886
|
-
{ signal },
|
|
1986
|
+
previousReplacementHistory,
|
|
1987
|
+
supportsOriginalDetail,
|
|
1988
|
+
);
|
|
1989
|
+
if (prefix.length === 0) return false;
|
|
1990
|
+
await sendV1(prefix, true);
|
|
1991
|
+
return true;
|
|
1992
|
+
},
|
|
1887
1993
|
);
|
|
1888
|
-
preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
|
|
1889
1994
|
usedRemoteCompaction = true;
|
|
1890
1995
|
} catch (err) {
|
|
1891
1996
|
// A user/session abort is a cancellation, not a remote failure —
|
|
@@ -2157,6 +2262,7 @@ export async function compact(
|
|
|
2157
2262
|
tokensBefore,
|
|
2158
2263
|
details: { readFiles, modifiedFiles } as CompactionDetails,
|
|
2159
2264
|
preserveData: finalPreserveData,
|
|
2265
|
+
...(remoteReplayThroughEntryId ? { providerReplayThroughEntryId: remoteReplayThroughEntryId } : {}),
|
|
2160
2266
|
};
|
|
2161
2267
|
}
|
|
2162
2268
|
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
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
|
|
102
|
+
function collectRemoteCompactionEstimate(
|
|
98
103
|
value: unknown,
|
|
99
104
|
tokenizer: Tokenizer,
|
|
100
|
-
dropEncrypted
|
|
101
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
135
|
+
parts.imageTokens += cached.tokens;
|
|
136
|
+
return;
|
|
129
137
|
}
|
|
130
|
-
const
|
|
131
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
|
168
|
+
* opaque compaction state included, counts as decoded text.
|
|
148
169
|
*/
|
|
149
170
|
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
213
|
+
const { fragments, imageTokens } = remoteCompactionEstimateParts(
|
|
194
214
|
{ instructions, input, ...(tools ? { tools } : {}) },
|
|
195
215
|
tokenizer,
|
|
216
|
+
true,
|
|
196
217
|
);
|
|
197
|
-
const
|
|
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(
|
|
220
|
+
return { tokens: tokenizer.countTokens(fragments, "upperbound") + flatTokens, fits: true, textFits: true };
|
|
201
221
|
}
|
|
202
|
-
const budget = tokenizer.checkTokenBudget(
|
|
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:
|
|
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", {
|
package/src/output-budget.ts
CHANGED
|
@@ -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).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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)
|
|
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/tokenizer.ts
CHANGED
|
@@ -295,7 +295,7 @@ export class Tokenizer {
|
|
|
295
295
|
*/
|
|
296
296
|
countMessage(message: AgentMessage, options?: MessageCountOptions): number {
|
|
297
297
|
const floored = options?.excludeEncryptedReasoning === true;
|
|
298
|
-
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored);
|
|
298
|
+
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored, false);
|
|
299
299
|
const version = messageEstimateVersion(message);
|
|
300
300
|
let entry = this.#estimates.get(message);
|
|
301
301
|
if (entry === undefined || entry.version !== version) {
|
|
@@ -304,12 +304,25 @@ export class Tokenizer {
|
|
|
304
304
|
}
|
|
305
305
|
const cached = floored ? entry.floored : entry.default;
|
|
306
306
|
if (cached !== undefined) return cached;
|
|
307
|
-
const result = this.#measureMessage(message, floored);
|
|
307
|
+
const result = this.#measureMessage(message, floored, false);
|
|
308
308
|
if (floored) entry.floored = result;
|
|
309
309
|
else entry.default = result;
|
|
310
310
|
return result;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
/**
|
|
314
|
+
* {@link countMessage} for the transcript's latest surviving assistant turn
|
|
315
|
+
* (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
|
|
316
|
+
* byte-for-byte (`transformMessages`): a turn whose tool calls ended without
|
|
317
|
+
* a tool-use stop keeps its signatures then, so its reasoning is charged. A
|
|
318
|
+
* count of one message cannot see its position; a caller walking back from
|
|
319
|
+
* the end of the transcript counts that turn here. Not memoized: it differs
|
|
320
|
+
* from {@link countMessage} only for that one turn.
|
|
321
|
+
*/
|
|
322
|
+
countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number {
|
|
323
|
+
return this.#measureMessage(message, options?.excludeEncryptedReasoning === true, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
313
326
|
/** Sum of {@link countMessage} over `messages`. */
|
|
314
327
|
countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number {
|
|
315
328
|
let total = 0;
|
|
@@ -361,7 +374,7 @@ export class Tokenizer {
|
|
|
361
374
|
return total;
|
|
362
375
|
}
|
|
363
376
|
|
|
364
|
-
#measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
|
|
377
|
+
#measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean, latestTurn: boolean): number {
|
|
365
378
|
const fragments: string[] = [];
|
|
366
379
|
let extra = 0;
|
|
367
380
|
// Declaration-merged app roles (the coding-agent's bashExecution) are
|
|
@@ -407,14 +420,20 @@ export class Tokenizer {
|
|
|
407
420
|
// The replay's signature rule (`transformMessages`): an aborted or
|
|
408
421
|
// errored turn's final block can hold a partial signature, and a turn
|
|
409
422
|
// whose tool calls ended without a tool-use stop is abandoned, so all
|
|
410
|
-
// its signatures are stripped. Every other block completed.
|
|
411
|
-
// assistant turn keeps an abandoned
|
|
412
|
-
//
|
|
413
|
-
//
|
|
423
|
+
// its signatures are stripped. Every other block completed. On the
|
|
424
|
+
// Anthropic Messages API the latest assistant turn keeps an abandoned
|
|
425
|
+
// turn's signatures byte-for-byte (Bedrock's Converse replay strips
|
|
426
|
+
// them too); a per-message count cannot see position, so it follows
|
|
427
|
+
// the rule for older turns unless the caller says it is the latest
|
|
428
|
+
// (`countLatestTurn`).
|
|
414
429
|
const content = message.content;
|
|
415
430
|
const invalidStop = message.stopReason === "aborted" || message.stopReason === "error";
|
|
431
|
+
const keepsLatestSignatures = latestTurn && message.api === "anthropic-messages";
|
|
416
432
|
const abandonedToolUse =
|
|
417
|
-
!
|
|
433
|
+
!keepsLatestSignatures &&
|
|
434
|
+
!invalidStop &&
|
|
435
|
+
message.stopReason !== "toolUse" &&
|
|
436
|
+
content.some(block => block.type === "toolCall");
|
|
418
437
|
const untrustedIndex = invalidStop ? content.length - 1 : -1;
|
|
419
438
|
let signedReasoning = false;
|
|
420
439
|
for (let index = 0; index < content.length; index++) {
|
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
|
|
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
|
|
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>;
|