@oh-my-pi/pi-agent-core 15.10.12 → 15.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/dist/types/compaction/compaction.d.ts +1 -1
- package/dist/types/compaction/messages.d.ts +14 -2
- package/dist/types/compaction/pruning.d.ts +48 -0
- package/dist/types/compaction/utils.d.ts +18 -2
- package/package.json +6 -5
- package/src/agent-loop.ts +20 -7
- package/src/compaction/branch-summarization.ts +5 -4
- package/src/compaction/compaction.ts +25 -9
- package/src/compaction/messages.ts +78 -64
- package/src/compaction/prompts/file-operations.md +3 -8
- package/src/compaction/pruning.ts +174 -8
- package/src/compaction/utils.ts +84 -19
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.0] - 2026-06-10
|
|
6
|
+
### Breaking Changes
|
|
7
|
+
|
|
8
|
+
- Removed `compaction/index.ts` re-export of snapcompact helpers, so snapcompact utilities are no longer available from the agent compaction barrel and should be imported from `@oh-my-pi/snapcompact`
|
|
9
|
+
- Removed the `convertToLlm` alias export from `compaction/messages` — it duplicated `defaultConvertToLlm` under a second name. Import `defaultConvertToLlm` (array form) or the new `convertMessageToLlm` (single-message form) instead
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added `convertMessageToLlm()`: the single-message core transformer behind `defaultConvertToLlm()`. Embedders with app-specific message roles should handle their own roles and delegate every core role (`user`/`developer`/`assistant`/`toolResult`/`custom`/`hookMessage`/`branchSummary`/`compactionSummary`) to it instead of duplicating the conversion — a duplicated `compactionSummary` case is how snapcompact frames once silently dropped off provider requests
|
|
14
|
+
- Added `pruneSupersededToolResults()` and the opt-in `PruneConfig.supersedeKey` hook so harnesses can prune stale tool results superseded by a newer read of the same file; superseded results are pruned ahead of age-based victims during overflow pruning and replaced with a `[Superseded by a newer read of this file]` placeholder. Without the new config, `pruneToolOutputs()` behavior is unchanged.
|
|
15
|
+
- Added `readToolSupersedeKey()` implementing the read-tool path/selector grammar (selector-free reads supersede range reads of the same file; URL-scheme paths exempt). Pruning honors prompt-cache economics: per-turn prunes only fire when the post-candidate suffix is small or the cache is cold (idle gap).
|
|
16
|
+
- Added the `snapcompact` compaction strategy via `@oh-my-pi/snapcompact`: instead of an LLM summary, discarded history is printed onto dense bitmap frames and re-attached to the compaction summary message as image blocks. `CompactionSummaryMessage` gains an optional `images` field, `estimateTokens()` charges per attached frame, and frames persist under `preserveData.snapcompact` with an 8-frame middle-out eviction budget.
|
|
17
|
+
- Snapcompact frames are now rendered in a provider-aware shape (`SNAPCOMPACT_SHAPES` + `resolveSnapcompactShape(api)`), following the snapcompact 200k-token monolithic evals: Anthropic-family and unknown APIs get `8x8r-bw` (unscii-8 square cells, black ink, every line printed twice with the copy on a pale highlight band — read at F1 parity with raw text at ~2x lower cost and the most refusal-robust), Google gets `8x8r-sent` (sentence-hue ink, ~2.9x cheaper), and OpenAI gets `6x6u-sent` (unscii Lanczos-stretched to 6x6 cells — OpenAI bills a flat ~2.9k tokens per image, so frame count is the only cost lever) with `detail: "original"` on the frame images. `snapcompactCompact()` accepts `model`/`shape` options, frames persist their shape metadata, mixed-shape archives (provider switches, legacy 5x8 frames) are flagged in the reading instructions, and `snapcompactGeometry()`/`renderSnapcompactFrame()` now take a shape
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Compaction and branch-summary file lists are now a single `<files>` tag instead of `<read-files>`/`<modified-files>`: paths render as the grouped, prefix-folded directory tree the find/search tools emit (`# dir/` headers, bare basenames), each annotated `(Read)`, `(Write)`, or `(RW)` — modified files that were also read get `(RW)`. Legacy tags in summaries written by earlier versions are still stripped and self-heal on the next compaction
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed queued steering messages being drained into an externally aborted run: interrupting mid-tool execution (e.g. Enter with a pending steer) dequeued the steer into the dying run — it landed in history without a response and the post-abort resume saw an empty queue, so the agent stopped instead of continuing. Steering/follow-up/aside queue polls are now skipped once the run's abort signal fires, leaving the queue intact for `Agent.continue()`.
|
|
26
|
+
- Fixed `<read-files>` compaction lists recording the same file once per line-range/raw selector (`src/foo.ts:50-200`, `:raw`, `:1-50:raw`, …): read-tool selectors are now stripped before tracking, so reads dedupe to the base path and match their write/edit path when splitting read-only vs modified lists. Selector-polluted lists stored by earlier compactions self-heal on the next compaction. `readToolSupersedeKey()` now shares the same splitter (`splitReadSelector()`), gaining the `..` range alias and `L`-prefix forms it previously missed.
|
|
27
|
+
- Fixed `estimateTokens()` undercounting thinking-heavy assistant messages on replay: `thinkingSignature` payloads (OpenAI Responses encrypted reasoning items, Anthropic signed thinking blocks, etc.) and `redactedThinking.data` are now charged alongside the visible thinking text, so the local estimate tracks provider-reported usage instead of straddling the threshold on every turn ([#2275](https://github.com/can1357/oh-my-pi/issues/2275)).
|
|
28
|
+
|
|
5
29
|
## [15.10.12] - 2026-06-10
|
|
6
30
|
|
|
7
31
|
### Added
|
|
@@ -664,4 +688,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
|
|
664
688
|
|
|
665
689
|
- `Agent` constructor now has all options optional (empty options use defaults).
|
|
666
690
|
|
|
667
|
-
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
|
691
|
+
- `queueMessage()` is now synchronous (no longer returns a Promise).
|
|
@@ -30,7 +30,7 @@ export interface CompactionResult<T = unknown> {
|
|
|
30
30
|
}
|
|
31
31
|
export interface CompactionSettings {
|
|
32
32
|
enabled: boolean;
|
|
33
|
-
strategy?: "context-full" | "handoff" | "shake" | "off";
|
|
33
|
+
strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
|
|
34
34
|
thresholdPercent?: number;
|
|
35
35
|
thresholdTokens?: number;
|
|
36
36
|
reserveTokens: number;
|
|
@@ -33,6 +33,8 @@ export interface CompactionSummaryMessage {
|
|
|
33
33
|
shortSummary?: string;
|
|
34
34
|
tokensBefore: number;
|
|
35
35
|
providerPayload?: ProviderPayload;
|
|
36
|
+
/** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */
|
|
37
|
+
images?: ImageContent[];
|
|
36
38
|
timestamp: number;
|
|
37
39
|
}
|
|
38
40
|
export type CoreCompactionMessage = CustomMessage | HookMessage | BranchSummaryMessage | CompactionSummaryMessage;
|
|
@@ -48,8 +50,19 @@ export type ConvertToLlm = (messages: AgentMessage[]) => Message[];
|
|
|
48
50
|
export declare function renderBranchSummaryContext(summary: string): string;
|
|
49
51
|
export declare function renderCompactionSummaryContext(summary: string): string;
|
|
50
52
|
export declare function createBranchSummaryMessage(summary: string, fromId: string, timestamp: string): BranchSummaryMessage;
|
|
51
|
-
export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload): CompactionSummaryMessage;
|
|
53
|
+
export declare function createCompactionSummaryMessage(summary: string, tokensBefore: number, timestamp: string, shortSummary?: string, providerPayload?: ProviderPayload, images?: ImageContent[]): CompactionSummaryMessage;
|
|
52
54
|
export declare function createCustomMessage(customType: string, content: string | (TextContent | ImageContent)[], display: boolean, details: unknown | undefined, timestamp: string, attribution?: MessageAttribution): CustomMessage;
|
|
55
|
+
/**
|
|
56
|
+
* Transform a single core-domain agent message to its LLM form; `undefined`
|
|
57
|
+
* drops it from the provider request.
|
|
58
|
+
*
|
|
59
|
+
* Single source of truth for the core roles (user/developer/assistant/
|
|
60
|
+
* toolResult) and the compaction messages owned by this package. Embedders
|
|
61
|
+
* with their own app messages (e.g. the coding agent) handle their custom
|
|
62
|
+
* roles and delegate every core role here — duplicating these cases is how
|
|
63
|
+
* snapcompact frames once silently fell off the provider request.
|
|
64
|
+
*/
|
|
65
|
+
export declare function convertMessageToLlm(message: AgentMessage): Message | undefined;
|
|
53
66
|
/**
|
|
54
67
|
* Default compaction-domain transformer.
|
|
55
68
|
*
|
|
@@ -58,4 +71,3 @@ export declare function createCustomMessage(customType: string, content: string
|
|
|
58
71
|
* core LLM roles and the compaction messages owned by this package.
|
|
59
72
|
*/
|
|
60
73
|
export declare function defaultConvertToLlm(messages: AgentMessage[]): Message[];
|
|
61
|
-
export declare const convertToLlm: typeof defaultConvertToLlm;
|
|
@@ -10,10 +10,58 @@ export interface PruneConfig {
|
|
|
10
10
|
minimumSavings: number;
|
|
11
11
|
/** Tool-result protection matchers. String entries protect every result from that tool; predicates may inspect the paired tool call. */
|
|
12
12
|
protectedTools: ProtectedToolMatcher[];
|
|
13
|
+
/**
|
|
14
|
+
* Optional supersede key function (see {@link SupersedePruneConfig.supersedeKey}).
|
|
15
|
+
* When provided, superseded tool results are pruned first — even inside the
|
|
16
|
+
* `protectTokens` window — before age-based victims. Absent, behavior is
|
|
17
|
+
* unchanged.
|
|
18
|
+
*/
|
|
19
|
+
supersedeKey?: SupersedeKeyFn;
|
|
13
20
|
}
|
|
14
21
|
export declare const DEFAULT_PRUNE_CONFIG: PruneConfig;
|
|
15
22
|
export interface PruneResult {
|
|
16
23
|
prunedCount: number;
|
|
17
24
|
tokensSaved: number;
|
|
18
25
|
}
|
|
26
|
+
/** Exact placeholder written over a superseded tool result. */
|
|
27
|
+
export declare const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
|
|
28
|
+
/**
|
|
29
|
+
* Maps a tool call to a supersede key. Results sharing a key form a group in
|
|
30
|
+
* which every result except the newest is a supersede candidate. A key `K`
|
|
31
|
+
* additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
|
|
32
|
+
* supersedes selector-carrying reads of the same base path). Return
|
|
33
|
+
* `undefined` to exempt a call from supersede grouping.
|
|
34
|
+
*/
|
|
35
|
+
export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
|
|
36
|
+
export interface SupersedePruneConfig {
|
|
37
|
+
/** Supersede key function; results sharing a key supersede older ones. */
|
|
38
|
+
supersedeKey: SupersedeKeyFn;
|
|
39
|
+
/** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
|
|
40
|
+
suffixTokenLimit?: number;
|
|
41
|
+
/** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
|
|
42
|
+
idleFlushMs?: number;
|
|
43
|
+
/** Clock override for tests. */
|
|
44
|
+
now?: number;
|
|
45
|
+
/** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
|
|
46
|
+
protectedTools: ProtectedToolMatcher[];
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
|
|
50
|
+
* read of the same file). Cheap, incremental, and prompt-cache-aware: a
|
|
51
|
+
* candidate is pruned now only when the suffix after it is small (tail case —
|
|
52
|
+
* the read→edit→read loop) or when the context has been idle long enough that
|
|
53
|
+
* the provider cache is cold anyway (then ALL candidates flush).
|
|
54
|
+
*/
|
|
55
|
+
export declare function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult;
|
|
19
56
|
export declare function pruneToolOutputs(entries: SessionEntry[], config?: PruneConfig): PruneResult;
|
|
57
|
+
/**
|
|
58
|
+
* Supersede key for the `read` tool: the file path with the trailing line/raw
|
|
59
|
+
* selector stripped (the read tool's own splitter grammar via
|
|
60
|
+
* {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
|
|
61
|
+
* Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
|
|
62
|
+
* Selector-free reads key on the bare path; selector-carrying reads key on
|
|
63
|
+
* `path + "\u0000" + selector`, so two reads collide only when the newer is
|
|
64
|
+
* selector-free or the selectors are identical (the pass's prefix rule lets a
|
|
65
|
+
* bare-path read supersede selector-carrying reads of the same file).
|
|
66
|
+
*/
|
|
67
|
+
export declare function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined;
|
|
@@ -9,6 +9,22 @@ export interface FileOperations {
|
|
|
9
9
|
edited: Set<string>;
|
|
10
10
|
}
|
|
11
11
|
export declare function createFileOps(): FileOperations;
|
|
12
|
+
/**
|
|
13
|
+
* Split a read-tool path into its base path and trailing selector, mirroring the
|
|
14
|
+
* read tool's own splitter. Single source of the grammar in this package: the
|
|
15
|
+
* file-operations list strips selectors via {@link stripReadSelector}, and the
|
|
16
|
+
* supersede-prune pass keys on both parts via `readToolSupersedeKey`.
|
|
17
|
+
*/
|
|
18
|
+
export declare function splitReadSelector(path: string): {
|
|
19
|
+
path: string;
|
|
20
|
+
sel?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Strip a trailing read-tool selector (`:50-200`, `:raw`, `:1-50:raw`, `:conflicts`, …)
|
|
24
|
+
* so the same file read with different line ranges dedupes to one `<files>` entry
|
|
25
|
+
* and matches its write/edit path when computing Read/Write/RW markers.
|
|
26
|
+
*/
|
|
27
|
+
export declare function stripReadSelector(path: string): string;
|
|
12
28
|
/**
|
|
13
29
|
* Extract file operations from tool calls in an assistant message.
|
|
14
30
|
*/
|
|
@@ -21,8 +37,8 @@ export declare function computeFileLists(fileOps: FileOperations): {
|
|
|
21
37
|
readFiles: string[];
|
|
22
38
|
modifiedFiles: string[];
|
|
23
39
|
};
|
|
24
|
-
export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[]): string;
|
|
25
|
-
export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[]): string;
|
|
40
|
+
export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
|
|
41
|
+
export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
|
|
26
42
|
/**
|
|
27
43
|
* Serialize LLM messages to text for summarization.
|
|
28
44
|
* This prevents the model from treating it as a conversation to continue.
|
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": "15.
|
|
4
|
+
"version": "15.11.0",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,10 +35,11 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "15.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "15.
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
38
|
+
"@oh-my-pi/pi-ai": "15.11.0",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "15.11.0",
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.11.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.11.0",
|
|
42
|
+
"@oh-my-pi/snapcompact": "15.11.0",
|
|
42
43
|
"@opentelemetry/api": "^1.9.1"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -564,8 +564,10 @@ async function runLoopBody(
|
|
|
564
564
|
streamFn?: StreamFn,
|
|
565
565
|
): Promise<void> {
|
|
566
566
|
let firstTurn = true;
|
|
567
|
-
// Check for steering messages at start (user may have typed while waiting)
|
|
568
|
-
|
|
567
|
+
// Check for steering messages at start (user may have typed while waiting).
|
|
568
|
+
// Skip when the run is already externally aborted — dequeuing would strand
|
|
569
|
+
// the messages in a run that is about to die.
|
|
570
|
+
let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
|
|
569
571
|
let harmonyRetryAttempt = 0;
|
|
570
572
|
let harmonyTruncateResumeCount = 0;
|
|
571
573
|
|
|
@@ -743,7 +745,12 @@ async function runLoopBody(
|
|
|
743
745
|
|
|
744
746
|
stream.push({ type: "turn_end", message, toolResults });
|
|
745
747
|
|
|
746
|
-
|
|
748
|
+
// On external abort (user interrupt), leave the steering queue intact: the
|
|
749
|
+
// session aborts then continues, delivering the queue into a fresh run.
|
|
750
|
+
// Draining it here would inject the messages right before a model call that
|
|
751
|
+
// instantly aborts — message lands in history, agent never responds.
|
|
752
|
+
const steering =
|
|
753
|
+
steeringMessagesFromExecution ?? (signal?.aborted ? [] : (await config.getSteeringMessages?.()) || []);
|
|
747
754
|
if (hasMoreToolCalls) {
|
|
748
755
|
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
749
756
|
const asides = resolveAsides(await config.getAsideMessages?.());
|
|
@@ -758,8 +765,9 @@ async function runLoopBody(
|
|
|
758
765
|
|
|
759
766
|
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
|
|
760
767
|
await config.onBeforeYield?.();
|
|
761
|
-
|
|
762
|
-
const
|
|
768
|
+
// Skip queue drains when externally aborted (same stranding hazard as above).
|
|
769
|
+
const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
|
|
770
|
+
const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || [];
|
|
763
771
|
if (asideMessages.length > 0 || followUpMessages.length > 0) {
|
|
764
772
|
// Set as pending so the inner loop processes them before stopping.
|
|
765
773
|
pendingMessages = [...asideMessages, ...followUpMessages];
|
|
@@ -1253,11 +1261,16 @@ async function executeToolCalls(
|
|
|
1253
1261
|
}));
|
|
1254
1262
|
|
|
1255
1263
|
const checkSteering = async (): Promise<void> => {
|
|
1256
|
-
|
|
1264
|
+
// `signal` (external/user abort) is checked separately from the internal
|
|
1265
|
+
// steeringAbortController: once the run is externally aborted it is
|
|
1266
|
+
// unwinding, and draining the steering queue here would strand the
|
|
1267
|
+
// messages in the dying run instead of leaving them for the post-abort
|
|
1268
|
+
// continue (interruptAndFlushQueuedMessages → Agent.continue()).
|
|
1269
|
+
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered || signal?.aborted) {
|
|
1257
1270
|
return;
|
|
1258
1271
|
}
|
|
1259
1272
|
const check = steeringCheckTail.then(async () => {
|
|
1260
|
-
if (interruptState.triggered) return;
|
|
1273
|
+
if (interruptState.triggered || signal?.aborted) return;
|
|
1261
1274
|
const steering = await getSteeringMessages();
|
|
1262
1275
|
if (steering.length > 0) {
|
|
1263
1276
|
steeringMessages = steering;
|
|
@@ -13,10 +13,10 @@ import { estimateTokens } from "./compaction";
|
|
|
13
13
|
import type { ReadonlySessionManager, SessionEntry } from "./entries";
|
|
14
14
|
import {
|
|
15
15
|
type ConvertToLlm,
|
|
16
|
-
convertToLlm,
|
|
17
16
|
createBranchSummaryMessage,
|
|
18
17
|
createCompactionSummaryMessage,
|
|
19
18
|
createCustomMessage,
|
|
19
|
+
defaultConvertToLlm,
|
|
20
20
|
} from "./messages";
|
|
21
21
|
import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" };
|
|
22
22
|
import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" };
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type FileOperations,
|
|
28
28
|
SUMMARIZATION_SYSTEM_PROMPT,
|
|
29
29
|
serializeConversation,
|
|
30
|
+
stripReadSelector,
|
|
30
31
|
upsertFileOperations,
|
|
31
32
|
} from "./utils";
|
|
32
33
|
|
|
@@ -214,7 +215,7 @@ export function prepareBranchEntries(entries: SessionEntry[], tokenBudget: numbe
|
|
|
214
215
|
if (entry.type === "branch_summary" && !entry.fromExtension && entry.details) {
|
|
215
216
|
const details = entry.details as BranchSummaryDetails;
|
|
216
217
|
if (Array.isArray(details.readFiles)) {
|
|
217
|
-
for (const f of details.readFiles) fileOps.read.add(f);
|
|
218
|
+
for (const f of details.readFiles) fileOps.read.add(stripReadSelector(f));
|
|
218
219
|
}
|
|
219
220
|
if (Array.isArray(details.modifiedFiles)) {
|
|
220
221
|
// Modified files go into both edited and written for proper deduplication
|
|
@@ -288,7 +289,7 @@ export async function generateBranchSummary(
|
|
|
288
289
|
|
|
289
290
|
// Transform to LLM-compatible messages, then serialize to text
|
|
290
291
|
// Serialization prevents the model from treating it as a conversation to continue
|
|
291
|
-
const llmMessages = (options.convertToLlm ??
|
|
292
|
+
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
292
293
|
const conversationText = serializeConversation(llmMessages);
|
|
293
294
|
|
|
294
295
|
// Build prompt
|
|
@@ -329,7 +330,7 @@ export async function generateBranchSummary(
|
|
|
329
330
|
|
|
330
331
|
// Compute file lists and append to summary
|
|
331
332
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
332
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles);
|
|
333
|
+
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
333
334
|
|
|
334
335
|
return {
|
|
335
336
|
summary: summary || "No summary generated",
|
|
@@ -18,11 +18,12 @@ import {
|
|
|
18
18
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
19
19
|
import { countTokens } from "@oh-my-pi/pi-natives";
|
|
20
20
|
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { SNAPCOMPACT_FRAME_TOKEN_ESTIMATE } from "@oh-my-pi/snapcompact";
|
|
21
22
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
22
23
|
import { ThinkingLevel } from "../thinking";
|
|
23
24
|
import type { AgentMessage } from "../types";
|
|
24
25
|
import type { CompactionEntry, SessionEntry } from "./entries";
|
|
25
|
-
import { type ConvertToLlm,
|
|
26
|
+
import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
|
|
26
27
|
import {
|
|
27
28
|
buildOpenAiNativeHistory,
|
|
28
29
|
getPreservedOpenAiRemoteCompactionData,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
type FileOperations,
|
|
46
47
|
SUMMARIZATION_SYSTEM_PROMPT,
|
|
47
48
|
serializeConversation,
|
|
49
|
+
stripReadSelector,
|
|
48
50
|
upsertFileOperations,
|
|
49
51
|
} from "./utils";
|
|
50
52
|
|
|
@@ -74,7 +76,7 @@ function extractFileOperations(
|
|
|
74
76
|
if (!prevCompaction.fromExtension && prevCompaction.details) {
|
|
75
77
|
const details = prevCompaction.details as CompactionDetails;
|
|
76
78
|
if (Array.isArray(details.readFiles)) {
|
|
77
|
-
for (const f of details.readFiles) fileOps.read.add(f);
|
|
79
|
+
for (const f of details.readFiles) fileOps.read.add(stripReadSelector(f));
|
|
78
80
|
}
|
|
79
81
|
if (Array.isArray(details.modifiedFiles)) {
|
|
80
82
|
for (const f of details.modifiedFiles) fileOps.edited.add(f);
|
|
@@ -137,7 +139,7 @@ export interface CompactionResult<T = unknown> {
|
|
|
137
139
|
|
|
138
140
|
export interface CompactionSettings {
|
|
139
141
|
enabled: boolean;
|
|
140
|
-
strategy?: "context-full" | "handoff" | "shake" | "off";
|
|
142
|
+
strategy?: "context-full" | "handoff" | "shake" | "snapcompact" | "off";
|
|
141
143
|
thresholdPercent?: number;
|
|
142
144
|
thresholdTokens?: number;
|
|
143
145
|
reserveTokens: number;
|
|
@@ -285,9 +287,19 @@ export function estimateTokens(message: AgentMessage): number {
|
|
|
285
287
|
fragments.push(block.text);
|
|
286
288
|
} else if (block.type === "thinking") {
|
|
287
289
|
fragments.push(block.thinking);
|
|
290
|
+
// Providers charge for the opaque signature/reasoning payload that
|
|
291
|
+
// rides alongside the thinking text (OpenAI Responses encrypted
|
|
292
|
+
// reasoning items, Anthropic signed thinking blocks, etc.). Without
|
|
293
|
+
// counting it, this estimator can read ~half of the provider-reported
|
|
294
|
+
// usage on thinking-heavy turns — see #2275 for the resulting
|
|
295
|
+
// compaction-trigger / post-check metric divergence.
|
|
296
|
+
if (block.thinkingSignature) fragments.push(block.thinkingSignature);
|
|
288
297
|
} else if (block.type === "toolCall") {
|
|
289
298
|
fragments.push(block.name);
|
|
290
299
|
fragments.push(JSON.stringify(block.arguments));
|
|
300
|
+
} else if (block.type === "redactedThinking") {
|
|
301
|
+
// Encrypted reasoning blob the provider still bills for on replay.
|
|
302
|
+
fragments.push(block.data);
|
|
291
303
|
}
|
|
292
304
|
}
|
|
293
305
|
break;
|
|
@@ -310,6 +322,10 @@ export function estimateTokens(message: AgentMessage): number {
|
|
|
310
322
|
case "branchSummary":
|
|
311
323
|
case "compactionSummary": {
|
|
312
324
|
fragments.push(message.summary);
|
|
325
|
+
if (message.role === "compactionSummary" && message.images) {
|
|
326
|
+
// Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
|
|
327
|
+
extra += message.images.length * SNAPCOMPACT_FRAME_TOKEN_ESTIMATE;
|
|
328
|
+
}
|
|
313
329
|
break;
|
|
314
330
|
}
|
|
315
331
|
default:
|
|
@@ -625,7 +641,7 @@ export async function generateSummary(
|
|
|
625
641
|
|
|
626
642
|
// Serialize conversation to text so model doesn't try to continue it
|
|
627
643
|
// Convert to LLM messages first (handles custom app messages when caller provides a transformer).
|
|
628
|
-
const llmMessages = (options?.convertToLlm ??
|
|
644
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
|
|
629
645
|
const conversationText = serializeConversation(llmMessages);
|
|
630
646
|
|
|
631
647
|
// Build the prompt with conversation wrapped in tags
|
|
@@ -724,7 +740,7 @@ export async function generateHandoff(
|
|
|
724
740
|
options: HandoffOptions,
|
|
725
741
|
signal?: AbortSignal,
|
|
726
742
|
): Promise<string> {
|
|
727
|
-
const llmMessages = (options.convertToLlm ??
|
|
743
|
+
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
728
744
|
const requestMessages: Message[] = [
|
|
729
745
|
...llmMessages,
|
|
730
746
|
{
|
|
@@ -773,7 +789,7 @@ async function generateShortSummary(
|
|
|
773
789
|
options?: SummaryOptions,
|
|
774
790
|
): Promise<string> {
|
|
775
791
|
const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
|
|
776
|
-
const llmMessages = (options?.convertToLlm ??
|
|
792
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
|
|
777
793
|
const conversationText = serializeConversation(llmMessages);
|
|
778
794
|
|
|
779
795
|
let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
|
|
@@ -1010,7 +1026,7 @@ export async function compact(
|
|
|
1010
1026
|
? previousRemoteCompaction.replacementHistory
|
|
1011
1027
|
: undefined;
|
|
1012
1028
|
const remoteHistory = buildOpenAiNativeHistory(
|
|
1013
|
-
(summaryOptions.convertToLlm ??
|
|
1029
|
+
(summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
|
|
1014
1030
|
model,
|
|
1015
1031
|
previousReplacementHistory,
|
|
1016
1032
|
);
|
|
@@ -1098,7 +1114,7 @@ export async function compact(
|
|
|
1098
1114
|
|
|
1099
1115
|
// Compute file lists and append to summary
|
|
1100
1116
|
const { readFiles, modifiedFiles } = computeFileLists(fileOps);
|
|
1101
|
-
summary = upsertFileOperations(summary, readFiles, modifiedFiles);
|
|
1117
|
+
summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
|
|
1102
1118
|
|
|
1103
1119
|
if (!firstKeptEntryId) {
|
|
1104
1120
|
throw new Error("First kept entry has no ID - session may need migration");
|
|
@@ -1127,7 +1143,7 @@ async function generateTurnPrefixSummary(
|
|
|
1127
1143
|
): Promise<string> {
|
|
1128
1144
|
const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
|
|
1129
1145
|
|
|
1130
|
-
const llmMessages = (options?.convertToLlm ??
|
|
1146
|
+
const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
1131
1147
|
const conversationText = serializeConversation(llmMessages);
|
|
1132
1148
|
const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
1133
1149
|
const summarizationMessages = [
|
|
@@ -51,6 +51,8 @@ export interface CompactionSummaryMessage {
|
|
|
51
51
|
shortSummary?: string;
|
|
52
52
|
tokensBefore: number;
|
|
53
53
|
providerPayload?: ProviderPayload;
|
|
54
|
+
/** Snapcompact frames archived by this compaction; appended as image blocks after the summary text. */
|
|
55
|
+
images?: ImageContent[];
|
|
54
56
|
timestamp: number;
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -98,6 +100,7 @@ export function createCompactionSummaryMessage(
|
|
|
98
100
|
timestamp: string,
|
|
99
101
|
shortSummary?: string,
|
|
100
102
|
providerPayload?: ProviderPayload,
|
|
103
|
+
images?: ImageContent[],
|
|
101
104
|
): CompactionSummaryMessage {
|
|
102
105
|
return {
|
|
103
106
|
role: "compactionSummary",
|
|
@@ -105,6 +108,7 @@ export function createCompactionSummaryMessage(
|
|
|
105
108
|
shortSummary,
|
|
106
109
|
tokensBefore,
|
|
107
110
|
providerPayload,
|
|
111
|
+
images: images && images.length > 0 ? images : undefined,
|
|
108
112
|
timestamp: new Date(timestamp).getTime(),
|
|
109
113
|
};
|
|
110
114
|
}
|
|
@@ -137,6 +141,79 @@ function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage
|
|
|
137
141
|
);
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Transform a single core-domain agent message to its LLM form; `undefined`
|
|
146
|
+
* drops it from the provider request.
|
|
147
|
+
*
|
|
148
|
+
* Single source of truth for the core roles (user/developer/assistant/
|
|
149
|
+
* toolResult) and the compaction messages owned by this package. Embedders
|
|
150
|
+
* with their own app messages (e.g. the coding agent) handle their custom
|
|
151
|
+
* roles and delegate every core role here — duplicating these cases is how
|
|
152
|
+
* snapcompact frames once silently fell off the provider request.
|
|
153
|
+
*/
|
|
154
|
+
export function convertMessageToLlm(message: AgentMessage): Message | undefined {
|
|
155
|
+
if (isCoreCompactionMessage(message)) {
|
|
156
|
+
switch (message.role) {
|
|
157
|
+
case "custom":
|
|
158
|
+
case "hookMessage": {
|
|
159
|
+
const content =
|
|
160
|
+
typeof message.content === "string"
|
|
161
|
+
? [{ type: "text" as const, text: message.content }]
|
|
162
|
+
: message.content;
|
|
163
|
+
return {
|
|
164
|
+
role: "developer",
|
|
165
|
+
content,
|
|
166
|
+
attribution: message.attribution,
|
|
167
|
+
timestamp: message.timestamp,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
case "branchSummary":
|
|
171
|
+
return {
|
|
172
|
+
role: "user",
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text" as const,
|
|
176
|
+
text: renderBranchSummaryContext(message.summary),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
attribution: "agent",
|
|
180
|
+
timestamp: message.timestamp,
|
|
181
|
+
};
|
|
182
|
+
case "compactionSummary":
|
|
183
|
+
return {
|
|
184
|
+
role: "user",
|
|
185
|
+
content: [
|
|
186
|
+
{
|
|
187
|
+
type: "text" as const,
|
|
188
|
+
text: renderCompactionSummaryContext(message.summary),
|
|
189
|
+
},
|
|
190
|
+
...(message.images ?? []),
|
|
191
|
+
],
|
|
192
|
+
attribution: "agent",
|
|
193
|
+
providerPayload: message.providerPayload,
|
|
194
|
+
timestamp: message.timestamp,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
switch (message.role) {
|
|
200
|
+
case "user":
|
|
201
|
+
return { ...message, attribution: message.attribution ?? "user" };
|
|
202
|
+
case "developer":
|
|
203
|
+
return { ...message, attribution: message.attribution ?? "agent" };
|
|
204
|
+
case "assistant":
|
|
205
|
+
return message as AssistantMessage;
|
|
206
|
+
case "toolResult":
|
|
207
|
+
return {
|
|
208
|
+
...message,
|
|
209
|
+
content: getPrunedToolResultContent(message as ToolResultMessage),
|
|
210
|
+
attribution: message.attribution ?? "agent",
|
|
211
|
+
};
|
|
212
|
+
default:
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
140
217
|
/**
|
|
141
218
|
* Default compaction-domain transformer.
|
|
142
219
|
*
|
|
@@ -145,68 +222,5 @@ function isCoreCompactionMessage(message: AgentMessage): message is AgentMessage
|
|
|
145
222
|
* core LLM roles and the compaction messages owned by this package.
|
|
146
223
|
*/
|
|
147
224
|
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
|
148
|
-
return messages
|
|
149
|
-
.map((message): Message | undefined => {
|
|
150
|
-
if (isCoreCompactionMessage(message)) {
|
|
151
|
-
switch (message.role) {
|
|
152
|
-
case "custom":
|
|
153
|
-
case "hookMessage": {
|
|
154
|
-
const content =
|
|
155
|
-
typeof message.content === "string"
|
|
156
|
-
? [{ type: "text" as const, text: message.content }]
|
|
157
|
-
: message.content;
|
|
158
|
-
return {
|
|
159
|
-
role: "developer",
|
|
160
|
-
content,
|
|
161
|
-
attribution: message.attribution,
|
|
162
|
-
timestamp: message.timestamp,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
case "branchSummary":
|
|
166
|
-
return {
|
|
167
|
-
role: "user",
|
|
168
|
-
content: [
|
|
169
|
-
{
|
|
170
|
-
type: "text" as const,
|
|
171
|
-
text: renderBranchSummaryContext(message.summary),
|
|
172
|
-
},
|
|
173
|
-
],
|
|
174
|
-
attribution: "agent",
|
|
175
|
-
timestamp: message.timestamp,
|
|
176
|
-
};
|
|
177
|
-
case "compactionSummary":
|
|
178
|
-
return {
|
|
179
|
-
role: "user",
|
|
180
|
-
content: [
|
|
181
|
-
{
|
|
182
|
-
type: "text" as const,
|
|
183
|
-
text: renderCompactionSummaryContext(message.summary),
|
|
184
|
-
},
|
|
185
|
-
],
|
|
186
|
-
attribution: "agent",
|
|
187
|
-
providerPayload: message.providerPayload,
|
|
188
|
-
timestamp: message.timestamp,
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
switch (message.role) {
|
|
194
|
-
case "user":
|
|
195
|
-
return { ...message, attribution: message.attribution ?? "user" };
|
|
196
|
-
case "developer":
|
|
197
|
-
return { ...message, attribution: message.attribution ?? "agent" };
|
|
198
|
-
case "assistant":
|
|
199
|
-
return message as AssistantMessage;
|
|
200
|
-
case "toolResult":
|
|
201
|
-
return {
|
|
202
|
-
...message,
|
|
203
|
-
content: getPrunedToolResultContent(message as ToolResultMessage),
|
|
204
|
-
attribution: message.attribution ?? "agent",
|
|
205
|
-
};
|
|
206
|
-
default:
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
})
|
|
210
|
-
.filter(message => message !== undefined);
|
|
225
|
+
return messages.map(convertMessageToLlm).filter(message => message !== undefined);
|
|
211
226
|
}
|
|
212
|
-
export const convertToLlm = defaultConvertToLlm;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
6
|
-
import type { AgentMessage } from "../types";
|
|
6
|
+
import type { AgentMessage, AgentToolCall } from "../types";
|
|
7
7
|
import { estimateTokens } from "./compaction";
|
|
8
8
|
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
9
9
|
import {
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isSkillReadToolResult,
|
|
13
13
|
type ProtectedToolMatcher,
|
|
14
14
|
} from "./tool-protection";
|
|
15
|
+
import { splitReadSelector } from "./utils";
|
|
15
16
|
|
|
16
17
|
export interface PruneConfig {
|
|
17
18
|
/** Keep the most recent tool output tokens intact. */
|
|
@@ -20,6 +21,13 @@ export interface PruneConfig {
|
|
|
20
21
|
minimumSavings: number;
|
|
21
22
|
/** Tool-result protection matchers. String entries protect every result from that tool; predicates may inspect the paired tool call. */
|
|
22
23
|
protectedTools: ProtectedToolMatcher[];
|
|
24
|
+
/**
|
|
25
|
+
* Optional supersede key function (see {@link SupersedePruneConfig.supersedeKey}).
|
|
26
|
+
* When provided, superseded tool results are pruned first — even inside the
|
|
27
|
+
* `protectTokens` window — before age-based victims. Absent, behavior is
|
|
28
|
+
* unchanged.
|
|
29
|
+
*/
|
|
30
|
+
supersedeKey?: SupersedeKeyFn;
|
|
23
31
|
}
|
|
24
32
|
|
|
25
33
|
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
@@ -33,6 +41,34 @@ export interface PruneResult {
|
|
|
33
41
|
tokensSaved: number;
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
/** Exact placeholder written over a superseded tool result. */
|
|
45
|
+
export const SUPERSEDED_NOTICE = "[Superseded by a newer read of this file]";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Maps a tool call to a supersede key. Results sharing a key form a group in
|
|
49
|
+
* which every result except the newest is a supersede candidate. A key `K`
|
|
50
|
+
* additionally supersedes keys with prefix `K + "\u0000"` (selector-free read
|
|
51
|
+
* supersedes selector-carrying reads of the same base path). Return
|
|
52
|
+
* `undefined` to exempt a call from supersede grouping.
|
|
53
|
+
*/
|
|
54
|
+
export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) => string | undefined;
|
|
55
|
+
|
|
56
|
+
export interface SupersedePruneConfig {
|
|
57
|
+
/** Supersede key function; results sharing a key supersede older ones. */
|
|
58
|
+
supersedeKey: SupersedeKeyFn;
|
|
59
|
+
/** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
|
|
60
|
+
suffixTokenLimit?: number;
|
|
61
|
+
/** Prune all candidates when the last message is at least this old (prompt cache is cold anyway). Default 30 min. */
|
|
62
|
+
idleFlushMs?: number;
|
|
63
|
+
/** Clock override for tests. */
|
|
64
|
+
now?: number;
|
|
65
|
+
/** Tool-result protection matchers (same contract as {@link PruneConfig.protectedTools}). */
|
|
66
|
+
protectedTools: ProtectedToolMatcher[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
|
|
70
|
+
const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
|
|
71
|
+
|
|
36
72
|
function createPrunedNotice(tokens: number): string {
|
|
37
73
|
return `[Output truncated - ${tokens} tokens]`;
|
|
38
74
|
}
|
|
@@ -44,18 +80,121 @@ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefine
|
|
|
44
80
|
return message as ToolResultMessage;
|
|
45
81
|
}
|
|
46
82
|
|
|
47
|
-
function estimatePrunedSavings(tokens: number): number {
|
|
48
|
-
const noticeTokens = Math.ceil(
|
|
83
|
+
function estimatePrunedSavings(tokens: number, notice: string): number {
|
|
84
|
+
const noticeTokens = Math.ceil(notice.length / 4);
|
|
49
85
|
return Math.max(0, tokens - noticeTokens);
|
|
50
86
|
}
|
|
51
87
|
|
|
88
|
+
interface SupersedeCandidate {
|
|
89
|
+
entry: SessionMessageEntry;
|
|
90
|
+
message: ToolResultMessage;
|
|
91
|
+
/** Index of the entry within the `entries` array. */
|
|
92
|
+
index: number;
|
|
93
|
+
tokens: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Collect superseded tool results: for every unpruned, unprotected tool result
|
|
98
|
+
* whose paired call resolves a supersede key, a LATER result with the same key
|
|
99
|
+
* — or with a key that is the `"\u0000"`-prefix parent of this one — marks it
|
|
100
|
+
* superseded. Returned in message order.
|
|
101
|
+
*/
|
|
102
|
+
function collectSupersededResults(
|
|
103
|
+
entries: readonly SessionEntry[],
|
|
104
|
+
toolCallsById: ReadonlyMap<string, AgentToolCall>,
|
|
105
|
+
supersedeKey: SupersedeKeyFn,
|
|
106
|
+
protectedTools: readonly ProtectedToolMatcher[],
|
|
107
|
+
): SupersedeCandidate[] {
|
|
108
|
+
const candidates: SupersedeCandidate[] = [];
|
|
109
|
+
const seenKeys = new Set<string>();
|
|
110
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
111
|
+
const entry = entries[i];
|
|
112
|
+
const message = getToolResultMessage(entry);
|
|
113
|
+
if (!message || message.prunedAt !== undefined) continue;
|
|
114
|
+
const toolCall = toolCallsById.get(message.toolCallId);
|
|
115
|
+
if (!toolCall) continue;
|
|
116
|
+
if (isProtectedToolResult(message, toolCall, protectedTools)) continue;
|
|
117
|
+
const key = supersedeKey(toolCall.name, toolCall.arguments as Record<string, unknown>);
|
|
118
|
+
if (key === undefined) continue;
|
|
119
|
+
const separator = key.indexOf("\u0000");
|
|
120
|
+
const superseded = seenKeys.has(key) || (separator >= 0 && seenKeys.has(key.slice(0, separator)));
|
|
121
|
+
seenKeys.add(key);
|
|
122
|
+
if (!superseded) continue;
|
|
123
|
+
candidates.push({
|
|
124
|
+
entry: entry as SessionMessageEntry,
|
|
125
|
+
message,
|
|
126
|
+
index: i,
|
|
127
|
+
tokens: estimateTokens(message as AgentMessage),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return candidates.reverse();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Prune superseded tool results (e.g. stale `read` outputs replaced by a newer
|
|
135
|
+
* read of the same file). Cheap, incremental, and prompt-cache-aware: a
|
|
136
|
+
* candidate is pruned now only when the suffix after it is small (tail case —
|
|
137
|
+
* the read→edit→read loop) or when the context has been idle long enough that
|
|
138
|
+
* the provider cache is cold anyway (then ALL candidates flush).
|
|
139
|
+
*/
|
|
140
|
+
export function pruneSupersededToolResults(entries: SessionEntry[], config: SupersedePruneConfig): PruneResult {
|
|
141
|
+
const toolCallsById = collectToolCallsById(entries);
|
|
142
|
+
const candidates = collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools);
|
|
143
|
+
if (candidates.length === 0) return { prunedCount: 0, tokensSaved: 0 };
|
|
144
|
+
|
|
145
|
+
const now = config.now ?? Date.now();
|
|
146
|
+
let lastMessageTimestamp: number | undefined;
|
|
147
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
148
|
+
const entry = entries[i];
|
|
149
|
+
if (entry.type !== "message") continue;
|
|
150
|
+
const timestamp = (entry.message as AgentMessage).timestamp;
|
|
151
|
+
if (typeof timestamp === "number") lastMessageTimestamp = timestamp;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const idle =
|
|
155
|
+
lastMessageTimestamp !== undefined && now - lastMessageTimestamp >= (config.idleFlushMs ?? DEFAULT_IDLE_FLUSH_MS);
|
|
156
|
+
|
|
157
|
+
let toPrune: SupersedeCandidate[];
|
|
158
|
+
if (idle) {
|
|
159
|
+
toPrune = candidates;
|
|
160
|
+
} else {
|
|
161
|
+
const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
|
|
162
|
+
// suffixTokens[i] = estimated tokens of all messages strictly after entry i.
|
|
163
|
+
const suffixTokens = new Array<number>(entries.length);
|
|
164
|
+
let accumulated = 0;
|
|
165
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
166
|
+
suffixTokens[i] = accumulated;
|
|
167
|
+
const entry = entries[i];
|
|
168
|
+
if (entry.type === "message") accumulated += estimateTokens(entry.message as AgentMessage);
|
|
169
|
+
}
|
|
170
|
+
toPrune = candidates.filter(candidate => suffixTokens[candidate.index] <= suffixTokenLimit);
|
|
171
|
+
}
|
|
172
|
+
if (toPrune.length === 0) return { prunedCount: 0, tokensSaved: 0 };
|
|
173
|
+
|
|
174
|
+
const prunedAt = Date.now();
|
|
175
|
+
let tokensSaved = 0;
|
|
176
|
+
for (const candidate of toPrune) {
|
|
177
|
+
candidate.message.content = [{ type: "text", text: SUPERSEDED_NOTICE }];
|
|
178
|
+
candidate.message.prunedAt = prunedAt;
|
|
179
|
+
tokensSaved += estimatePrunedSavings(candidate.tokens, SUPERSEDED_NOTICE);
|
|
180
|
+
}
|
|
181
|
+
return { prunedCount: toPrune.length, tokensSaved };
|
|
182
|
+
}
|
|
183
|
+
|
|
52
184
|
export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
53
185
|
let accumulatedTokens = 0;
|
|
54
186
|
let tokensSaved = 0;
|
|
55
187
|
let prunedCount = 0;
|
|
56
188
|
|
|
57
|
-
const candidates: Array<{ entry: SessionMessageEntry; tokens: number }> = [];
|
|
189
|
+
const candidates: Array<{ entry: SessionMessageEntry; tokens: number; superseded: boolean }> = [];
|
|
58
190
|
const toolCallsById = collectToolCallsById(entries);
|
|
191
|
+
const supersededMessages = config.supersedeKey
|
|
192
|
+
? new Set(
|
|
193
|
+
collectSupersededResults(entries, toolCallsById, config.supersedeKey, config.protectedTools).map(
|
|
194
|
+
candidate => candidate.message,
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
: undefined;
|
|
59
198
|
|
|
60
199
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
61
200
|
const entry = entries[i];
|
|
@@ -70,17 +209,23 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
|
|
|
70
209
|
continue;
|
|
71
210
|
}
|
|
72
211
|
|
|
73
|
-
|
|
212
|
+
// Superseded results are pruned first: they bypass the protect window
|
|
213
|
+
// (a stale copy of re-read content is dead weight at any age).
|
|
214
|
+
const superseded = supersededMessages?.has(message) ?? false;
|
|
215
|
+
if (!superseded && (accumulatedTokens < config.protectTokens || isProtected)) {
|
|
74
216
|
accumulatedTokens += tokens;
|
|
75
217
|
continue;
|
|
76
218
|
}
|
|
77
219
|
|
|
78
|
-
candidates.push({ entry: entry as SessionMessageEntry, tokens });
|
|
220
|
+
candidates.push({ entry: entry as SessionMessageEntry, tokens, superseded });
|
|
79
221
|
accumulatedTokens += tokens;
|
|
80
222
|
}
|
|
81
223
|
|
|
82
224
|
for (const candidate of candidates) {
|
|
83
|
-
tokensSaved += estimatePrunedSavings(
|
|
225
|
+
tokensSaved += estimatePrunedSavings(
|
|
226
|
+
candidate.tokens,
|
|
227
|
+
candidate.superseded ? SUPERSEDED_NOTICE : createPrunedNotice(candidate.tokens),
|
|
228
|
+
);
|
|
84
229
|
}
|
|
85
230
|
|
|
86
231
|
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
@@ -90,10 +235,31 @@ export function pruneToolOutputs(entries: SessionEntry[], config: PruneConfig =
|
|
|
90
235
|
const prunedAt = Date.now();
|
|
91
236
|
for (const candidate of candidates) {
|
|
92
237
|
const message = candidate.entry.message as ToolResultMessage;
|
|
93
|
-
message.content = [
|
|
238
|
+
message.content = [
|
|
239
|
+
{ type: "text", text: candidate.superseded ? SUPERSEDED_NOTICE : createPrunedNotice(candidate.tokens) },
|
|
240
|
+
];
|
|
94
241
|
message.prunedAt = prunedAt;
|
|
95
242
|
prunedCount++;
|
|
96
243
|
}
|
|
97
244
|
|
|
98
245
|
return { prunedCount, tokensSaved };
|
|
99
246
|
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Supersede key for the `read` tool: the file path with the trailing line/raw
|
|
250
|
+
* selector stripped (the read tool's own splitter grammar via
|
|
251
|
+
* {@link splitReadSelector}, e.g. `src/foo.ts:50-200`, `:2-4:raw`).
|
|
252
|
+
* Internal/URL-scheme paths (`skill://…`, `https://…`) are exempt.
|
|
253
|
+
* Selector-free reads key on the bare path; selector-carrying reads key on
|
|
254
|
+
* `path + "\u0000" + selector`, so two reads collide only when the newer is
|
|
255
|
+
* selector-free or the selectors are identical (the pass's prefix rule lets a
|
|
256
|
+
* bare-path read supersede selector-carrying reads of the same file).
|
|
257
|
+
*/
|
|
258
|
+
export function readToolSupersedeKey(toolName: string, args: Record<string, unknown>): string | undefined {
|
|
259
|
+
if (toolName !== "read") return undefined;
|
|
260
|
+
const path = args.path;
|
|
261
|
+
if (typeof path !== "string" || path.length === 0) return undefined;
|
|
262
|
+
if (path.includes("://")) return undefined;
|
|
263
|
+
const { path: base, sel } = splitReadSelector(path);
|
|
264
|
+
return sel === undefined ? base : `${base}\u0000${sel}`;
|
|
265
|
+
}
|
package/src/compaction/utils.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { Message } from "@oh-my-pi/pi-ai";
|
|
6
|
-
import { prompt } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
|
|
7
7
|
import type { AgentMessage } from "../types";
|
|
8
8
|
import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
|
|
9
9
|
import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
|
|
@@ -26,6 +26,55 @@ export function createFileOps(): FileOperations {
|
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Read-tool selector grammar, mirrored from the conservative filesystem splitter in
|
|
30
|
+
// packages/coding-agent/src/tools/path-utils.ts (splitPathAndSel). Keep in sync.
|
|
31
|
+
// A trailing `:chunk` is a selector only when it is a line-range list
|
|
32
|
+
// (`50`, `50-200`, `50+10`, `5-16,960-973`, `..` alias), `raw`, or `conflicts` —
|
|
33
|
+
// alone or as a `range:raw` / `raw:range` compound.
|
|
34
|
+
const RANGE_CHUNK_SRC = String.raw`L?\d+(?:(?:[-+]|\.\.)L?\d+|-|\.\.)?`;
|
|
35
|
+
const RANGE_LIST_SRC = `${RANGE_CHUNK_SRC}(?:,${RANGE_CHUNK_SRC})*`;
|
|
36
|
+
const READ_SELECTOR_RE = new RegExp(`^(?:${RANGE_LIST_SRC}|raw|conflicts)$`, "i");
|
|
37
|
+
const READ_RANGE_ONLY_RE = new RegExp(`^${RANGE_LIST_SRC}$`, "i");
|
|
38
|
+
const READ_RAW_ONLY_RE = /^raw$/i;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a read-tool path into its base path and trailing selector, mirroring the
|
|
42
|
+
* read tool's own splitter. Single source of the grammar in this package: the
|
|
43
|
+
* file-operations list strips selectors via {@link stripReadSelector}, and the
|
|
44
|
+
* supersede-prune pass keys on both parts via `readToolSupersedeKey`.
|
|
45
|
+
*/
|
|
46
|
+
export function splitReadSelector(path: string): { path: string; sel?: string } {
|
|
47
|
+
const colon = path.lastIndexOf(":");
|
|
48
|
+
if (colon <= 0) return { path };
|
|
49
|
+
const candidate = path.slice(colon + 1);
|
|
50
|
+
if (!READ_SELECTOR_RE.test(candidate)) return { path };
|
|
51
|
+
let base = path.slice(0, colon);
|
|
52
|
+
let sel = candidate;
|
|
53
|
+
// Compound trailing selector: `path:1-50:raw` or `path:raw:1-50`.
|
|
54
|
+
const inner = base.lastIndexOf(":");
|
|
55
|
+
if (inner > 0) {
|
|
56
|
+
const innerCandidate = base.slice(inner + 1);
|
|
57
|
+
const innerIsRaw = READ_RAW_ONLY_RE.test(innerCandidate);
|
|
58
|
+
const outerIsRaw = READ_RAW_ONLY_RE.test(candidate);
|
|
59
|
+
const innerIsRange = READ_RANGE_ONLY_RE.test(innerCandidate);
|
|
60
|
+
const outerIsRange = READ_RANGE_ONLY_RE.test(candidate);
|
|
61
|
+
if ((innerIsRaw && outerIsRange) || (innerIsRange && outerIsRaw)) {
|
|
62
|
+
sel = `${innerCandidate}:${candidate}`;
|
|
63
|
+
base = base.slice(0, inner);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { path: base, sel };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Strip a trailing read-tool selector (`:50-200`, `:raw`, `:1-50:raw`, `:conflicts`, …)
|
|
71
|
+
* so the same file read with different line ranges dedupes to one `<files>` entry
|
|
72
|
+
* and matches its write/edit path when computing Read/Write/RW markers.
|
|
73
|
+
*/
|
|
74
|
+
export function stripReadSelector(path: string): string {
|
|
75
|
+
return splitReadSelector(path).path;
|
|
76
|
+
}
|
|
77
|
+
|
|
29
78
|
/**
|
|
30
79
|
* Extract file operations from tool calls in an assistant message.
|
|
31
80
|
*/
|
|
@@ -46,7 +95,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
|
|
|
46
95
|
|
|
47
96
|
switch (block.name) {
|
|
48
97
|
case "read":
|
|
49
|
-
fileOps.read.add(path);
|
|
98
|
+
fileOps.read.add(stripReadSelector(path));
|
|
50
99
|
break;
|
|
51
100
|
case "write":
|
|
52
101
|
fileOps.written.add(path);
|
|
@@ -70,32 +119,48 @@ export function computeFileLists(fileOps: FileOperations): { readFiles: string[]
|
|
|
70
119
|
}
|
|
71
120
|
|
|
72
121
|
/**
|
|
73
|
-
* Format file operations as
|
|
122
|
+
* Format file operations as one `<files>` tag: a grouped, prefix-folded
|
|
123
|
+
* directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
|
|
124
|
+
* ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
|
|
125
|
+
* read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
|
|
126
|
+
* used to tell modified files that were also read (RW) from blind writes.
|
|
74
127
|
*/
|
|
75
128
|
const FILE_OPERATION_SUMMARY_LIMIT = 20;
|
|
76
129
|
|
|
77
|
-
function truncateFileList(files: string[]): string[] {
|
|
78
|
-
if (files.length <= FILE_OPERATION_SUMMARY_LIMIT) return files;
|
|
79
|
-
const omitted = files.length - FILE_OPERATION_SUMMARY_LIMIT;
|
|
80
|
-
return [...files.slice(0, FILE_OPERATION_SUMMARY_LIMIT), `… (${omitted} more files omitted)`];
|
|
81
|
-
}
|
|
82
|
-
|
|
83
130
|
function stripFileOperationTags(summary: string): string {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return
|
|
131
|
+
// Legacy <read-files>/<modified-files> tags are still stripped so summaries
|
|
132
|
+
// written before the combined <files> tag self-heal on the next compaction.
|
|
133
|
+
return summary
|
|
134
|
+
.replace(/<files>[\s\S]*?<\/files>\s*/g, "")
|
|
135
|
+
.replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
|
|
136
|
+
.replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
|
|
137
|
+
.trimEnd();
|
|
87
138
|
}
|
|
88
|
-
export function formatFileOperations(
|
|
139
|
+
export function formatFileOperations(
|
|
140
|
+
readFiles: string[],
|
|
141
|
+
modifiedFiles: string[],
|
|
142
|
+
readSet?: ReadonlySet<string>,
|
|
143
|
+
): string {
|
|
89
144
|
if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
145
|
+
const mode = new Map<string, "Read" | "Write" | "RW">();
|
|
146
|
+
for (const file of readFiles) mode.set(file, "Read");
|
|
147
|
+
for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
|
|
148
|
+
const all = [...mode.keys()].sort();
|
|
149
|
+
let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
|
|
150
|
+
if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
|
|
151
|
+
files += `\n… (${all.length - FILE_OPERATION_SUMMARY_LIMIT} more files omitted)`;
|
|
152
|
+
}
|
|
153
|
+
return prompt.render(fileOperationsTemplate, { files });
|
|
94
154
|
}
|
|
95
155
|
|
|
96
|
-
export function upsertFileOperations(
|
|
156
|
+
export function upsertFileOperations(
|
|
157
|
+
summary: string,
|
|
158
|
+
readFiles: string[],
|
|
159
|
+
modifiedFiles: string[],
|
|
160
|
+
readSet?: ReadonlySet<string>,
|
|
161
|
+
): string {
|
|
97
162
|
const baseSummary = stripFileOperationTags(summary);
|
|
98
|
-
const fileOperations = formatFileOperations(readFiles, modifiedFiles);
|
|
163
|
+
const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
|
|
99
164
|
if (!fileOperations) return baseSummary;
|
|
100
165
|
if (!baseSummary) return fileOperations;
|
|
101
166
|
return `${baseSummary}\n\n${fileOperations}`;
|