@oh-my-pi/pi-agent-core 18.10.3 → 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 +32 -0
- package/dist/types/agent.d.ts +4 -3
- package/dist/types/compaction/compaction.d.ts +12 -4
- package/dist/types/compaction/openai.d.ts +13 -2
- package/dist/types/output-budget.d.ts +17 -7
- package/dist/types/types.d.ts +5 -0
- package/package.json +8 -8
- package/src/agent-loop.ts +9 -11
- package/src/agent.ts +10 -7
- package/src/compaction/branch-summarization.ts +13 -2
- package/src/compaction/compaction.ts +248 -138
- package/src/compaction/openai.ts +73 -38
- package/src/output-budget.ts +22 -9
- package/src/types.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
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
|
+
|
|
23
|
+
## [18.10.4] - 2026-10-13
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `AgentTool.formatApprovalDetails` receives `ApprovalDetailsOptions`; `compact: true` asks for untruncated bodies so a compact approval prompt can count their lines ([#15550](https://github.com/can1357/oh-my-pi/pull/15550) by [@H4vC](https://github.com/H4vC))
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Before-model-call hooks now receive the loop context as a second argument, and `buildProviderContext` receives the summarizer model ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- Fixed a Claude compaction or branch summarizer receiving assistant reasoning from a snapcompact archive rendered under another model, which Claude refuses (`reasoning_extraction`) ([#15436](https://github.com/can1357/oh-my-pi/pull/15436) by [@will-bogusz](https://github.com/will-bogusz))
|
|
36
|
+
|
|
5
37
|
## [18.10.3] - 2026-10-12
|
|
6
38
|
|
|
7
39
|
### Breaking Changes
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
|
3
3
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
4
4
|
import type { AppendOnlyContextManager } from "./append-only-context.js";
|
|
5
5
|
import { Tokenizer } from "./tokenizer.js";
|
|
6
|
-
import type { AgentBeforeModelCall, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
6
|
+
import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
7
7
|
export declare class AgentBusyError extends Error {
|
|
8
8
|
constructor(message?: string);
|
|
9
9
|
}
|
|
@@ -427,8 +427,9 @@ export declare class Agent {
|
|
|
427
427
|
onQueueChange(listener: () => void): () => void;
|
|
428
428
|
/** Register an independently removable hook that runs before queued messages are consumed. */
|
|
429
429
|
addBeforeQueuedMessageDequeueHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
430
|
-
/** Register an independently removable hook that runs immediately before each model call
|
|
431
|
-
|
|
430
|
+
/** Register an independently removable hook that runs immediately before each model call, with the
|
|
431
|
+
* loop's live context (mutating `context.messages` changes what this and later calls send). */
|
|
432
|
+
addBeforeModelCallHook(hook: (signal: AbortSignal | undefined, context: AgentContext) => Promise<void> | void): () => void;
|
|
432
433
|
/**
|
|
433
434
|
* Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
|
|
434
435
|
* it to the editor): steering live steering took for the in-flight response, then each batch
|
|
@@ -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;
|
|
@@ -187,11 +193,11 @@ export interface SummaryOptions {
|
|
|
187
193
|
convertToLlm?: ConvertToLlm;
|
|
188
194
|
/**
|
|
189
195
|
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
190
|
-
* `summarized
|
|
191
|
-
* thinking keeps its prefix; without
|
|
192
|
-
* `tools` and `convertToLlm`.
|
|
196
|
+
* `summarized`, as read by the summarizer `model`. Anthropic on-demand
|
|
197
|
+
* compaction sends it so retained signed thinking keeps its prefix; without
|
|
198
|
+
* it the request uses `remoteSystemPrompt`, `tools` and `convertToLlm`.
|
|
193
199
|
*/
|
|
194
|
-
buildProviderContext?: (summarized: AgentMessage[], retained: AgentMessage[], signal
|
|
200
|
+
buildProviderContext?: (summarized: AgentMessage[], retained: AgentMessage[], signal: AbortSignal | undefined, model: Model) => Promise<Context>;
|
|
195
201
|
/**
|
|
196
202
|
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
197
203
|
* next to the compaction item. Defaults to `role === "user"`; hosts whose
|
|
@@ -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
|
|
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
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1009,6 +1009,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|
|
1009
1009
|
loadMode?: ToolLoadMode;
|
|
1010
1010
|
/** Short one-line summary used for tool discovery indexes. */
|
|
1011
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;
|
|
1012
1017
|
/**
|
|
1013
1018
|
* On-demand documentation topics (`topic → markdown`), readable as
|
|
1014
1019
|
* `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
|
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.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.
|
|
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.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.
|
|
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
|
|
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
|
@@ -504,7 +504,7 @@ export class Agent {
|
|
|
504
504
|
#telemetry?: AgentLoopConfig["telemetry"];
|
|
505
505
|
#appendOnlyContext?: AppendOnlyContextManager;
|
|
506
506
|
#beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise<void> | void>();
|
|
507
|
-
#beforeModelCallHooks = new Set<(signal
|
|
507
|
+
#beforeModelCallHooks = new Set<(signal: AbortSignal | undefined, context: AgentContext) => Promise<void> | void>();
|
|
508
508
|
|
|
509
509
|
/** Buffered Cursor tool results with text length at time of call (for correct ordering) */
|
|
510
510
|
#cursorToolResultBuffer: CursorToolResultEntry[] = [];
|
|
@@ -964,15 +964,18 @@ export class Agent {
|
|
|
964
964
|
return () => this.#beforeQueuedMessageDequeueHooks.delete(registration);
|
|
965
965
|
}
|
|
966
966
|
|
|
967
|
-
/** Register an independently removable hook that runs immediately before each model call
|
|
968
|
-
|
|
969
|
-
|
|
967
|
+
/** Register an independently removable hook that runs immediately before each model call, with the
|
|
968
|
+
* loop's live context (mutating `context.messages` changes what this and later calls send). */
|
|
969
|
+
addBeforeModelCallHook(
|
|
970
|
+
hook: (signal: AbortSignal | undefined, context: AgentContext) => Promise<void> | void,
|
|
971
|
+
): () => void {
|
|
972
|
+
const registration = (signal: AbortSignal | undefined, context: AgentContext) => hook(signal, context);
|
|
970
973
|
this.#beforeModelCallHooks.add(registration);
|
|
971
974
|
return () => this.#beforeModelCallHooks.delete(registration);
|
|
972
975
|
}
|
|
973
976
|
|
|
974
|
-
async #runBeforeModelCallHooks(signal
|
|
975
|
-
for (const hook of this.#beforeModelCallHooks) await hook(signal);
|
|
977
|
+
async #runBeforeModelCallHooks(signal: AbortSignal | undefined, context: AgentContext): Promise<void> {
|
|
978
|
+
for (const hook of this.#beforeModelCallHooks) await hook(signal, context);
|
|
976
979
|
}
|
|
977
980
|
|
|
978
981
|
async #runBeforeQueuedMessageDequeueHooks(signal?: AbortSignal): Promise<void> {
|
|
@@ -1884,7 +1887,7 @@ export class Agent {
|
|
|
1884
1887
|
getApiKey: this.getApiKey,
|
|
1885
1888
|
getToolContext: this.#getToolContext,
|
|
1886
1889
|
syncContextBeforeModelCall: async (context, signal) => {
|
|
1887
|
-
await this.#runBeforeModelCallHooks(signal);
|
|
1890
|
+
await this.#runBeforeModelCallHooks(signal, context);
|
|
1888
1891
|
if (this.#listeners.size > 0) {
|
|
1889
1892
|
await Bun.sleep(0);
|
|
1890
1893
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
|
|
9
9
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
10
10
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
11
12
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
12
13
|
import { Tokenizer } from "../tokenizer";
|
|
13
14
|
import type { AgentMessage } from "../types";
|
|
@@ -326,12 +327,22 @@ export async function generateBranchSummary(
|
|
|
326
327
|
const tokenBudget = contextWindow - reserveTokens;
|
|
327
328
|
const tokenizer = new Tokenizer(model);
|
|
328
329
|
|
|
329
|
-
const { messages, fileOps } = prepareBranchEntries(entries, tokenizer, tokenBudget);
|
|
330
|
+
const { messages: branchMessages, fileOps } = prepareBranchEntries(entries, tokenizer, tokenBudget);
|
|
330
331
|
|
|
331
|
-
if (
|
|
332
|
+
if (branchMessages.length === 0) {
|
|
332
333
|
return { summary: "No content to summarize" };
|
|
333
334
|
}
|
|
334
335
|
|
|
336
|
+
// A snapcompact summary rendered for another model may carry reasoning
|
|
337
|
+
// sections and their legend, which Claude refuses (issue #6093).
|
|
338
|
+
const messages = snapcompact.readerIncludesThinking(model)
|
|
339
|
+
? branchMessages
|
|
340
|
+
: branchMessages.map(message =>
|
|
341
|
+
message.role === "compactionSummary"
|
|
342
|
+
? { ...message, summary: snapcompact.textWithoutThinking(message.summary) }
|
|
343
|
+
: message,
|
|
344
|
+
);
|
|
345
|
+
|
|
335
346
|
// Transform to LLM-compatible messages, then serialize to text
|
|
336
347
|
// Serialization prevents the model from treating it as a conversation to continue
|
|
337
348
|
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
@@ -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
|
// ============================================================================
|
|
@@ -673,14 +679,15 @@ export interface SummaryOptions {
|
|
|
673
679
|
convertToLlm?: ConvertToLlm;
|
|
674
680
|
/**
|
|
675
681
|
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
676
|
-
* `summarized
|
|
677
|
-
* thinking keeps its prefix; without
|
|
678
|
-
* `tools` and `convertToLlm`.
|
|
682
|
+
* `summarized`, as read by the summarizer `model`. Anthropic on-demand
|
|
683
|
+
* compaction sends it so retained signed thinking keeps its prefix; without
|
|
684
|
+
* it the request uses `remoteSystemPrompt`, `tools` and `convertToLlm`.
|
|
679
685
|
*/
|
|
680
686
|
buildProviderContext?: (
|
|
681
687
|
summarized: AgentMessage[],
|
|
682
688
|
retained: AgentMessage[],
|
|
683
|
-
signal
|
|
689
|
+
signal: AbortSignal | undefined,
|
|
690
|
+
model: Model,
|
|
684
691
|
) => Promise<Context>;
|
|
685
692
|
/**
|
|
686
693
|
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
@@ -1267,6 +1274,8 @@ export interface CompactionPreparation {
|
|
|
1267
1274
|
recentMessages: AgentMessage[];
|
|
1268
1275
|
/** Entry IDs parallel to recentMessages, for an Anthropic-safe keep-tail boundary. */
|
|
1269
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;
|
|
1270
1279
|
/** Whether this is a split turn (cut point in middle of turn) */
|
|
1271
1280
|
isSplitTurn: boolean;
|
|
1272
1281
|
tokensBefore: number;
|
|
@@ -1479,6 +1488,7 @@ export function prepareCompaction(
|
|
|
1479
1488
|
turnPrefixMessages,
|
|
1480
1489
|
recentMessages,
|
|
1481
1490
|
recentEntryIds,
|
|
1491
|
+
lastSummarizedEntryId: compactionEntries[cutPoint.firstKeptEntryIndex - 1]?.id,
|
|
1482
1492
|
isSplitTurn: cutPoint.isSplitTurn,
|
|
1483
1493
|
tokensBefore,
|
|
1484
1494
|
previousSummary: previousCompaction?.summary,
|
|
@@ -1586,6 +1596,13 @@ function formatRemoteCompactionSummary(inputTokens: number): string {
|
|
|
1586
1596
|
);
|
|
1587
1597
|
}
|
|
1588
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
|
+
|
|
1589
1606
|
/**
|
|
1590
1607
|
* Generate summaries for compaction using prepared data.
|
|
1591
1608
|
* Returns CompactionResult - SessionManager adds id/parentId when saving.
|
|
@@ -1607,6 +1624,7 @@ export async function compact(
|
|
|
1607
1624
|
turnPrefixMessages,
|
|
1608
1625
|
recentMessages,
|
|
1609
1626
|
recentEntryIds,
|
|
1627
|
+
lastSummarizedEntryId,
|
|
1610
1628
|
isSplitTurn,
|
|
1611
1629
|
tokensBefore,
|
|
1612
1630
|
previousSummary,
|
|
@@ -1647,12 +1665,28 @@ export async function compact(
|
|
|
1647
1665
|
oneshotRetry: options?.oneshotRetry,
|
|
1648
1666
|
};
|
|
1649
1667
|
|
|
1650
|
-
|
|
1668
|
+
// An archive keeps the reasoning policy of the model it was rendered for;
|
|
1669
|
+
// Claude refuses inputs that reproduce reasoning (issue #6093), so a
|
|
1670
|
+
// summarizer whose classifier refuses quoted reasoning (the
|
|
1671
|
+
// `classifier-cot-extraction` catalog axis) reads the archive's no-reasoning view (scrubbed
|
|
1672
|
+
// while its tool-output spans are still marked), and a summary with no
|
|
1673
|
+
// archive behind it is scrubbed of `¶think:` sections.
|
|
1674
|
+
const withoutThinking = !snapcompact.readerIncludesThinking(model);
|
|
1675
|
+
const storedSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData);
|
|
1676
|
+
const snapcompactView =
|
|
1677
|
+
storedSnapcompactArchive && withoutThinking
|
|
1678
|
+
? snapcompact.viewWithoutThinking(previousSummary ?? "", storedSnapcompactArchive)
|
|
1679
|
+
: undefined;
|
|
1680
|
+
const previousSnapcompactArchive = snapcompactView?.archive ?? storedSnapcompactArchive;
|
|
1681
|
+
const previousSummaryForReader =
|
|
1682
|
+
withoutThinking && previousSummary !== undefined
|
|
1683
|
+
? (snapcompactView?.summary ?? snapcompact.textWithoutThinking(previousSummary))
|
|
1684
|
+
: previousSummary;
|
|
1651
1685
|
const previousSnapcompactArchiveText = previousSnapcompactArchive
|
|
1652
1686
|
? snapcompact.archiveSourceText(previousSnapcompactArchive)
|
|
1653
1687
|
: undefined;
|
|
1654
1688
|
const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive(
|
|
1655
|
-
|
|
1689
|
+
previousSummaryForReader,
|
|
1656
1690
|
previousSnapcompactArchiveText,
|
|
1657
1691
|
);
|
|
1658
1692
|
const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText
|
|
@@ -1663,23 +1697,58 @@ export async function compact(
|
|
|
1663
1697
|
// A local summary has no native payload to carry it into the first remote
|
|
1664
1698
|
// request. Encode it as history; do not resend opaque native placeholders.
|
|
1665
1699
|
const previousSummaryMigrationMessage =
|
|
1666
|
-
settings.remoteEnabled !== false &&
|
|
1667
|
-
? createCompactionSummaryMessage(
|
|
1700
|
+
settings.remoteEnabled !== false && previousSummaryForReader && !previousNativeHistory
|
|
1701
|
+
? createCompactionSummaryMessage(previousSummaryForReader, tokensBefore, new Date().toISOString())
|
|
1668
1702
|
: undefined;
|
|
1669
1703
|
|
|
1670
1704
|
let preserveData = withAnthropicCompactionPreserveData(
|
|
1671
1705
|
withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined),
|
|
1672
1706
|
undefined,
|
|
1673
1707
|
);
|
|
1674
|
-
const
|
|
1708
|
+
const remotePrefixMessages: AgentMessage[] = [
|
|
1675
1709
|
...(previousSummaryMigrationMessage ? [previousSummaryMigrationMessage] : []),
|
|
1676
1710
|
...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
|
|
1677
1711
|
...messagesToSummarize,
|
|
1678
1712
|
...turnPrefixMessages,
|
|
1679
|
-
...recentMessages,
|
|
1680
1713
|
];
|
|
1714
|
+
const remoteMessages: AgentMessage[] = [...remotePrefixMessages, ...recentMessages];
|
|
1681
1715
|
let usedRemoteCompaction = false;
|
|
1716
|
+
let remoteReplayThroughEntryId: string | undefined;
|
|
1682
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
|
+
};
|
|
1683
1752
|
if (
|
|
1684
1753
|
settings.remoteEnabled !== false &&
|
|
1685
1754
|
settings.remoteStreamingV2Enabled !== false &&
|
|
@@ -1691,126 +1760,147 @@ export async function compact(
|
|
|
1691
1760
|
? previousRemoteCompaction.replacementHistory
|
|
1692
1761
|
: undefined;
|
|
1693
1762
|
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1694
|
-
const messages = convertToLlm(remoteMessages);
|
|
1695
1763
|
// Replacement history keeps what the user wrote. Summaries, archive
|
|
1696
1764
|
// migrations, and custom/hook messages can serialize as user-role items
|
|
1697
1765
|
// too, so pick the user's own messages before serialization erases that,
|
|
1698
1766
|
// then serialize each one exactly as the request does.
|
|
1699
1767
|
const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
|
|
1700
|
-
const userMessages = convertToLlm(
|
|
1701
|
-
[...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
|
|
1702
|
-
);
|
|
1703
|
-
const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
|
|
1704
1768
|
const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
throw new Error("Stored Codex V2 compaction history contains an invalid input item");
|
|
1712
|
-
}
|
|
1713
|
-
previousCodexInput.push(item);
|
|
1714
|
-
}
|
|
1715
|
-
codexBody = await buildTransformedCodexRequestBody(
|
|
1716
|
-
model,
|
|
1717
|
-
{ systemPrompt: remoteSystemPrompt, messages, tools: summaryOptions.tools },
|
|
1718
|
-
{
|
|
1719
|
-
reasoning: resolveCompactionEffort(model, summaryOptions.thinkingLevel),
|
|
1720
|
-
forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
|
|
1721
|
-
responsesLite: model.useResponsesLite,
|
|
1722
|
-
sessionId: summaryOptions.sessionId,
|
|
1723
|
-
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1724
|
-
providerSessionState: summaryOptions.providerSessionState,
|
|
1725
|
-
codexCompaction: createOpenAICodexCompactionRequestContext({
|
|
1726
|
-
context: summaryOptions.codexCompaction,
|
|
1727
|
-
implementation: "responses_compaction_v2",
|
|
1728
|
-
}),
|
|
1729
|
-
},
|
|
1730
|
-
undefined,
|
|
1731
|
-
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
|
+
),
|
|
1732
1775
|
);
|
|
1733
|
-
const
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
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);
|
|
1738
1786
|
}
|
|
1739
|
-
|
|
1740
|
-
}
|
|
1741
|
-
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1742
|
-
codexBody.input = remoteHistory;
|
|
1743
|
-
for (const message of userMessages) {
|
|
1744
|
-
const userBody = await buildTransformedCodexRequestBody(
|
|
1787
|
+
codexBody = await buildTransformedCodexRequestBody(
|
|
1745
1788
|
model,
|
|
1746
|
-
{ messages:
|
|
1747
|
-
{
|
|
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,
|
|
1748
1804
|
);
|
|
1749
|
-
|
|
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
|
+
}
|
|
1750
1828
|
}
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
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
|
+
});
|
|
1755
1864
|
}
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
|
|
1770
|
-
: undefined;
|
|
1771
|
-
const trimmed = trimRemoteCompactionInputToContextWindow(
|
|
1772
|
-
remoteHistory,
|
|
1773
|
-
new Tokenizer(model),
|
|
1774
|
-
model.contextWindow,
|
|
1775
|
-
instructions,
|
|
1776
|
-
tools,
|
|
1777
|
-
);
|
|
1778
|
-
if (trimmed.rewrittenOutputs > 0) {
|
|
1779
|
-
logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
|
|
1780
|
-
model: model.id,
|
|
1781
|
-
provider: model.provider,
|
|
1782
|
-
rewrittenOutputs: trimmed.rewrittenOutputs,
|
|
1783
|
-
estimatedTokensBefore: trimmed.estimatedTokensBefore,
|
|
1784
|
-
estimatedTokensAfter: trimmed.estimatedTokensAfter,
|
|
1785
|
-
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),
|
|
1786
1878
|
});
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
{ 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
|
+
},
|
|
1812
1903
|
);
|
|
1813
|
-
preserveData = { ...preserveData, ...storeCompactionV2PreserveData(remote, model) };
|
|
1814
1904
|
usedRemoteCompaction = true;
|
|
1815
1905
|
} catch (err) {
|
|
1816
1906
|
// A user/session abort is a cancellation, not a remote failure —
|
|
@@ -1842,33 +1932,52 @@ export async function compact(
|
|
|
1842
1932
|
: previousV2Compaction?.provider === model.provider
|
|
1843
1933
|
? previousV2Compaction.replacementHistory
|
|
1844
1934
|
: undefined;
|
|
1845
|
-
const
|
|
1846
|
-
|
|
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),
|
|
1847
1961
|
model,
|
|
1848
1962
|
previousReplacementHistory,
|
|
1849
|
-
|
|
1963
|
+
supportsOriginalDetail,
|
|
1850
1964
|
);
|
|
1851
|
-
if (
|
|
1965
|
+
if (whole.length > 0) {
|
|
1852
1966
|
try {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1967
|
+
await sendRemoteCompaction(
|
|
1968
|
+
() => sendV1(whole, !canCompactBeforeTail),
|
|
1969
|
+
async () => {
|
|
1970
|
+
const prefix = buildOpenAiNativeHistory(
|
|
1971
|
+
convertToLlm(remotePrefixMessages),
|
|
1857
1972
|
model,
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
providerSessionState: summaryOptions.providerSessionState,
|
|
1866
|
-
codexCompaction: summaryOptions.codexCompaction,
|
|
1867
|
-
},
|
|
1868
|
-
),
|
|
1869
|
-
{ signal },
|
|
1973
|
+
previousReplacementHistory,
|
|
1974
|
+
supportsOriginalDetail,
|
|
1975
|
+
);
|
|
1976
|
+
if (prefix.length === 0) return false;
|
|
1977
|
+
await sendV1(prefix, true);
|
|
1978
|
+
return true;
|
|
1979
|
+
},
|
|
1870
1980
|
);
|
|
1871
|
-
preserveData = withOpenAiRemoteCompactionPreserveData(preserveData, remote);
|
|
1872
1981
|
usedRemoteCompaction = true;
|
|
1873
1982
|
} catch (err) {
|
|
1874
1983
|
// A user/session abort is a cancellation, not a remote failure —
|
|
@@ -1981,7 +2090,7 @@ export async function compact(
|
|
|
1981
2090
|
// history when the host can build them: kept thinking remains valid
|
|
1982
2091
|
// only when the summarized prefix matches what was sent byte for byte.
|
|
1983
2092
|
const context = summaryOptions.buildProviderContext
|
|
1984
|
-
? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
|
|
2093
|
+
? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal, model)
|
|
1985
2094
|
: {
|
|
1986
2095
|
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1987
2096
|
messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
|
|
@@ -2140,6 +2249,7 @@ export async function compact(
|
|
|
2140
2249
|
tokensBefore,
|
|
2141
2250
|
details: { readFiles, modifiedFiles } as CompactionDetails,
|
|
2142
2251
|
preserveData: finalPreserveData,
|
|
2252
|
+
...(remoteReplayThroughEntryId ? { providerReplayThroughEntryId: remoteReplayThroughEntryId } : {}),
|
|
2143
2253
|
};
|
|
2144
2254
|
}
|
|
2145
2255
|
|
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/types.ts
CHANGED
|
@@ -1142,6 +1142,11 @@ export interface AgentTool<
|
|
|
1142
1142
|
loadMode?: ToolLoadMode;
|
|
1143
1143
|
/** Short one-line summary used for tool discovery indexes. */
|
|
1144
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;
|
|
1145
1150
|
/**
|
|
1146
1151
|
* On-demand documentation topics (`topic → markdown`), readable as
|
|
1147
1152
|
* `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
|