@oh-my-pi/pi-agent-core 18.6.1 → 18.7.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 +29 -0
- package/dist/types/agent.d.ts +12 -5
- package/dist/types/compaction/anthropic.d.ts +14 -8
- package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
- package/dist/types/compaction/compaction.d.ts +14 -0
- package/dist/types/compaction/entries.d.ts +2 -2
- package/dist/types/compaction/openai.d.ts +6 -0
- package/dist/types/image-tokens.d.ts +6 -7
- package/dist/types/telemetry.d.ts +2 -2
- package/dist/types/tokenizer.d.ts +7 -5
- package/package.json +8 -8
- package/src/agent.ts +88 -17
- package/src/compaction/anthropic.ts +30 -12
- package/src/compaction/compaction-v2-streaming.ts +13 -15
- package/src/compaction/compaction.ts +127 -31
- package/src/compaction/entries.ts +2 -2
- package/src/compaction/openai.ts +15 -4
- package/src/image-tokens.ts +23 -52
- package/src/telemetry.ts +2 -2
- package/src/tokenizer.ts +20 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.7.0] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed interrupted runs so assistant message boundaries are emitted reliably, allowing subscribers to persist and recover the interrupted turn.
|
|
10
|
+
|
|
11
|
+
## [18.6.3] - 2026-10-06
|
|
12
|
+
|
|
13
|
+
### Breaking Changes
|
|
14
|
+
|
|
15
|
+
- `Agent.withdrawUndeliveredQueuedMessages()` replaces `withdrawLiveSteering()` and returns `{ steering, followUp }`: it also takes back queued input already dequeued for the next model call, which the aborted run then neither records nor reports in `agent_end` ([#14179](https://github.com/can1357/oh-my-pi/pull/14179) by [@andrebrait](https://github.com/andrebrait))
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Added `Agent.setOnModelCallSystemPrompt`, called with the exact system prompt each model call is built from ([#14338](https://github.com/can1357/oh-my-pi/pull/14338) by [@will-bogusz](https://github.com/will-bogusz))
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `SessionInitEntry.systemPrompt` holds the system prompt blocks as sent; session files written earlier keep one joined string ([#14338](https://github.com/can1357/oh-my-pi/pull/14338) by [@will-bogusz](https://github.com/will-bogusz))
|
|
24
|
+
- Image token estimates now use the catalog's `imageTokens()` formula with its OpenAI Responses wire rule; values are unchanged ([#14286](https://github.com/can1357/oh-my-pi/pull/14286) by [@will-bogusz](https://github.com/will-bogusz)).
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Fixed the context count and auto-compaction trigger pricing every snapcompact archive frame at Opus's high-res rate, which overcounted Codex and Gemini sessions and could re-trigger compaction right after compacting ([#14291](https://github.com/can1357/oh-my-pi/pull/14291) by [@will-bogusz](https://github.com/will-bogusz))
|
|
29
|
+
- Fixed native (OpenAI) compaction being refused as over the context window in sessions with many screenshots. Images were counted as about 1,200 tokens when deciding to compact but 12,000 when checking whether the compaction request fits; both checks now estimate images from their actual dimensions, and a request is no longer refused when only the image estimate pushes it over the window ([#14260](https://github.com/can1357/oh-my-pi/pull/14260) by [@H4vC](https://github.com/H4vC)).
|
|
30
|
+
- Fixed OpenAI and Codex Remote Compaction V2 dropping your recent messages instead of keeping them next to the compaction summary ([#14247](https://github.com/can1357/oh-my-pi/pull/14247) by [@will-bogusz](https://github.com/will-bogusz))
|
|
31
|
+
- Fixed the failed V2 remote compaction warning claiming a V1 fallback on Codex, where V1 does not run ([#14245](https://github.com/can1357/oh-my-pi/pull/14245) by [@will-bogusz](https://github.com/will-bogusz))
|
|
32
|
+
- Fixed Anthropic native compaction being rejected with `Invalid signature in thinking block` (or silently dropping the summarized thinking) on models with preserved thinking ([#14251](https://github.com/can1357/oh-my-pi/pull/14251) by [@will-bogusz](https://github.com/will-bogusz))
|
|
33
|
+
|
|
5
34
|
## [18.6.1] - 2026-10-04
|
|
6
35
|
|
|
7
36
|
### Fixed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -390,8 +390,8 @@ export declare class Agent {
|
|
|
390
390
|
get state(): AgentState;
|
|
391
391
|
/**
|
|
392
392
|
* Tokenizer for the active model. The instance is replaced whenever the
|
|
393
|
-
* active model's encoding
|
|
394
|
-
* not cache it across model switches.
|
|
393
|
+
* active model's encoding or snapcompact frame pricing changes (see
|
|
394
|
+
* {@link setModel}), so callers must not cache it across model switches.
|
|
395
395
|
*/
|
|
396
396
|
get tokenizer(): Tokenizer;
|
|
397
397
|
get appendOnlyContext(): AppendOnlyContextManager | undefined;
|
|
@@ -422,10 +422,15 @@ export declare class Agent {
|
|
|
422
422
|
/** Register an independently removable hook that runs immediately before each model call. */
|
|
423
423
|
addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void;
|
|
424
424
|
/**
|
|
425
|
-
* Take back
|
|
426
|
-
* the
|
|
425
|
+
* Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
|
|
426
|
+
* it to the editor): steering live steering took for the in-flight response, then each batch
|
|
427
|
+
* dequeued for the next model call, oldest first. The aborted run then neither records it (or
|
|
428
|
+
* the context prepared for it) nor requeues it.
|
|
427
429
|
*/
|
|
428
|
-
|
|
430
|
+
withdrawUndeliveredQueuedMessages(): {
|
|
431
|
+
steering: AgentMessage[];
|
|
432
|
+
followUp: AgentMessage[];
|
|
433
|
+
};
|
|
429
434
|
/** Steering live steering took for the streaming response; the transcript records it once
|
|
430
435
|
* that response (or its tool batch) ends, which is when the model switches to it. */
|
|
431
436
|
peekLiveSteeredMessages(): AgentMessage[];
|
|
@@ -434,6 +439,8 @@ export declare class Agent {
|
|
|
434
439
|
setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
|
|
435
440
|
setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
|
|
436
441
|
setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
|
|
442
|
+
/** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
|
|
443
|
+
setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void;
|
|
437
444
|
/**
|
|
438
445
|
* Install or replace the host pre-model-call gate; pass `undefined` to
|
|
439
446
|
* remove it. Gates are sampled when a run starts: installing the first
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Anthropic on-demand compaction (`compact-2026-09-04` beta).
|
|
3
3
|
*
|
|
4
|
-
* The request sends only the prefix to summarize,
|
|
5
|
-
* system prompt, tools and thinking settings.
|
|
6
|
-
* replaces that prefix; the retained tail is
|
|
4
|
+
* The request sends only the prefix to summarize, as the live conversation
|
|
5
|
+
* sent it: same system prompt, tools, history bytes and thinking settings.
|
|
6
|
+
* The returned signed block replaces that prefix; the retained tail is
|
|
7
|
+
* replayed from session entries.
|
|
7
8
|
*/
|
|
8
|
-
import type { AnthropicCompactionPayload, ApiKey,
|
|
9
|
+
import type { AnthropicCompactionFiles, AnthropicCompactionPayload, ApiKey, Context, Effort, Model, SimpleStreamOptions, Usage } from "@oh-my-pi/pi-ai";
|
|
9
10
|
import { type InstrumentedChatSpanOptions } from "../telemetry.js";
|
|
10
11
|
import type { AgentMessage } from "../types.js";
|
|
11
12
|
export declare const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
|
|
@@ -17,8 +18,12 @@ export interface AnthropicCompactionPreserveData {
|
|
|
17
18
|
signature?: string;
|
|
18
19
|
/** Legacy threshold block state; replay-only. */
|
|
19
20
|
encryptedContent?: string;
|
|
20
|
-
/** Harness file metadata (`<files>` section) replayed after the
|
|
21
|
+
/** Harness file metadata (`<files>` section) replayed after the retained tail. */
|
|
21
22
|
filesText?: string;
|
|
23
|
+
/** Earlier summaries' file metadata replayed inside the retained tail. */
|
|
24
|
+
retainedFiles?: AnthropicCompactionFiles[];
|
|
25
|
+
/** The retained tail replays unchanged (see `AnthropicCompactionPayload.exactTail`). */
|
|
26
|
+
exactTail?: true;
|
|
22
27
|
/** Model that wrote the summary. */
|
|
23
28
|
model?: string;
|
|
24
29
|
/** Prompt tokens the compaction request processed, for display. */
|
|
@@ -51,10 +56,11 @@ export declare function findAnthropicCompactionCut(messages: readonly (AgentMess
|
|
|
51
56
|
/** Instructions replace the API default; the request contains only summarized messages. */
|
|
52
57
|
export declare function buildAnthropicCompactionInstructions(basePrompt: string, customInstructions: string | undefined, extraContext: string | undefined): string;
|
|
53
58
|
export interface AnthropicNativeCompactionRequest {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
tools?: Tool[];
|
|
59
|
+
/** The summarized prefix as the live turn would send it. */
|
|
60
|
+
context: Context;
|
|
57
61
|
instructions: string;
|
|
62
|
+
/** Replayed file metadata due before this time ends the request (see `AnthropicCompactionRequest.filesDueBefore`). */
|
|
63
|
+
filesDueBefore?: number;
|
|
58
64
|
maxTokens: number;
|
|
59
65
|
reasoning?: Effort;
|
|
60
66
|
}
|
|
@@ -26,6 +26,8 @@ export interface CompactionV2Usage {
|
|
|
26
26
|
export interface CompactionV2Request {
|
|
27
27
|
body: OpenAICodexCompactionBody;
|
|
28
28
|
input: unknown[];
|
|
29
|
+
/** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
|
|
30
|
+
retainedUserItems: unknown[];
|
|
29
31
|
retainedMessageBudget: number;
|
|
30
32
|
sessionId?: string;
|
|
31
33
|
promptCacheKey?: string;
|
|
@@ -56,12 +58,14 @@ export declare function buildCompactionV2Request(model: Model, input: unknown[],
|
|
|
56
58
|
sessionId?: string;
|
|
57
59
|
promptCacheKey?: string;
|
|
58
60
|
retainedMessageBudget?: number;
|
|
61
|
+
retainedUserItems?: unknown[];
|
|
59
62
|
}): CompactionV2Request;
|
|
60
63
|
/** Wrap a body built by the normal Codex serializer for V2 compaction transport. */
|
|
61
64
|
export declare function buildCompactionV2RequestFromBody(model: Model, body: OpenAICodexCompactionBody, options?: {
|
|
62
65
|
sessionId?: string;
|
|
63
66
|
promptCacheKey?: string;
|
|
64
67
|
retainedMessageBudget?: number;
|
|
68
|
+
retainedUserItems?: unknown[];
|
|
65
69
|
}): CompactionV2Request;
|
|
66
70
|
/** Request V2 compaction over the normal OpenAI Responses streaming endpoint. */
|
|
67
71
|
export declare function requestCompactionV2Streaming(model: Model, apiKey: string, request: CompactionV2Request, signal?: AbortSignal, options?: {
|
|
@@ -72,7 +76,7 @@ export declare function requestCompactionV2Streaming(model: Model, apiKey: strin
|
|
|
72
76
|
codexCompaction?: CodexCompactionContext;
|
|
73
77
|
preferWebsockets?: boolean;
|
|
74
78
|
}): Promise<CompactionV2Response>;
|
|
75
|
-
/** Build Codex-style V2 replacement history from
|
|
79
|
+
/** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
|
|
76
80
|
export declare function buildCompactionV2ReplacementHistory(input: unknown[], compactionItem: Record<string, unknown>, retainedMessageBudget?: number): {
|
|
77
81
|
replacementHistory: Array<Record<string, unknown>>;
|
|
78
82
|
retainedImageCount: number;
|
|
@@ -177,6 +177,20 @@ export interface SummaryOptions {
|
|
|
177
177
|
initiatorOverride?: MessageAttribution;
|
|
178
178
|
metadata?: Record<string, unknown>;
|
|
179
179
|
convertToLlm?: ConvertToLlm;
|
|
180
|
+
/**
|
|
181
|
+
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
182
|
+
* `summarized`. Anthropic on-demand compaction sends it so retained signed
|
|
183
|
+
* thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
|
|
184
|
+
* `tools` and `convertToLlm`.
|
|
185
|
+
*/
|
|
186
|
+
buildProviderContext?: (summarized: AgentMessage[], retained: AgentMessage[], signal?: AbortSignal) => Promise<Context>;
|
|
187
|
+
/**
|
|
188
|
+
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
189
|
+
* next to the compaction item. Defaults to `role === "user"`; hosts whose
|
|
190
|
+
* user-initiated turns also arrive as custom messages (e.g. skill invocations)
|
|
191
|
+
* widen it.
|
|
192
|
+
*/
|
|
193
|
+
isUserAuthored?: (message: AgentMessage) => boolean;
|
|
180
194
|
/**
|
|
181
195
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
182
196
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
@@ -88,8 +88,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
|
88
88
|
}
|
|
89
89
|
export interface SessionInitEntry extends SessionEntryBase {
|
|
90
90
|
type: "session_init";
|
|
91
|
-
/**
|
|
92
|
-
systemPrompt: string;
|
|
91
|
+
/** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
|
|
92
|
+
systemPrompt: string[] | string;
|
|
93
93
|
/** Initial task/user message */
|
|
94
94
|
task: string;
|
|
95
95
|
/** Tools available to the agent */
|
|
@@ -28,6 +28,12 @@ export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompac
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const REMOTE_COMPACTION_TIMEOUT_MS = 300000;
|
|
30
30
|
export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
|
|
31
|
+
/**
|
|
32
|
+
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
33
|
+
* count at their image estimate rather than as base64 text; everything else,
|
|
34
|
+
* opaque compaction state included, counts as serialized.
|
|
35
|
+
*/
|
|
36
|
+
export declare function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number;
|
|
31
37
|
export interface TrimRemoteCompactionInputResult {
|
|
32
38
|
input: Array<Record<string, unknown>>;
|
|
33
39
|
rewrittenOutputs: number;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { type ImageSize } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
|
|
2
3
|
/**
|
|
3
4
|
* Dimension-based image token estimates shared by the local context counter
|
|
4
5
|
* ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
|
|
@@ -7,16 +8,14 @@ import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
|
7
8
|
* stayed under the local compaction trigger yet was refused as over-window by
|
|
8
9
|
* the remote probe.
|
|
9
10
|
*
|
|
10
|
-
* Follows
|
|
11
|
-
* and patch budget, x1.2 multiplier),
|
|
12
|
-
*
|
|
11
|
+
* Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
|
|
12
|
+
* 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
|
|
13
|
+
* which is also a close upper estimate for other providers once omp has
|
|
14
|
+
* downscaled the image (≤1568px by default).
|
|
13
15
|
*/
|
|
14
16
|
/** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
|
|
15
17
|
export type ImageDetail = ImageContent["detail"];
|
|
16
|
-
export
|
|
17
|
-
width: number;
|
|
18
|
-
height: number;
|
|
19
|
-
}
|
|
18
|
+
export type { ImageSize };
|
|
20
19
|
/**
|
|
21
20
|
* Estimated input tokens for one image. Unknown dimensions (undecodable data,
|
|
22
21
|
* remote URLs, provider file ids) charge the detail level's full patch budget.
|
|
@@ -520,8 +520,8 @@ export interface InstrumentedChatSpanOptions {
|
|
|
520
520
|
/**
|
|
521
521
|
* Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
|
|
522
522
|
* `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
|
|
523
|
-
* `handoff`, `branch_summary`, `image_question`. Free-form
|
|
524
|
-
* outside this package to add new kinds without bumping the helper.
|
|
523
|
+
* `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
|
|
524
|
+
* to allow callers outside this package to add new kinds without bumping the helper.
|
|
525
525
|
*/
|
|
526
526
|
readonly oneshotKind?: string;
|
|
527
527
|
/** Extra span attributes applied verbatim. */
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Model } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import * as natives from "@oh-my-pi/pi-natives";
|
|
3
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
3
4
|
import type { AgentMessage } from "./types.js";
|
|
4
5
|
/** Maps the catalog-resolved tokenizer family to its native implementation. */
|
|
5
6
|
export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): natives.Encoding | null;
|
|
@@ -42,14 +43,15 @@ export interface TokenBudgetCheck {
|
|
|
42
43
|
* Model-aware local token counter. Immutable: the catalog-resolved encoding
|
|
43
44
|
* is fixed at construction, so a cached count can never straddle two
|
|
44
45
|
* encodings. An `Agent` owns one for its active model (swapping the instance
|
|
45
|
-
* when the model's encoding changes); one-shot
|
|
46
|
-
* the model that will be billed. Known
|
|
47
|
-
* counts; unknown models keep the fast
|
|
48
|
-
* `PI_TOKENIZER_ACCURATE=1`).
|
|
46
|
+
* when the model's encoding or snapcompact frame pricing changes); one-shot
|
|
47
|
+
* flows construct their own for the model that will be billed. Known
|
|
48
|
+
* tokenizer families use exact native counts; unknown models keep the fast
|
|
49
|
+
* byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
|
|
49
50
|
*/
|
|
50
51
|
export declare class Tokenizer {
|
|
51
52
|
#private;
|
|
52
|
-
|
|
53
|
+
readonly frameBillingKey: string;
|
|
54
|
+
constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null);
|
|
53
55
|
get encoding(): natives.Encoding | null;
|
|
54
56
|
countTokens(text: string | string[], mode?: TokenCountMode): number;
|
|
55
57
|
/**
|
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.7.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.7.0",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.7.0",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.7.0",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.7.0",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.7.0",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.7.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.7.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.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
|
26
26
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
27
27
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
28
28
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
29
|
+
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
29
30
|
import {
|
|
30
31
|
abortReasonText,
|
|
31
32
|
agentLoop,
|
|
@@ -386,6 +387,13 @@ interface CursorToolResultEntry {
|
|
|
386
387
|
|
|
387
388
|
type QueuedMessageQueue = "steering" | "followUp";
|
|
388
389
|
|
|
390
|
+
interface LiveSteeredEntry {
|
|
391
|
+
message: AgentMessage;
|
|
392
|
+
controller: AbortController | undefined;
|
|
393
|
+
/** Context preparation committed for the batch, carried by its last adopted entry. Never requeued. */
|
|
394
|
+
additional?: readonly AgentMessage[];
|
|
395
|
+
}
|
|
396
|
+
|
|
389
397
|
interface QueuedMessageClaim {
|
|
390
398
|
messages: AgentMessage[];
|
|
391
399
|
controller: AbortController;
|
|
@@ -420,16 +428,20 @@ export class Agent {
|
|
|
420
428
|
* transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
|
|
421
429
|
* {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
|
|
422
430
|
* replacement must not drop it too; the run's end requeues whatever it did not record, and
|
|
423
|
-
* {@link
|
|
431
|
+
* {@link withdrawUndeliveredQueuedMessages} takes it back ahead of an abort.
|
|
424
432
|
*/
|
|
425
|
-
#liveSteered:
|
|
426
|
-
/** Dequeued originals remain recoverable until their transcript events arrive.
|
|
433
|
+
#liveSteered: LiveSteeredEntry[] = [];
|
|
434
|
+
/** Dequeued originals remain recoverable until their transcript events arrive. `additional` is
|
|
435
|
+
* the context preparation appended after them, which the transcript records with them. */
|
|
427
436
|
#queuedMessageDeliveries = new Set<{
|
|
428
437
|
queue: QueuedMessageQueue;
|
|
429
438
|
controller: AbortController | undefined;
|
|
430
439
|
messages: AgentMessage[];
|
|
431
440
|
next: number;
|
|
441
|
+
additional: readonly AgentMessage[];
|
|
432
442
|
}>();
|
|
443
|
+
/** Messages {@link withdrawUndeliveredQueuedMessages} took back, keyed to the run that must not record them. */
|
|
444
|
+
#withdrawnMessages = new WeakMap<AgentMessage, AbortController>();
|
|
433
445
|
#steeringWaiters = new Set<() => void>();
|
|
434
446
|
#queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
|
|
435
447
|
|
|
@@ -484,6 +496,7 @@ export class Agent {
|
|
|
484
496
|
#onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
|
|
485
497
|
#onBeforeYield?: () => Promise<void> | void;
|
|
486
498
|
#onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
|
|
499
|
+
#onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
|
|
487
500
|
#beforeModelCall?: AgentBeforeModelCall;
|
|
488
501
|
#additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
|
|
489
502
|
#asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
|
|
@@ -831,19 +844,22 @@ export class Agent {
|
|
|
831
844
|
|
|
832
845
|
/**
|
|
833
846
|
* Tokenizer for the active model. The instance is replaced whenever the
|
|
834
|
-
* active model's encoding
|
|
835
|
-
* not cache it across model switches.
|
|
847
|
+
* active model's encoding or snapcompact frame pricing changes (see
|
|
848
|
+
* {@link setModel}), so callers must not cache it across model switches.
|
|
836
849
|
*/
|
|
837
850
|
get tokenizer(): Tokenizer {
|
|
838
851
|
return this.#tokenizer;
|
|
839
852
|
}
|
|
840
853
|
|
|
841
854
|
/**
|
|
842
|
-
* Swap the tokenizer only when the encoding
|
|
843
|
-
* per-message memo survives same-
|
|
855
|
+
* Swap the tokenizer only when the encoding or frame pricing actually
|
|
856
|
+
* changes, so the warm per-message memo survives same-pricing model switches.
|
|
844
857
|
*/
|
|
845
858
|
#syncTokenizer(model: Model | null | undefined): void {
|
|
846
|
-
if (
|
|
859
|
+
if (
|
|
860
|
+
tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
|
|
861
|
+
snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
|
|
862
|
+
) {
|
|
847
863
|
this.#tokenizer = new Tokenizer(model);
|
|
848
864
|
}
|
|
849
865
|
}
|
|
@@ -967,7 +983,7 @@ export class Agent {
|
|
|
967
983
|
if (messages.length === 0) return messages;
|
|
968
984
|
const runController = this.#abortController;
|
|
969
985
|
if (!prepare) {
|
|
970
|
-
this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
|
|
986
|
+
this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
|
|
971
987
|
return messages;
|
|
972
988
|
}
|
|
973
989
|
|
|
@@ -986,7 +1002,13 @@ export class Agent {
|
|
|
986
1002
|
throw new DOMException("Queued message preparation cancelled", "AbortError");
|
|
987
1003
|
}
|
|
988
1004
|
delete this.#queuedMessageClaims[queue];
|
|
989
|
-
this.#queuedMessageDeliveries.add({
|
|
1005
|
+
this.#queuedMessageDeliveries.add({
|
|
1006
|
+
queue,
|
|
1007
|
+
controller: runController,
|
|
1008
|
+
messages,
|
|
1009
|
+
next: 0,
|
|
1010
|
+
additional: additional ?? [],
|
|
1011
|
+
});
|
|
990
1012
|
return additional?.length ? [...messages, ...additional] : messages;
|
|
991
1013
|
} catch (error) {
|
|
992
1014
|
if (signal.aborted) throw error;
|
|
@@ -1048,10 +1070,15 @@ export class Agent {
|
|
|
1048
1070
|
const pending = delivery.messages.slice(delivery.next);
|
|
1049
1071
|
const kept = pending.filter(message => !taken.includes(message));
|
|
1050
1072
|
if (kept.length === pending.length) continue;
|
|
1073
|
+
let last: LiveSteeredEntry | undefined;
|
|
1051
1074
|
for (const message of pending) {
|
|
1052
|
-
if (taken.includes(message))
|
|
1075
|
+
if (!taken.includes(message)) continue;
|
|
1076
|
+
last = { message, controller: delivery.controller };
|
|
1077
|
+
this.#liveSteered.push(last);
|
|
1053
1078
|
}
|
|
1054
1079
|
if (kept.length === 0) {
|
|
1080
|
+
// The delivery record goes away; keep its context withdrawable with the adopted batch.
|
|
1081
|
+
if (last && delivery.additional.length > 0) last.additional = delivery.additional;
|
|
1055
1082
|
this.#queuedMessageDeliveries.delete(delivery);
|
|
1056
1083
|
} else {
|
|
1057
1084
|
delivery.messages = kept;
|
|
@@ -1061,13 +1088,29 @@ export class Agent {
|
|
|
1061
1088
|
}
|
|
1062
1089
|
|
|
1063
1090
|
/**
|
|
1064
|
-
* Take back
|
|
1065
|
-
* the
|
|
1091
|
+
* Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
|
|
1092
|
+
* it to the editor): steering live steering took for the in-flight response, then each batch
|
|
1093
|
+
* dequeued for the next model call, oldest first. The aborted run then neither records it (or
|
|
1094
|
+
* the context prepared for it) nor requeues it.
|
|
1066
1095
|
*/
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1096
|
+
withdrawUndeliveredQueuedMessages(): { steering: AgentMessage[]; followUp: AgentMessage[] } {
|
|
1097
|
+
const withdrawn: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
|
|
1098
|
+
const suppress = (message: AgentMessage, controller: AbortController | undefined) => {
|
|
1099
|
+
if (controller) this.#withdrawnMessages.set(message, controller);
|
|
1100
|
+
};
|
|
1101
|
+
for (const { message, controller, additional } of this.#liveSteered) {
|
|
1102
|
+
withdrawn.steering.push(message);
|
|
1103
|
+
suppress(message, controller);
|
|
1104
|
+
for (const context of additional ?? []) suppress(context, controller);
|
|
1105
|
+
}
|
|
1069
1106
|
this.#liveSteered = [];
|
|
1070
|
-
|
|
1107
|
+
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1108
|
+
const pending = delivery.messages.slice(delivery.next);
|
|
1109
|
+
withdrawn[delivery.queue].push(...pending);
|
|
1110
|
+
for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
|
|
1111
|
+
}
|
|
1112
|
+
this.#queuedMessageDeliveries.clear();
|
|
1113
|
+
return withdrawn;
|
|
1071
1114
|
}
|
|
1072
1115
|
|
|
1073
1116
|
/** Steering live steering took for the streaming response; the transcript records it once
|
|
@@ -1101,6 +1144,11 @@ export class Agent {
|
|
|
1101
1144
|
this.#onTurnEnd = fn;
|
|
1102
1145
|
}
|
|
1103
1146
|
|
|
1147
|
+
/** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
|
|
1148
|
+
setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void {
|
|
1149
|
+
this.#onModelCallSystemPrompt = fn;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1104
1152
|
/**
|
|
1105
1153
|
* Install or replace the host pre-model-call gate; pass `undefined` to
|
|
1106
1154
|
* remove it. Gates are sampled when a run starts: installing the first
|
|
@@ -1800,6 +1848,7 @@ export class Agent {
|
|
|
1800
1848
|
await Bun.sleep(0);
|
|
1801
1849
|
}
|
|
1802
1850
|
context.systemPrompt = this.#state.systemPrompt;
|
|
1851
|
+
this.#onModelCallSystemPrompt?.(context.systemPrompt);
|
|
1803
1852
|
context.tools = this.#toolsForModel(this.#state.model ?? model);
|
|
1804
1853
|
},
|
|
1805
1854
|
beforeModelCall:
|
|
@@ -1879,8 +1928,19 @@ export class Agent {
|
|
|
1879
1928
|
? agentLoop(messages, context, config, loopSignal, this.streamFn)
|
|
1880
1929
|
: agentLoopContinue(context, config, loopSignal, this.streamFn);
|
|
1881
1930
|
|
|
1882
|
-
for await (
|
|
1931
|
+
for await (let event of stream) {
|
|
1883
1932
|
if (this.#abortController !== loopAbortController) return;
|
|
1933
|
+
// Withdrawn input the loop already holds for this run never reaches the transcript.
|
|
1934
|
+
if (
|
|
1935
|
+
(event.type === "message_start" || event.type === "message_end") &&
|
|
1936
|
+
this.#withdrawnMessages.get(event.message) === loopAbortController
|
|
1937
|
+
) {
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
if (event.type === "agent_end") {
|
|
1941
|
+
const messages = event.messages.filter(m => this.#withdrawnMessages.get(m) !== loopAbortController);
|
|
1942
|
+
if (messages.length < event.messages.length) event = { ...event, messages };
|
|
1943
|
+
}
|
|
1884
1944
|
if (event.type === "turn_start") turnOpen = true;
|
|
1885
1945
|
if (event.type === "turn_end") turnOpen = false;
|
|
1886
1946
|
// Update internal state based on events
|
|
@@ -2058,8 +2118,19 @@ export class Agent {
|
|
|
2058
2118
|
turnOpen = false;
|
|
2059
2119
|
this.#emit({ type: "agent_end", messages: agentEndMessages });
|
|
2060
2120
|
} else {
|
|
2121
|
+
// Context hooks can reject on abort before provider streaming starts.
|
|
2122
|
+
// Publish the boundary just like a streaming abort so subscribers can
|
|
2123
|
+
// persist it and recover the interrupted turn after a reload.
|
|
2124
|
+
if (!turnOpen) this.#emit({ type: "turn_start" });
|
|
2125
|
+
if (!hadAssistantStart) {
|
|
2126
|
+
this.#state.streamMessage = errorMsg;
|
|
2127
|
+
this.#emit({ type: "message_start", message: errorMsg });
|
|
2128
|
+
}
|
|
2129
|
+
this.#state.streamMessage = null;
|
|
2061
2130
|
this.appendMessage(errorMsg);
|
|
2062
2131
|
this.#state.error = errorMessage;
|
|
2132
|
+
this.#emit({ type: "message_end", message: errorMsg });
|
|
2133
|
+
this.#emit({ type: "turn_end", message: errorMsg, toolResults: [] });
|
|
2063
2134
|
this.#emit({ type: "agent_end", messages: [errorMsg] });
|
|
2064
2135
|
}
|
|
2065
2136
|
} finally {
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Anthropic on-demand compaction (`compact-2026-09-04` beta).
|
|
3
3
|
*
|
|
4
|
-
* The request sends only the prefix to summarize,
|
|
5
|
-
* system prompt, tools and thinking settings.
|
|
6
|
-
* replaces that prefix; the retained tail is
|
|
4
|
+
* The request sends only the prefix to summarize, as the live conversation
|
|
5
|
+
* sent it: same system prompt, tools, history bytes and thinking settings.
|
|
6
|
+
* The returned signed block replaces that prefix; the retained tail is
|
|
7
|
+
* replayed from session entries.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type {
|
|
11
|
+
AnthropicCompactionFiles,
|
|
10
12
|
AnthropicCompactionPayload,
|
|
11
13
|
ApiKey,
|
|
14
|
+
Context,
|
|
12
15
|
Effort,
|
|
13
|
-
Message,
|
|
14
16
|
Model,
|
|
15
17
|
SimpleStreamOptions,
|
|
16
|
-
Tool,
|
|
17
18
|
Usage,
|
|
18
19
|
} from "@oh-my-pi/pi-ai";
|
|
19
20
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
21
|
+
import { resolveAnthropicCompactionEffort } from "@oh-my-pi/pi-ai/providers/anthropic";
|
|
20
22
|
import { supportsAnthropicCompaction } from "@oh-my-pi/pi-ai/providers/anthropic-compaction";
|
|
21
23
|
import { isRecord, prompt } from "@oh-my-pi/pi-utils";
|
|
22
24
|
import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
|
|
@@ -33,8 +35,12 @@ export interface AnthropicCompactionPreserveData {
|
|
|
33
35
|
signature?: string;
|
|
34
36
|
/** Legacy threshold block state; replay-only. */
|
|
35
37
|
encryptedContent?: string;
|
|
36
|
-
/** Harness file metadata (`<files>` section) replayed after the
|
|
38
|
+
/** Harness file metadata (`<files>` section) replayed after the retained tail. */
|
|
37
39
|
filesText?: string;
|
|
40
|
+
/** Earlier summaries' file metadata replayed inside the retained tail. */
|
|
41
|
+
retainedFiles?: AnthropicCompactionFiles[];
|
|
42
|
+
/** The retained tail replays unchanged (see `AnthropicCompactionPayload.exactTail`). */
|
|
43
|
+
exactTail?: true;
|
|
38
44
|
/** Model that wrote the summary. */
|
|
39
45
|
model?: string;
|
|
40
46
|
/** Prompt tokens the compaction request processed, for display. */
|
|
@@ -76,6 +82,15 @@ export function getPreservedAnthropicCompactionData(
|
|
|
76
82
|
...(typeof candidate.filesText === "string" && candidate.filesText.length > 0
|
|
77
83
|
? { filesText: candidate.filesText }
|
|
78
84
|
: {}),
|
|
85
|
+
...(Array.isArray(candidate.retainedFiles) && candidate.retainedFiles.length > 0
|
|
86
|
+
? {
|
|
87
|
+
retainedFiles: candidate.retainedFiles.filter(
|
|
88
|
+
(files): files is AnthropicCompactionFiles =>
|
|
89
|
+
isRecord(files) && typeof files.text === "string" && typeof files.after === "number",
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
...(candidate.exactTail === true ? { exactTail: true as const } : {}),
|
|
79
94
|
...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
|
|
80
95
|
...(typeof candidate.usedTokens === "number" ? { usedTokens: candidate.usedTokens } : {}),
|
|
81
96
|
};
|
|
@@ -109,6 +124,8 @@ export function getAnthropicCompactionPayload(
|
|
|
109
124
|
...(preserved.signature ? { signature: preserved.signature } : {}),
|
|
110
125
|
...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
|
|
111
126
|
...(preserved.filesText ? { filesText: preserved.filesText } : {}),
|
|
127
|
+
...(preserved.retainedFiles?.length ? { retainedFiles: preserved.retainedFiles } : {}),
|
|
128
|
+
...(preserved.exactTail ? { exactTail: true as const } : {}),
|
|
112
129
|
};
|
|
113
130
|
}
|
|
114
131
|
|
|
@@ -167,10 +184,11 @@ export function buildAnthropicCompactionInstructions(
|
|
|
167
184
|
}
|
|
168
185
|
|
|
169
186
|
export interface AnthropicNativeCompactionRequest {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
tools?: Tool[];
|
|
187
|
+
/** The summarized prefix as the live turn would send it. */
|
|
188
|
+
context: Context;
|
|
173
189
|
instructions: string;
|
|
190
|
+
/** Replayed file metadata due before this time ends the request (see `AnthropicCompactionRequest.filesDueBefore`). */
|
|
191
|
+
filesDueBefore?: number;
|
|
174
192
|
maxTokens: number;
|
|
175
193
|
reasoning?: Effort;
|
|
176
194
|
}
|
|
@@ -214,12 +232,12 @@ export async function requestAnthropicNativeCompaction(
|
|
|
214
232
|
): Promise<AnthropicNativeCompactionResponse> {
|
|
215
233
|
const response = await instrumentedCompleteSimple(
|
|
216
234
|
model,
|
|
217
|
-
|
|
235
|
+
request.context,
|
|
218
236
|
{
|
|
219
237
|
apiKey,
|
|
220
238
|
signal,
|
|
221
239
|
maxTokens: request.maxTokens,
|
|
222
|
-
reasoning: request.reasoning,
|
|
240
|
+
reasoning: resolveAnthropicCompactionEffort(model, request.context.messages, request.reasoning),
|
|
223
241
|
initiatorOverride: options.initiatorOverride,
|
|
224
242
|
metadata: options.metadata,
|
|
225
243
|
fetch: options.fetch,
|
|
@@ -227,7 +245,7 @@ export async function requestAnthropicNativeCompaction(
|
|
|
227
245
|
promptCacheKey: options.promptCacheKey,
|
|
228
246
|
providerSessionState: options.providerSessionState,
|
|
229
247
|
maxInFlightRequests: options.maxInFlightRequests,
|
|
230
|
-
anthropicCompaction: { instructions: request.instructions },
|
|
248
|
+
anthropicCompaction: { instructions: request.instructions, filesDueBefore: request.filesDueBefore },
|
|
231
249
|
},
|
|
232
250
|
{
|
|
233
251
|
telemetry: options.telemetry,
|
|
@@ -77,6 +77,8 @@ export interface CompactionV2Usage {
|
|
|
77
77
|
export interface CompactionV2Request {
|
|
78
78
|
body: OpenAICodexCompactionBody;
|
|
79
79
|
input: unknown[];
|
|
80
|
+
/** Serialized user-written turns to keep next to the compaction item; defaults to `input`. */
|
|
81
|
+
retainedUserItems: unknown[];
|
|
80
82
|
retainedMessageBudget: number;
|
|
81
83
|
sessionId?: string;
|
|
82
84
|
promptCacheKey?: string;
|
|
@@ -190,6 +192,7 @@ export function buildCompactionV2Request(
|
|
|
190
192
|
sessionId?: string;
|
|
191
193
|
promptCacheKey?: string;
|
|
192
194
|
retainedMessageBudget?: number;
|
|
195
|
+
retainedUserItems?: unknown[];
|
|
193
196
|
},
|
|
194
197
|
): CompactionV2Request {
|
|
195
198
|
const cacheOptions = { sessionId: options?.sessionId, promptCacheKey: options?.promptCacheKey };
|
|
@@ -223,12 +226,14 @@ export function buildCompactionV2RequestFromBody(
|
|
|
223
226
|
sessionId?: string;
|
|
224
227
|
promptCacheKey?: string;
|
|
225
228
|
retainedMessageBudget?: number;
|
|
229
|
+
retainedUserItems?: unknown[];
|
|
226
230
|
},
|
|
227
231
|
): CompactionV2Request {
|
|
228
232
|
const input = Array.isArray(body.input) ? body.input : [];
|
|
229
233
|
return {
|
|
230
234
|
body: { ...body, model: resolveCompactionV2Model(model), input },
|
|
231
235
|
input,
|
|
236
|
+
retainedUserItems: options?.retainedUserItems ?? input,
|
|
232
237
|
retainedMessageBudget: resolveCompactionV2RetainedMessageBudget(options?.retainedMessageBudget),
|
|
233
238
|
sessionId: options?.sessionId,
|
|
234
239
|
promptCacheKey: options?.promptCacheKey,
|
|
@@ -536,7 +541,7 @@ function finishCompactionV2Collection(
|
|
|
536
541
|
|
|
537
542
|
const compactionItem = state.compactionItems[0];
|
|
538
543
|
const { replacementHistory, retainedImageCount } = buildCompactionV2ReplacementHistory(
|
|
539
|
-
request.
|
|
544
|
+
request.retainedUserItems,
|
|
540
545
|
compactionItem,
|
|
541
546
|
request.retainedMessageBudget,
|
|
542
547
|
);
|
|
@@ -665,15 +670,14 @@ function isRetryableCompactionError(error: Error): boolean {
|
|
|
665
670
|
// Replacement History
|
|
666
671
|
// ============================================================================
|
|
667
672
|
|
|
668
|
-
/** Build Codex-style V2 replacement history from
|
|
673
|
+
/** Build Codex-style V2 replacement history from retention candidates plus compaction output. */
|
|
669
674
|
export function buildCompactionV2ReplacementHistory(
|
|
670
675
|
input: unknown[],
|
|
671
676
|
compactionItem: Record<string, unknown>,
|
|
672
677
|
retainedMessageBudget = V2_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
673
678
|
): { replacementHistory: Array<Record<string, unknown>>; retainedImageCount: number } {
|
|
674
679
|
const retained = input.filter(
|
|
675
|
-
(item): item is Record<string, unknown> =>
|
|
676
|
-
isRecord(item) && isRetainedForCompactionV2(item) && shouldKeepCompactionV2HistoryItem(item),
|
|
680
|
+
(item): item is Record<string, unknown> => isRecord(item) && isRetainedUserMessageForCompactionV2(item),
|
|
677
681
|
);
|
|
678
682
|
const replacementHistory = truncateRetainedMessagesForCompactionV2(
|
|
679
683
|
retained,
|
|
@@ -684,17 +688,11 @@ export function buildCompactionV2ReplacementHistory(
|
|
|
684
688
|
return { replacementHistory, retainedImageCount };
|
|
685
689
|
}
|
|
686
690
|
|
|
687
|
-
function
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
function shouldKeepCompactionV2HistoryItem(item: Record<string, unknown>): boolean {
|
|
694
|
-
if (item.type !== "message") return item.type === "compaction";
|
|
695
|
-
const role = stringField(item, "role");
|
|
696
|
-
if (role !== "user") return false;
|
|
697
|
-
return !isContextualUserMessage(item);
|
|
691
|
+
function isRetainedUserMessageForCompactionV2(item: Record<string, unknown>): boolean {
|
|
692
|
+
// Responses input messages may omit `type`: omp serializes turns as
|
|
693
|
+
// `{ role, content }`, which the API reads as `type: "message"`.
|
|
694
|
+
const isMessage = item.type === "message" || (item.type === undefined && typeof item.role === "string");
|
|
695
|
+
return isMessage && item.role === "user" && !isContextualUserMessage(item);
|
|
698
696
|
}
|
|
699
697
|
|
|
700
698
|
function isContextualUserMessage(item: Record<string, unknown>): boolean {
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
+
type AnthropicCompactionFiles,
|
|
9
10
|
type Api,
|
|
10
11
|
type ApiKey,
|
|
11
12
|
type AssistantMessage,
|
|
@@ -676,6 +677,24 @@ export interface SummaryOptions {
|
|
|
676
677
|
initiatorOverride?: MessageAttribution;
|
|
677
678
|
metadata?: Record<string, unknown>;
|
|
678
679
|
convertToLlm?: ConvertToLlm;
|
|
680
|
+
/**
|
|
681
|
+
* The provider context a live turn sends for `summarized` + `retained`, cut to
|
|
682
|
+
* `summarized`. Anthropic on-demand compaction sends it so retained signed
|
|
683
|
+
* thinking keeps its prefix; without it the request uses `remoteSystemPrompt`,
|
|
684
|
+
* `tools` and `convertToLlm`.
|
|
685
|
+
*/
|
|
686
|
+
buildProviderContext?: (
|
|
687
|
+
summarized: AgentMessage[],
|
|
688
|
+
retained: AgentMessage[],
|
|
689
|
+
signal?: AbortSignal,
|
|
690
|
+
) => Promise<Context>;
|
|
691
|
+
/**
|
|
692
|
+
* Whether a message is a turn the user wrote. Remote Compaction V2 keeps these
|
|
693
|
+
* next to the compaction item. Defaults to `role === "user"`; hosts whose
|
|
694
|
+
* user-initiated turns also arrive as custom messages (e.g. skill invocations)
|
|
695
|
+
* widen it.
|
|
696
|
+
*/
|
|
697
|
+
isUserAuthored?: (message: AgentMessage) => boolean;
|
|
679
698
|
/**
|
|
680
699
|
* Optional telemetry handle. When provided, every LLM call emitted during
|
|
681
700
|
* compaction is wrapped in an OTEL chat span tagged with
|
|
@@ -1612,6 +1631,8 @@ export async function compact(
|
|
|
1612
1631
|
initiatorOverride: options?.initiatorOverride,
|
|
1613
1632
|
metadata: options?.metadata,
|
|
1614
1633
|
convertToLlm: options?.convertToLlm,
|
|
1634
|
+
buildProviderContext: options?.buildProviderContext,
|
|
1635
|
+
isUserAuthored: options?.isUserAuthored,
|
|
1615
1636
|
telemetry: options?.telemetry,
|
|
1616
1637
|
// Honor /model thinking selection on every fan-out summarizer.
|
|
1617
1638
|
// Without this propagation, generateSummary / generateTurnPrefixSummary
|
|
@@ -1675,7 +1696,17 @@ export async function compact(
|
|
|
1675
1696
|
previousRemoteCompaction?.provider === model.provider
|
|
1676
1697
|
? previousRemoteCompaction.replacementHistory
|
|
1677
1698
|
: undefined;
|
|
1678
|
-
const
|
|
1699
|
+
const convertToLlm = summaryOptions.convertToLlm ?? defaultConvertToLlm;
|
|
1700
|
+
const messages = convertToLlm(remoteMessages);
|
|
1701
|
+
// Replacement history keeps what the user wrote. Summaries, archive
|
|
1702
|
+
// migrations, and custom/hook messages can serialize as user-role items
|
|
1703
|
+
// too, so pick the user's own messages before serialization erases that,
|
|
1704
|
+
// then serialize each one exactly as the request does.
|
|
1705
|
+
const isUserAuthored = summaryOptions.isUserAuthored ?? ((message: AgentMessage) => message.role === "user");
|
|
1706
|
+
const userMessages = convertToLlm(
|
|
1707
|
+
[...messagesToSummarize, ...turnPrefixMessages, ...recentMessages].filter(isUserAuthored),
|
|
1708
|
+
);
|
|
1709
|
+
const retainedUserItems: unknown[] = [...(previousReplacementHistory ?? [])];
|
|
1679
1710
|
const remoteSystemPrompt = summaryOptions.remoteSystemPrompt ?? [SUMMARIZATION_SYSTEM_PROMPT];
|
|
1680
1711
|
let codexBody: OpenAICodexCompactionBody | undefined;
|
|
1681
1712
|
let remoteHistory: Array<Record<string, unknown>>;
|
|
@@ -1715,8 +1746,19 @@ export async function compact(
|
|
|
1715
1746
|
}
|
|
1716
1747
|
remoteHistory = stripOpenAIResponsesOutputOnlyStatusesForReplay(nativeInput);
|
|
1717
1748
|
codexBody.input = remoteHistory;
|
|
1749
|
+
for (const message of userMessages) {
|
|
1750
|
+
const userBody = await buildTransformedCodexRequestBody(
|
|
1751
|
+
model,
|
|
1752
|
+
{ messages: [message] },
|
|
1753
|
+
{ responsesLite: model.useResponsesLite },
|
|
1754
|
+
);
|
|
1755
|
+
retainedUserItems.push(...(userBody.input ?? []));
|
|
1756
|
+
}
|
|
1718
1757
|
} else {
|
|
1719
1758
|
remoteHistory = buildOpenAiResponsesCompactionInput(messages, model, previousReplacementHistory);
|
|
1759
|
+
for (const message of userMessages) {
|
|
1760
|
+
retainedUserItems.push(...buildOpenAiResponsesCompactionInput([message], model, undefined));
|
|
1761
|
+
}
|
|
1720
1762
|
}
|
|
1721
1763
|
if (remoteHistory.length > 0) {
|
|
1722
1764
|
try {
|
|
@@ -1754,6 +1796,7 @@ export async function compact(
|
|
|
1754
1796
|
sessionId: summaryOptions.sessionId,
|
|
1755
1797
|
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1756
1798
|
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1799
|
+
retainedUserItems,
|
|
1757
1800
|
};
|
|
1758
1801
|
const request = codexBody
|
|
1759
1802
|
? buildCompactionV2RequestFromBody(model, { ...codexBody, input: trimmed.input }, requestOptions)
|
|
@@ -1781,11 +1824,17 @@ export async function compact(
|
|
|
1781
1824
|
// summarization" and keep compaction running on an aborted signal.
|
|
1782
1825
|
if (signal?.aborted) throw err;
|
|
1783
1826
|
nativeCompactionError = selectNativeCompactionError(nativeCompactionError, err);
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
model
|
|
1787
|
-
|
|
1788
|
-
|
|
1827
|
+
// Claim the V1 fallback only when the V1 block below will run.
|
|
1828
|
+
logger.warn(
|
|
1829
|
+
shouldUseOpenAiRemoteCompaction(model)
|
|
1830
|
+
? "OpenAI V2 remote compaction failed, falling back to V1 remote compaction"
|
|
1831
|
+
: "OpenAI V2 remote compaction failed",
|
|
1832
|
+
{
|
|
1833
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1834
|
+
model: model.id,
|
|
1835
|
+
provider: model.provider,
|
|
1836
|
+
},
|
|
1837
|
+
);
|
|
1789
1838
|
}
|
|
1790
1839
|
}
|
|
1791
1840
|
}
|
|
@@ -1848,32 +1897,40 @@ export async function compact(
|
|
|
1848
1897
|
let nativeSignature: string | undefined;
|
|
1849
1898
|
let nativeFirstKeptEntryId = firstKeptEntryId;
|
|
1850
1899
|
let nativeUsedTokens: number | undefined;
|
|
1900
|
+
let nativeRetainedFiles: AnthropicCompactionFiles[] | undefined;
|
|
1851
1901
|
if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseAnthropicNativeCompaction(model)) {
|
|
1852
1902
|
const previousNative = getPreservedAnthropicCompactionData(previousPreserveData);
|
|
1853
1903
|
// Lead with the previous summary as the live context renders it:
|
|
1854
1904
|
// natively when this provider wrote it, as text otherwise.
|
|
1855
1905
|
// A prior snapcompact archive is already merged into that summary
|
|
1856
|
-
// text.
|
|
1906
|
+
// text. Like the live context, the summary keeps its commit time and
|
|
1907
|
+
// predates its rewrite marker before all replayed messages, so
|
|
1857
1908
|
// retained thinking stays bound to the original prefix.
|
|
1858
1909
|
const firstReplayed = messagesToSummarize[0] ?? turnPrefixMessages[0] ?? recentMessages[0];
|
|
1859
|
-
const previousSummaryAt =
|
|
1860
|
-
firstReplayed !== undefined ? new Date(firstReplayed.timestamp - 1).toISOString() : new Date().toISOString();
|
|
1861
1910
|
const previousSummaryMessage = previousSummaryForCompaction
|
|
1862
|
-
? createCompactionSummaryMessage(
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
:
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1911
|
+
? createCompactionSummaryMessage(
|
|
1912
|
+
previousSummaryForCompaction,
|
|
1913
|
+
tokensBefore,
|
|
1914
|
+
preparation.previousSummaryTimestamp ?? new Date().toISOString(),
|
|
1915
|
+
{
|
|
1916
|
+
historyRewriteAt: firstReplayed !== undefined ? firstReplayed.timestamp - 1 : undefined,
|
|
1917
|
+
providerPayload:
|
|
1918
|
+
previousNative?.provider === model.provider
|
|
1919
|
+
? {
|
|
1920
|
+
type: "anthropicCompaction",
|
|
1921
|
+
provider: previousNative.provider,
|
|
1922
|
+
content: previousNative.content,
|
|
1923
|
+
...(previousNative.signature ? { signature: previousNative.signature } : {}),
|
|
1924
|
+
...(previousNative.encryptedContent
|
|
1925
|
+
? { encryptedContent: previousNative.encryptedContent }
|
|
1926
|
+
: {}),
|
|
1927
|
+
...(previousNative.filesText ? { filesText: previousNative.filesText } : {}),
|
|
1928
|
+
...(previousNative.retainedFiles ? { retainedFiles: previousNative.retainedFiles } : {}),
|
|
1929
|
+
...(previousNative.exactTail ? { exactTail: true as const } : {}),
|
|
1930
|
+
}
|
|
1931
|
+
: undefined,
|
|
1932
|
+
},
|
|
1933
|
+
)
|
|
1877
1934
|
: undefined;
|
|
1878
1935
|
const allMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages];
|
|
1879
1936
|
const originalCut = messagesToSummarize.length + turnPrefixMessages.length;
|
|
@@ -1891,20 +1948,57 @@ export async function compact(
|
|
|
1891
1948
|
? firstKeptEntryId
|
|
1892
1949
|
: (recentEntryIds?.[safeCut - originalCut] ?? "");
|
|
1893
1950
|
for (let i = originalCut; i < safeCut; i++) extractFileOpsFromMessage(allMessages[i], fileOps);
|
|
1894
|
-
const
|
|
1951
|
+
const summarizedMessages = [
|
|
1895
1952
|
...(previousSummaryMessage ? [previousSummaryMessage] : []),
|
|
1896
1953
|
...allMessages.slice(0, safeCut),
|
|
1897
|
-
]
|
|
1954
|
+
];
|
|
1955
|
+
// Earlier file metadata replays before the first message created after
|
|
1956
|
+
// its summary. Metadata due inside the new retained tail stays out of the
|
|
1957
|
+
// request, and the new summary keeps replaying it. So does metadata due
|
|
1958
|
+
// right at the cut when the first kept message is a user-side turn: live
|
|
1959
|
+
// requests sent it merged into that turn, and ending the request with it
|
|
1960
|
+
// would merge the summarized range into the kept turn instead, which
|
|
1961
|
+
// invalidates the kept thinking.
|
|
1962
|
+
const firstRetained = allMessages[safeCut];
|
|
1963
|
+
const filesDueBefore =
|
|
1964
|
+
firstRetained === undefined
|
|
1965
|
+
? undefined
|
|
1966
|
+
: firstRetained.role === "assistant"
|
|
1967
|
+
? firstRetained.timestamp
|
|
1968
|
+
: (allMessages.slice(0, safeCut).findLast(message => message.role !== "toolResult")?.timestamp ??
|
|
1969
|
+
Number.NEGATIVE_INFINITY);
|
|
1970
|
+
const previousPayload = previousSummaryMessage?.providerPayload;
|
|
1971
|
+
if (
|
|
1972
|
+
filesDueBefore !== undefined &&
|
|
1973
|
+
previousSummaryMessage !== undefined &&
|
|
1974
|
+
previousPayload?.type === "anthropicCompaction" &&
|
|
1975
|
+
previousPayload.exactTail
|
|
1976
|
+
) {
|
|
1977
|
+
const carried = [
|
|
1978
|
+
...(previousPayload.retainedFiles ?? []),
|
|
1979
|
+
...(previousPayload.filesText
|
|
1980
|
+
? [{ text: previousPayload.filesText, after: previousSummaryMessage.timestamp }]
|
|
1981
|
+
: []),
|
|
1982
|
+
].filter(files => files.after >= filesDueBefore);
|
|
1983
|
+
if (carried.length > 0) nativeRetainedFiles = carried;
|
|
1984
|
+
}
|
|
1898
1985
|
try {
|
|
1986
|
+
// The live turn's own system prompt, wire tools and transformed
|
|
1987
|
+
// history when the host can build them: kept thinking remains valid
|
|
1988
|
+
// only when the summarized prefix matches what was sent byte for byte.
|
|
1989
|
+
const context = summaryOptions.buildProviderContext
|
|
1990
|
+
? await summaryOptions.buildProviderContext(summarizedMessages, allMessages.slice(safeCut), signal)
|
|
1991
|
+
: {
|
|
1992
|
+
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1993
|
+
messages: (summaryOptions.convertToLlm ?? defaultConvertToLlm)(summarizedMessages),
|
|
1994
|
+
tools: summaryOptions.tools,
|
|
1995
|
+
};
|
|
1899
1996
|
const remote = await requestAnthropicNativeCompaction(
|
|
1900
1997
|
model,
|
|
1901
1998
|
apiKey,
|
|
1902
1999
|
{
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
systemPrompt: summaryOptions.remoteSystemPrompt ?? [],
|
|
1906
|
-
messages,
|
|
1907
|
-
tools: summaryOptions.tools,
|
|
2000
|
+
context,
|
|
2001
|
+
filesDueBefore,
|
|
1908
2002
|
instructions: buildAnthropicCompactionInstructions(
|
|
1909
2003
|
summaryOptions.promptOverride ?? SUMMARIZATION_PROMPT,
|
|
1910
2004
|
customInstructions,
|
|
@@ -2026,6 +2120,8 @@ export async function compact(
|
|
|
2026
2120
|
content: nativeSummary,
|
|
2027
2121
|
...(nativeSignature ? { signature: nativeSignature } : {}),
|
|
2028
2122
|
...(filesText ? { filesText } : {}),
|
|
2123
|
+
...(nativeRetainedFiles ? { retainedFiles: nativeRetainedFiles } : {}),
|
|
2124
|
+
exactTail: true,
|
|
2029
2125
|
model: model.id,
|
|
2030
2126
|
usedTokens: nativeUsedTokens,
|
|
2031
2127
|
});
|
|
@@ -101,8 +101,8 @@ export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
|
101
101
|
|
|
102
102
|
export interface SessionInitEntry extends SessionEntryBase {
|
|
103
103
|
type: "session_init";
|
|
104
|
-
/**
|
|
105
|
-
systemPrompt: string;
|
|
104
|
+
/** System prompt blocks exactly as sent to the model; files written before blocks were kept store one joined string. */
|
|
105
|
+
systemPrompt: string[] | string;
|
|
106
106
|
/** Initial task/user message */
|
|
107
107
|
task: string;
|
|
108
108
|
/** Tools available to the agent */
|
package/src/compaction/openai.ts
CHANGED
|
@@ -90,12 +90,12 @@ interface NormalizedEstimateValue {
|
|
|
90
90
|
imageTokens: number;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
|
|
93
|
+
function normalizeRemoteCompactionEstimateValue(value: unknown, dropEncrypted = true): NormalizedEstimateValue {
|
|
94
94
|
if (Array.isArray(value)) {
|
|
95
95
|
const normalized: unknown[] = [];
|
|
96
96
|
let imageTokens = 0;
|
|
97
97
|
for (const item of value) {
|
|
98
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
98
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
99
99
|
normalized.push(result.value);
|
|
100
100
|
imageTokens += result.imageTokens;
|
|
101
101
|
}
|
|
@@ -123,14 +123,25 @@ function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstim
|
|
|
123
123
|
// Opaque encrypted reasoning/compaction state: its local base64 size far
|
|
124
124
|
// exceeds what the provider bills, so it stays out of the fit estimate
|
|
125
125
|
// (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
|
|
126
|
-
if (key === "encrypted_content" && typeof item === "string") continue;
|
|
127
|
-
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
126
|
+
if (dropEncrypted && key === "encrypted_content" && typeof item === "string") continue;
|
|
127
|
+
const result = normalizeRemoteCompactionEstimateValue(item, dropEncrypted);
|
|
128
128
|
normalized[key] = result.value;
|
|
129
129
|
imageTokens += result.imageTokens;
|
|
130
130
|
}
|
|
131
131
|
return { value: normalized, imageTokens };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Tokens of stored Responses replacement history. Retained `input_image` parts
|
|
136
|
+
* count at their image estimate rather than as base64 text; everything else,
|
|
137
|
+
* opaque compaction state included, counts as serialized.
|
|
138
|
+
*/
|
|
139
|
+
export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
|
|
140
|
+
const normalized = normalizeRemoteCompactionEstimateValue(items, false);
|
|
141
|
+
const serialized = stringifyJson(normalized.value);
|
|
142
|
+
return (serialized === undefined ? 0 : tokenizer.countTokens(serialized)) + normalized.imageTokens;
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
export interface TrimRemoteCompactionInputResult {
|
|
135
146
|
input: Array<Record<string, unknown>>;
|
|
136
147
|
rewrittenOutputs: number;
|
package/src/image-tokens.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import {
|
|
3
|
+
type ImageSize,
|
|
4
|
+
type ImageTokenization,
|
|
5
|
+
imageTokens,
|
|
6
|
+
resolveImageTokenization,
|
|
7
|
+
} from "@oh-my-pi/pi-catalog/compat/image-tokenization";
|
|
2
8
|
import { parseImageMetadata } from "@oh-my-pi/pi-utils";
|
|
3
9
|
|
|
4
10
|
/**
|
|
@@ -9,63 +15,30 @@ import { parseImageMetadata } from "@oh-my-pi/pi-utils";
|
|
|
9
15
|
* stayed under the local compaction trigger yet was refused as over-window by
|
|
10
16
|
* the remote probe.
|
|
11
17
|
*
|
|
12
|
-
* Follows
|
|
13
|
-
* and patch budget, x1.2 multiplier),
|
|
14
|
-
*
|
|
18
|
+
* Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
|
|
19
|
+
* 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
|
|
20
|
+
* which is also a close upper estimate for other providers once omp has
|
|
21
|
+
* downscaled the image (≤1568px by default).
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
|
|
18
25
|
export type ImageDetail = ImageContent["detail"];
|
|
19
26
|
|
|
20
|
-
export
|
|
21
|
-
width: number;
|
|
22
|
-
height: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const PATCH_PX = 32;
|
|
26
|
-
const PATCH_TOKEN_MULTIPLIER = 1.2;
|
|
27
|
-
|
|
28
|
-
interface DetailSizing {
|
|
29
|
-
/** Longest side after the pixel-dimension fit. */
|
|
30
|
-
maxDimension: number;
|
|
31
|
-
/** Resizing patch budget, when the detail level defines one. */
|
|
32
|
-
patchBudget?: number;
|
|
33
|
-
/** Patches charged when the image size is unknown. */
|
|
34
|
-
unknownPatches: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const LOW_SIZING: DetailSizing = { maxDimension: 512, unknownPatches: 16 * 16 };
|
|
38
|
-
const HIGH_SIZING: DetailSizing = { maxDimension: 2048, patchBudget: 2_500, unknownPatches: 2_500 };
|
|
39
|
-
// `auto` resolves to `original` sizing on current models, so it shares the
|
|
40
|
-
// larger budget rather than risk undercounting.
|
|
41
|
-
const ORIGINAL_SIZING: DetailSizing = { maxDimension: 6_000, patchBudget: 10_000, unknownPatches: 10_000 };
|
|
27
|
+
export type { ImageSize };
|
|
42
28
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
return ORIGINAL_SIZING;
|
|
47
|
-
}
|
|
29
|
+
// Larger than every detail level's pixel limit, so an unknown size charges
|
|
30
|
+
// the level's full patch budget.
|
|
31
|
+
const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
|
|
48
32
|
|
|
49
|
-
|
|
50
|
-
let { width, height } = size;
|
|
51
|
-
const longest = Math.max(width, height);
|
|
52
|
-
if (longest > sizing.maxDimension) {
|
|
53
|
-
const scale = sizing.maxDimension / longest;
|
|
54
|
-
width = Math.max(1, Math.round(width * scale));
|
|
55
|
-
height = Math.max(1, Math.round(height * scale));
|
|
56
|
-
}
|
|
57
|
-
const patches = Math.ceil(width / PATCH_PX) * Math.ceil(height / PATCH_PX);
|
|
58
|
-
const budget = sizing.patchBudget;
|
|
59
|
-
if (budget === undefined || patches <= budget) return patches;
|
|
33
|
+
let wireRule: ImageTokenization | undefined;
|
|
60
34
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return Math.min(budget, Math.ceil(resizedW / PATCH_PX) * Math.ceil(resizedH / PATCH_PX));
|
|
35
|
+
/** The OpenAI Responses wire's image rule, resolved from the catalog once. */
|
|
36
|
+
function openAiWireRule(): ImageTokenization {
|
|
37
|
+
if (wireRule) return wireRule;
|
|
38
|
+
const rule = resolveImageTokenization({ api: "openai-responses" });
|
|
39
|
+
if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
|
|
40
|
+
wireRule = rule;
|
|
41
|
+
return rule;
|
|
69
42
|
}
|
|
70
43
|
|
|
71
44
|
/**
|
|
@@ -73,9 +46,7 @@ function countPatches(size: ImageSize, sizing: DetailSizing): number {
|
|
|
73
46
|
* remote URLs, provider file ids) charge the detail level's full patch budget.
|
|
74
47
|
*/
|
|
75
48
|
export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
|
|
76
|
-
|
|
77
|
-
const patches = size && size.width > 0 && size.height > 0 ? countPatches(size, sizing) : sizing.unknownPatches;
|
|
78
|
-
return Math.ceil(patches * PATCH_TOKEN_MULTIPLIER);
|
|
49
|
+
return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
|
|
79
50
|
}
|
|
80
51
|
|
|
81
52
|
// Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
|
package/src/telemetry.ts
CHANGED
|
@@ -1790,8 +1790,8 @@ export interface InstrumentedChatSpanOptions {
|
|
|
1790
1790
|
/**
|
|
1791
1791
|
* Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
|
|
1792
1792
|
* `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
|
|
1793
|
-
* `handoff`, `branch_summary`, `image_question`. Free-form
|
|
1794
|
-
* outside this package to add new kinds without bumping the helper.
|
|
1793
|
+
* `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
|
|
1794
|
+
* to allow callers outside this package to add new kinds without bumping the helper.
|
|
1795
1795
|
*/
|
|
1796
1796
|
readonly oneshotKind?: string;
|
|
1797
1797
|
/** Extra span attributes applied verbatim. */
|
package/src/tokenizer.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { materializeString, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
|
5
5
|
import { LRUCache } from "@oh-my-pi/pi-utils/lru";
|
|
6
6
|
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
7
7
|
import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
|
|
8
|
-
import { estimateImageContentTokens } from "./image-tokens";
|
|
8
|
+
import { base64ImageSize, estimateImageContentTokens } from "./image-tokens";
|
|
9
9
|
import type { AgentMessage } from "./types";
|
|
10
10
|
|
|
11
11
|
const testEnv = Bun.env.NODE_ENV === "test";
|
|
@@ -149,13 +149,15 @@ interface MessageEstimate {
|
|
|
149
149
|
* Model-aware local token counter. Immutable: the catalog-resolved encoding
|
|
150
150
|
* is fixed at construction, so a cached count can never straddle two
|
|
151
151
|
* encodings. An `Agent` owns one for its active model (swapping the instance
|
|
152
|
-
* when the model's encoding changes); one-shot
|
|
153
|
-
* the model that will be billed. Known
|
|
154
|
-
* counts; unknown models keep the fast
|
|
155
|
-
* `PI_TOKENIZER_ACCURATE=1`).
|
|
152
|
+
* when the model's encoding or snapcompact frame pricing changes); one-shot
|
|
153
|
+
* flows construct their own for the model that will be billed. Known
|
|
154
|
+
* tokenizer families use exact native counts; unknown models keep the fast
|
|
155
|
+
* byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
|
|
156
156
|
*/
|
|
157
157
|
export class Tokenizer {
|
|
158
158
|
readonly #encoding: natives.Encoding | null;
|
|
159
|
+
readonly #frameBilling: snapcompact.FrameBilling;
|
|
160
|
+
readonly frameBillingKey: string;
|
|
159
161
|
|
|
160
162
|
/** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
|
|
161
163
|
readonly #nativeCounts = new LRUCache<string, number>({
|
|
@@ -174,8 +176,10 @@ export class Tokenizer {
|
|
|
174
176
|
*/
|
|
175
177
|
#estimates = new WeakMap<AgentMessage, MessageEstimate>();
|
|
176
178
|
|
|
177
|
-
constructor(model?: Pick<Model, "tokenizer"> | null) {
|
|
179
|
+
constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
|
|
178
180
|
this.#encoding = tokenizerEncodingForModel(model);
|
|
181
|
+
this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
|
|
182
|
+
this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
get encoding(): natives.Encoding | null {
|
|
@@ -332,11 +336,12 @@ export class Tokenizer {
|
|
|
332
336
|
if (message.blocks) {
|
|
333
337
|
for (const block of message.blocks) {
|
|
334
338
|
if (block.type === "text") fragments.push(block.text);
|
|
335
|
-
else extra +=
|
|
339
|
+
else extra += this.#frameTokens(block.data);
|
|
336
340
|
}
|
|
337
341
|
} else if (message.images) {
|
|
338
|
-
|
|
339
|
-
|
|
342
|
+
for (const image of message.images) {
|
|
343
|
+
extra += this.#frameTokens(image.data);
|
|
344
|
+
}
|
|
340
345
|
}
|
|
341
346
|
}
|
|
342
347
|
break;
|
|
@@ -348,4 +353,10 @@ export class Tokenizer {
|
|
|
348
353
|
if (fragments.length === 0) return extra;
|
|
349
354
|
return extra + this.countTokens(fragments);
|
|
350
355
|
}
|
|
356
|
+
|
|
357
|
+
/** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
|
|
358
|
+
#frameTokens(data: string): number {
|
|
359
|
+
const size = base64ImageSize(data);
|
|
360
|
+
return size ? snapcompact.frameTokens(this.#frameBilling, size) : snapcompact.FRAME_TOKEN_ESTIMATE;
|
|
361
|
+
}
|
|
351
362
|
}
|