@oh-my-pi/pi-agent-core 18.6.0 → 18.6.3
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 +14 -2
- package/dist/types/image-tokens.d.ts +29 -0
- package/dist/types/tokenizer.d.ts +9 -6
- package/package.json +8 -8
- package/src/agent.ts +77 -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 +63 -28
- package/src/image-tokens.ts +77 -0
- package/src/tokenizer.ts +24 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.6.3] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- `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))
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- 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))
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `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))
|
|
18
|
+
- 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)).
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- 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))
|
|
23
|
+
- 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)).
|
|
24
|
+
- 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))
|
|
25
|
+
- 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))
|
|
26
|
+
- 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))
|
|
27
|
+
|
|
28
|
+
## [18.6.1] - 2026-10-04
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- Fixed native OpenAI context compaction for sessions containing many screenshots, preventing image-size estimates from incorrectly causing compaction requests to be rejected.
|
|
33
|
+
|
|
5
34
|
## [18.6.0] - 2026-10-03
|
|
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,19 +28,31 @@ 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;
|
|
34
40
|
estimatedTokensBefore: number;
|
|
35
41
|
estimatedTokensAfter: number;
|
|
36
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Whether `input` may be sent; false means it must not be. True when the
|
|
44
|
+
* full estimate fits, or when only the image estimate pushes it over (image
|
|
45
|
+
* pricing is a guess; the provider's own accounting decides).
|
|
46
|
+
*/
|
|
37
47
|
fits: boolean;
|
|
38
48
|
}
|
|
39
49
|
/**
|
|
40
50
|
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
41
51
|
* remote compaction request beyond the model window. Replacing only those
|
|
42
52
|
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
43
|
-
* matching Codex's recovery path for oversized tool turns.
|
|
53
|
+
* matching Codex's recovery path for oversized tool turns. A request whose text
|
|
54
|
+
* fits but whose image estimate does not is still sendable: refusing it on an
|
|
55
|
+
* image guess would skip native compaction the provider would have accepted.
|
|
44
56
|
*/
|
|
45
57
|
export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, tokenizer: Tokenizer, contextWindow: number | null | undefined, instructions: string, tools?: unknown[]): TrimRemoteCompactionInputResult;
|
|
46
58
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { type ImageSize } from "@oh-my-pi/pi-catalog/compat/image-tokenization";
|
|
3
|
+
/**
|
|
4
|
+
* Dimension-based image token estimates shared by the local context counter
|
|
5
|
+
* ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
|
|
6
|
+
* Both sides must price the same image the same way: when they disagreed
|
|
7
|
+
* (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
|
|
8
|
+
* stayed under the local compaction trigger yet was refused as over-window by
|
|
9
|
+
* the remote probe.
|
|
10
|
+
*
|
|
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).
|
|
15
|
+
*/
|
|
16
|
+
/** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
|
|
17
|
+
export type ImageDetail = ImageContent["detail"];
|
|
18
|
+
export type { ImageSize };
|
|
19
|
+
/**
|
|
20
|
+
* Estimated input tokens for one image. Unknown dimensions (undecodable data,
|
|
21
|
+
* remote URLs, provider file ids) charge the detail level's full patch budget.
|
|
22
|
+
*/
|
|
23
|
+
export declare function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number;
|
|
24
|
+
/** Pixel size read from the header of base64-encoded image data, or null. */
|
|
25
|
+
export declare function base64ImageSize(base64: string): ImageSize | null;
|
|
26
|
+
/** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
|
|
27
|
+
export declare function dataUrlImageSize(url: string): ImageSize | null;
|
|
28
|
+
/** {@link estimateImageTokens} for an in-memory image block. */
|
|
29
|
+
export declare function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number;
|
|
@@ -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
|
/**
|
|
@@ -70,7 +72,8 @@ export declare class Tokenizer {
|
|
|
70
72
|
* Settled historical messages are counted once and reused until an owner
|
|
71
73
|
* (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
|
|
72
74
|
* assistants bypass the memo entirely (see the message-cache settle-gate
|
|
73
|
-
* invariant). Image blocks charge
|
|
75
|
+
* invariant). Image blocks charge {@link estimateImageContentTokens}, the
|
|
76
|
+
* same dimension-based estimate the native remote-compaction probe applies.
|
|
74
77
|
*/
|
|
75
78
|
countMessage(message: AgentMessage, options?: MessageCountOptions): number;
|
|
76
79
|
/** Sum of {@link countMessage} over `messages`. */
|
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.6.
|
|
4
|
+
"version": "18.6.3",
|
|
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.6.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.6.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.6.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.6.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.6.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.6.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.6.3",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.6.3",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.6.3",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.6.3",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.6.3",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.6.3",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.6.
|
|
50
|
+
"@oh-my-pi/omptype": "18.6.3",
|
|
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
|
|
@@ -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,
|