@oh-my-pi/pi-agent-core 18.8.4 → 18.8.6
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 +7 -0
- package/dist/types/agent.d.ts +7 -1
- package/dist/types/compaction/messages.d.ts +6 -0
- package/dist/types/compaction/pruning.d.ts +27 -4
- package/dist/types/types.d.ts +12 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +13 -7
- package/src/agent.ts +8 -1
- package/src/compaction/compaction.ts +1 -30
- package/src/compaction/messages.ts +25 -0
- package/src/compaction/pruning.ts +146 -11
- package/src/types.ts +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.8.6] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added support for warm-cache-aware conversation pruning, keeping pruned history within the model’s prompt-cache lookback window while preserving Anthropic prompt-cache efficiency.
|
|
10
|
+
- Added `AgentLoopConfig.hasQueuedAsides` (also available on `Agent`) to allow interruptible waits to detect queued asides without consuming them or signaling other tools.
|
|
11
|
+
|
|
5
12
|
## [18.8.1] - 2026-10-07
|
|
6
13
|
|
|
7
14
|
### Added
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -270,6 +270,11 @@ export declare class Agent {
|
|
|
270
270
|
* are queued for the next boundary.
|
|
271
271
|
*/
|
|
272
272
|
hasBackgroundCompletions?: AgentLoopConfig["hasBackgroundCompletions"];
|
|
273
|
+
/**
|
|
274
|
+
* Hook that peeks whether a passive aside is queued for the next boundary;
|
|
275
|
+
* ends an interruptible wait without interrupting other tools.
|
|
276
|
+
*/
|
|
277
|
+
hasQueuedAsides?: AgentLoopConfig["hasQueuedAsides"];
|
|
273
278
|
constructor(opts?: AgentOptions);
|
|
274
279
|
/**
|
|
275
280
|
* Get the current session ID used for provider caching.
|
|
@@ -457,7 +462,8 @@ export declare class Agent {
|
|
|
457
462
|
/**
|
|
458
463
|
* Provide a source of non-interrupting "aside" messages (e.g. background-job
|
|
459
464
|
* completions, late LSP diagnostics) drained at each step boundary. Never
|
|
460
|
-
* aborts
|
|
465
|
+
* aborts foreground tools; an interruptible `wait` may end early through the
|
|
466
|
+
* peek hooks. See `AgentLoopConfig.getAsideMessages`.
|
|
461
467
|
*/
|
|
462
468
|
setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
|
|
463
469
|
emitExternalEvent(event: AgentEvent): void;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ImageContent, Message, MessageAttribution, ProviderPayload, TextContent } from "@oh-my-pi/pi-ai";
|
|
2
2
|
import type { AgentMessage } from "../types.js";
|
|
3
|
+
import type { SessionEntry } from "./entries.js";
|
|
3
4
|
export interface CustomMessage<T = unknown> {
|
|
4
5
|
role: "custom";
|
|
5
6
|
customType: string;
|
|
@@ -110,3 +111,8 @@ export declare function convertMessageToLlm(message: AgentMessage): Message | un
|
|
|
110
111
|
* core LLM roles and the compaction messages owned by this package.
|
|
111
112
|
*/
|
|
112
113
|
export declare function defaultConvertToLlm(messages: AgentMessage[]): Message[];
|
|
114
|
+
/**
|
|
115
|
+
* The context message a session entry contributes, or `undefined` for entries
|
|
116
|
+
* that don't reach the LLM (compaction markers, labels, model changes, ...).
|
|
117
|
+
*/
|
|
118
|
+
export declare function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined;
|
|
@@ -4,8 +4,26 @@
|
|
|
4
4
|
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
5
5
|
import type { Tokenizer } from "../tokenizer.js";
|
|
6
6
|
import type { SessionEntry } from "./entries.js";
|
|
7
|
+
import { type ConvertToLlm } from "./messages.js";
|
|
7
8
|
import { type ProtectedToolMatcher } from "./tool-protection.js";
|
|
8
|
-
|
|
9
|
+
/** Bound warm-cache pruning by the provider's prompt-cache lookback window. */
|
|
10
|
+
export interface CacheLookbackConfig {
|
|
11
|
+
/**
|
|
12
|
+
* Prompt-cache lookback of the model the next request goes to, in block
|
|
13
|
+
* positions (catalog `prompt-cache-lookback`; Anthropic: 20). When set,
|
|
14
|
+
* warm-cache pruning also leaves a result whose rewrite would put the next
|
|
15
|
+
* request's cache lookup out of reach, whatever its token suffix (see
|
|
16
|
+
* `cacheLookbackFloor`). Undefined = no lookback bound.
|
|
17
|
+
*/
|
|
18
|
+
cacheLookbackPositions?: number;
|
|
19
|
+
/**
|
|
20
|
+
* Projects context messages to what the provider receives, so lookback
|
|
21
|
+
* positions count app roles (file mentions, command output, custom
|
|
22
|
+
* messages) as sent. Default {@link defaultConvertToLlm}.
|
|
23
|
+
*/
|
|
24
|
+
convertToLlm?: ConvertToLlm;
|
|
25
|
+
}
|
|
26
|
+
export interface PruneConfig extends CacheLookbackConfig {
|
|
9
27
|
/** Keep the most recent tool output tokens intact. */
|
|
10
28
|
protectTokens: number;
|
|
11
29
|
/** Only prune if total savings meets this threshold. */
|
|
@@ -37,7 +55,8 @@ export interface PruneConfig {
|
|
|
37
55
|
* already-sent cache prefix: mutating it forces the provider to re-write the
|
|
38
56
|
* whole suffix (cacheWrite premium). Such results — including superseded and
|
|
39
57
|
* useless ones, which otherwise bypass {@link protectTokens} — are left for
|
|
40
|
-
* compaction/shake (which rebuild the cache anyway) to reclaim
|
|
58
|
+
* compaction/shake (which rebuild the cache anyway) to reclaim, as are
|
|
59
|
+
* results beyond {@link CacheLookbackConfig.cacheLookbackPositions}. Undefined =
|
|
41
60
|
* no cache guard (legacy: superseded/useless prune at any depth).
|
|
42
61
|
*/
|
|
43
62
|
cacheWarmSuffixTokens?: number;
|
|
@@ -72,7 +91,7 @@ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) =
|
|
|
72
91
|
* range reads). A summary, truncated page, or notice is not complete.
|
|
73
92
|
*/
|
|
74
93
|
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
75
|
-
export interface SupersedePruneConfig {
|
|
94
|
+
export interface SupersedePruneConfig extends CacheLookbackConfig {
|
|
76
95
|
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
77
96
|
supersedeKey?: SupersedeKeyFn;
|
|
78
97
|
/**
|
|
@@ -90,7 +109,11 @@ export interface SupersedePruneConfig {
|
|
|
90
109
|
supersedeComplete?: SupersedeCompleteFn;
|
|
91
110
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
92
111
|
pruneUseless?: boolean;
|
|
93
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Prune a candidate now when all messages after it total at most this many
|
|
114
|
+
* estimated tokens and it sits within
|
|
115
|
+
* {@link CacheLookbackConfig.cacheLookbackPositions}. Default 8 000.
|
|
116
|
+
*/
|
|
94
117
|
suffixTokenLimit?: number;
|
|
95
118
|
/**
|
|
96
119
|
* Prune all candidates when the last message is at least this old: the
|
package/dist/types/types.d.ts
CHANGED
|
@@ -269,6 +269,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
269
269
|
* background.
|
|
270
270
|
*/
|
|
271
271
|
hasBackgroundCompletions?: () => boolean | Promise<boolean>;
|
|
272
|
+
/**
|
|
273
|
+
* Peeks whether a passive aside (an extension or user message queued with
|
|
274
|
+
* `deliverAs: "aside"`) is waiting for injection at the next boundary.
|
|
275
|
+
*
|
|
276
|
+
* Same rules as {@link hasBackgroundCompletions}: non-consuming, ends only
|
|
277
|
+
* *interruptible* waits, never raises {@link ToolCallContext.steeringSignal}.
|
|
278
|
+
* The host still injects the aside once at the boundary; ordinary running
|
|
279
|
+
* tools finish first.
|
|
280
|
+
*/
|
|
281
|
+
hasQueuedAsides?: () => boolean | Promise<boolean>;
|
|
272
282
|
/**
|
|
273
283
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
274
284
|
*
|
|
@@ -281,7 +291,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
281
291
|
* Returns non-interrupting "aside" messages to inject at a step boundary.
|
|
282
292
|
*
|
|
283
293
|
* Polled after each tool batch (before the next LLM call) AND at the yield
|
|
284
|
-
* check. Unlike steering, these
|
|
294
|
+
* check. Unlike steering, these never abort foreground tools (only an
|
|
295
|
+
* interruptible `wait` ends early, via the peek hooks) — they are passive
|
|
285
296
|
* notifications (e.g. background-job completions, late LSP diagnostics) that
|
|
286
297
|
* should reach the model between requests without waiting for the agent to
|
|
287
298
|
* fully stop. Returned messages are appended to the context with normal
|
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.8.
|
|
4
|
+
"version": "18.8.6",
|
|
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.8.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.8.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.8.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.8.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.8.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.8.
|
|
41
|
+
"@oh-my-pi/pi-ai": "18.8.6",
|
|
42
|
+
"@oh-my-pi/pi-catalog": "18.8.6",
|
|
43
|
+
"@oh-my-pi/pi-natives": "18.8.6",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.8.6",
|
|
45
|
+
"@oh-my-pi/pi-wire": "18.8.6",
|
|
46
|
+
"@oh-my-pi/snapcompact": "18.8.6",
|
|
47
47
|
"@opentelemetry/api": "^1.9.1"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@oh-my-pi/omptype": "18.8.
|
|
50
|
+
"@oh-my-pi/omptype": "18.8.6",
|
|
51
51
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
52
52
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
53
53
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -3204,6 +3204,7 @@ async function executeToolCalls(
|
|
|
3204
3204
|
hasSteeringMessages,
|
|
3205
3205
|
hasIrcInterrupts,
|
|
3206
3206
|
hasBackgroundCompletions,
|
|
3207
|
+
hasQueuedAsides,
|
|
3207
3208
|
interruptMode = "immediate",
|
|
3208
3209
|
getToolContext,
|
|
3209
3210
|
|
|
@@ -3305,11 +3306,12 @@ async function executeToolCalls(
|
|
|
3305
3306
|
// (only when soft interrupts are enabled; otherwise nothing is left).
|
|
3306
3307
|
if (signal?.aborted) return;
|
|
3307
3308
|
if (interruptState.triggered && (!softInterrupts || steeringSoftController.signal.aborted)) return;
|
|
3308
|
-
// Peer IRC and background completions (finished jobs,
|
|
3309
|
-
// processes) hard-abort interruptible waits only;
|
|
3310
|
-
// running (no partial side effects).
|
|
3309
|
+
// Peer IRC, queued asides and background completions (finished jobs,
|
|
3310
|
+
// exited supervised processes) hard-abort interruptible waits only;
|
|
3311
|
+
// foreground tools keep running (no partial side effects).
|
|
3311
3312
|
let source: AsideInterruptSource | undefined;
|
|
3312
3313
|
if (hasIrcInterrupts && (await hasIrcInterrupts())) source = "irc";
|
|
3314
|
+
else if (!interruptState.triggered && hasQueuedAsides && (await hasQueuedAsides())) source = "aside";
|
|
3313
3315
|
else if (!interruptState.triggered && hasBackgroundCompletions && (await hasBackgroundCompletions()))
|
|
3314
3316
|
source = "background";
|
|
3315
3317
|
if (!source) return;
|
|
@@ -3326,7 +3328,7 @@ async function executeToolCalls(
|
|
|
3326
3328
|
// also cascades: the freshly detached job's own completion re-triggers
|
|
3327
3329
|
// this check for the next command, so millisecond-long commands chain
|
|
3328
3330
|
// into separate background deliveries (#12869).
|
|
3329
|
-
if (source
|
|
3331
|
+
if (source === "irc" && softInterrupts) steeringSoftController.abort();
|
|
3330
3332
|
};
|
|
3331
3333
|
|
|
3332
3334
|
const checkSteering = async (): Promise<void> => {
|
|
@@ -3697,7 +3699,8 @@ async function executeToolCalls(
|
|
|
3697
3699
|
// (auto-background bash), so the boundary dequeue below injects the message
|
|
3698
3700
|
// promptly. In "wait" mode only a batch holding an interruptible wait needs
|
|
3699
3701
|
// the watch; checkSteering is idempotent (no-op once triggered).
|
|
3700
|
-
const hasAsidePeek =
|
|
3702
|
+
const hasAsidePeek =
|
|
3703
|
+
hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined || hasQueuedAsides !== undefined;
|
|
3701
3704
|
const watchSteeringWhileRunning =
|
|
3702
3705
|
(softInterrupts || records.some(record => record.interruptible)) &&
|
|
3703
3706
|
(hasSteeringMessages !== undefined || hasAsidePeek);
|
|
@@ -3983,8 +3986,8 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
|
|
|
3983
3986
|
};
|
|
3984
3987
|
}
|
|
3985
3988
|
|
|
3986
|
-
/** Origin of a mid-batch interrupt: queued steering, a peer IRC, or a background completion notice. */
|
|
3987
|
-
type AsideInterruptSource = SteeringInterruptSource | "irc" | "background";
|
|
3989
|
+
/** Origin of a mid-batch interrupt: queued steering, a peer IRC, a queued aside, or a background completion notice. */
|
|
3990
|
+
type AsideInterruptSource = SteeringInterruptSource | "irc" | "aside" | "background";
|
|
3988
3991
|
|
|
3989
3992
|
function createSkippedToolResult(
|
|
3990
3993
|
source: AsideInterruptSource | undefined,
|
|
@@ -4004,6 +4007,9 @@ function createSkippedToolResult(
|
|
|
4004
4007
|
} else if (source === "irc") {
|
|
4005
4008
|
reason = "pending peer interrupt";
|
|
4006
4009
|
blocker = "interrupt";
|
|
4010
|
+
} else if (source === "aside") {
|
|
4011
|
+
reason = "a queued aside message";
|
|
4012
|
+
blocker = "aside";
|
|
4007
4013
|
} else if (source === "background") {
|
|
4008
4014
|
reason = "a queued background completion (job or supervised process)";
|
|
4009
4015
|
blocker = "completion notice";
|
package/src/agent.ts
CHANGED
|
@@ -539,6 +539,11 @@ export class Agent {
|
|
|
539
539
|
* are queued for the next boundary.
|
|
540
540
|
*/
|
|
541
541
|
hasBackgroundCompletions?: AgentLoopConfig["hasBackgroundCompletions"];
|
|
542
|
+
/**
|
|
543
|
+
* Hook that peeks whether a passive aside is queued for the next boundary;
|
|
544
|
+
* ends an interruptible wait without interrupting other tools.
|
|
545
|
+
*/
|
|
546
|
+
hasQueuedAsides?: AgentLoopConfig["hasQueuedAsides"];
|
|
542
547
|
|
|
543
548
|
constructor(opts: AgentOptions = {}) {
|
|
544
549
|
this.#state = { ...this.#state, ...opts.initialState };
|
|
@@ -1174,7 +1179,8 @@ export class Agent {
|
|
|
1174
1179
|
/**
|
|
1175
1180
|
* Provide a source of non-interrupting "aside" messages (e.g. background-job
|
|
1176
1181
|
* completions, late LSP diagnostics) drained at each step boundary. Never
|
|
1177
|
-
* aborts
|
|
1182
|
+
* aborts foreground tools; an interruptible `wait` may end early through the
|
|
1183
|
+
* peek hooks. See `AgentLoopConfig.getAsideMessages`.
|
|
1178
1184
|
*/
|
|
1179
1185
|
setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
|
|
1180
1186
|
this.#asideMessageProvider = fn;
|
|
@@ -1913,6 +1919,7 @@ export class Agent {
|
|
|
1913
1919
|
onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
|
|
1914
1920
|
hasIrcInterrupts: this.hasIrcInterrupts,
|
|
1915
1921
|
hasBackgroundCompletions: this.hasBackgroundCompletions,
|
|
1922
|
+
hasQueuedAsides: this.hasQueuedAsides,
|
|
1916
1923
|
getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
|
|
1917
1924
|
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
|
1918
1925
|
onBeforeYield: () => this.#onBeforeYield?.(),
|
|
@@ -64,10 +64,9 @@ import type { CompactionEntry, SessionEntry } from "./entries";
|
|
|
64
64
|
import { NativeCompactionError } from "./errors";
|
|
65
65
|
import {
|
|
66
66
|
type ConvertToLlm,
|
|
67
|
-
createBranchSummaryMessage,
|
|
68
67
|
createCompactionSummaryMessage,
|
|
69
|
-
createCustomMessage,
|
|
70
68
|
defaultConvertToLlm,
|
|
69
|
+
getMessageFromEntry,
|
|
71
70
|
} from "./messages";
|
|
72
71
|
import {
|
|
73
72
|
assertRemoteCompactionInputFits,
|
|
@@ -142,34 +141,6 @@ function extractFileOperations(
|
|
|
142
141
|
return fileOps;
|
|
143
142
|
}
|
|
144
143
|
|
|
145
|
-
// ============================================================================
|
|
146
|
-
// Message Extraction
|
|
147
|
-
// ============================================================================
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Extract AgentMessage from an entry if it produces one.
|
|
151
|
-
* Returns undefined for entries that don't contribute to LLM context.
|
|
152
|
-
*/
|
|
153
|
-
function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
154
|
-
if (entry.type === "message") {
|
|
155
|
-
return entry.message;
|
|
156
|
-
}
|
|
157
|
-
if (entry.type === "custom_message") {
|
|
158
|
-
return createCustomMessage(
|
|
159
|
-
entry.customType,
|
|
160
|
-
entry.content,
|
|
161
|
-
entry.display,
|
|
162
|
-
entry.details,
|
|
163
|
-
entry.timestamp,
|
|
164
|
-
entry.attribution,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
if (entry.type === "branch_summary") {
|
|
168
|
-
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
|
169
|
-
}
|
|
170
|
-
return undefined;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
144
|
/** Result from compact() - SessionManager adds uuid/parentUuid when saving */
|
|
174
145
|
export interface CompactionResult<T = unknown> {
|
|
175
146
|
summary: string;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "@oh-my-pi/pi-ai";
|
|
9
9
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import type { AgentMessage } from "../types";
|
|
11
|
+
import type { SessionEntry } from "./entries";
|
|
11
12
|
import branchSummaryContextPrompt from "./prompts/branch-summary-context.md" with { type: "text" };
|
|
12
13
|
import compactionSummaryContextPrompt from "./prompts/compaction-summary-context.md" with { type: "text" };
|
|
13
14
|
import handoffSummaryContextPrompt from "./prompts/handoff-summary-context.md" with { type: "text" };
|
|
@@ -291,3 +292,27 @@ export function convertMessageToLlm(message: AgentMessage): Message | undefined
|
|
|
291
292
|
export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
|
|
292
293
|
return messages.map(convertMessageToLlm).filter(message => message !== undefined);
|
|
293
294
|
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The context message a session entry contributes, or `undefined` for entries
|
|
298
|
+
* that don't reach the LLM (compaction markers, labels, model changes, ...).
|
|
299
|
+
*/
|
|
300
|
+
export function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
301
|
+
if (entry.type === "message") {
|
|
302
|
+
return entry.message;
|
|
303
|
+
}
|
|
304
|
+
if (entry.type === "custom_message") {
|
|
305
|
+
return createCustomMessage(
|
|
306
|
+
entry.customType,
|
|
307
|
+
entry.content,
|
|
308
|
+
entry.display,
|
|
309
|
+
entry.details,
|
|
310
|
+
entry.timestamp,
|
|
311
|
+
entry.attribution,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
if (entry.type === "branch_summary") {
|
|
315
|
+
return createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp);
|
|
316
|
+
}
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { Tokenizer } from "../tokenizer";
|
|
|
7
7
|
import type { AgentMessage, AgentToolCall } from "../types";
|
|
8
8
|
import type { SessionEntry, SessionMessageEntry } from "./entries";
|
|
9
9
|
import { invalidateMessageCache } from "./message-cache";
|
|
10
|
+
import { type ConvertToLlm, defaultConvertToLlm, getMessageFromEntry } from "./messages";
|
|
10
11
|
import {
|
|
11
12
|
collectToolCallsById,
|
|
12
13
|
getToolResultMessage,
|
|
@@ -16,7 +17,25 @@ import {
|
|
|
16
17
|
} from "./tool-protection";
|
|
17
18
|
import { isAlternateFormReadSelector, splitReadSelector } from "./utils";
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
/** Bound warm-cache pruning by the provider's prompt-cache lookback window. */
|
|
21
|
+
export interface CacheLookbackConfig {
|
|
22
|
+
/**
|
|
23
|
+
* Prompt-cache lookback of the model the next request goes to, in block
|
|
24
|
+
* positions (catalog `prompt-cache-lookback`; Anthropic: 20). When set,
|
|
25
|
+
* warm-cache pruning also leaves a result whose rewrite would put the next
|
|
26
|
+
* request's cache lookup out of reach, whatever its token suffix (see
|
|
27
|
+
* `cacheLookbackFloor`). Undefined = no lookback bound.
|
|
28
|
+
*/
|
|
29
|
+
cacheLookbackPositions?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Projects context messages to what the provider receives, so lookback
|
|
32
|
+
* positions count app roles (file mentions, command output, custom
|
|
33
|
+
* messages) as sent. Default {@link defaultConvertToLlm}.
|
|
34
|
+
*/
|
|
35
|
+
convertToLlm?: ConvertToLlm;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PruneConfig extends CacheLookbackConfig {
|
|
20
39
|
/** Keep the most recent tool output tokens intact. */
|
|
21
40
|
protectTokens: number;
|
|
22
41
|
/** Only prune if total savings meets this threshold. */
|
|
@@ -48,7 +67,8 @@ export interface PruneConfig {
|
|
|
48
67
|
* already-sent cache prefix: mutating it forces the provider to re-write the
|
|
49
68
|
* whole suffix (cacheWrite premium). Such results — including superseded and
|
|
50
69
|
* useless ones, which otherwise bypass {@link protectTokens} — are left for
|
|
51
|
-
* compaction/shake (which rebuild the cache anyway) to reclaim
|
|
70
|
+
* compaction/shake (which rebuild the cache anyway) to reclaim, as are
|
|
71
|
+
* results beyond {@link CacheLookbackConfig.cacheLookbackPositions}. Undefined =
|
|
52
72
|
* no cache guard (legacy: superseded/useless prune at any depth).
|
|
53
73
|
*/
|
|
54
74
|
cacheWarmSuffixTokens?: number;
|
|
@@ -117,7 +137,7 @@ export type SupersedeKeyFn = (toolName: string, args: Record<string, unknown>) =
|
|
|
117
137
|
*/
|
|
118
138
|
export type SupersedeCompleteFn = (message: ToolResultMessage) => boolean;
|
|
119
139
|
|
|
120
|
-
export interface SupersedePruneConfig {
|
|
140
|
+
export interface SupersedePruneConfig extends CacheLookbackConfig {
|
|
121
141
|
/** Supersede key function; a newer successful result with the same key supersedes older ones (see {@link SupersedeKeyFn}). */
|
|
122
142
|
supersedeKey?: SupersedeKeyFn;
|
|
123
143
|
/**
|
|
@@ -135,7 +155,11 @@ export interface SupersedePruneConfig {
|
|
|
135
155
|
supersedeComplete?: SupersedeCompleteFn;
|
|
136
156
|
/** Also prune results flagged useless by their tool. Default false. */
|
|
137
157
|
pruneUseless?: boolean;
|
|
138
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Prune a candidate now when all messages after it total at most this many
|
|
160
|
+
* estimated tokens and it sits within
|
|
161
|
+
* {@link CacheLookbackConfig.cacheLookbackPositions}. Default 8 000.
|
|
162
|
+
*/
|
|
139
163
|
suffixTokenLimit?: number;
|
|
140
164
|
/**
|
|
141
165
|
* Prune all candidates when the last message is at least this old: the
|
|
@@ -160,6 +184,99 @@ export interface SupersedePruneConfig {
|
|
|
160
184
|
const DEFAULT_SUFFIX_TOKEN_LIMIT = 8_000;
|
|
161
185
|
const DEFAULT_IDLE_FLUSH_MS = 30 * 60_000;
|
|
162
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Positions kept free for the next request's own input (prompt, date/cwd
|
|
189
|
+
* reminder, attachments) and for request projections the count below does not
|
|
190
|
+
* model (orphan-result notes, replayed compaction file metadata, tool-change
|
|
191
|
+
* controls): with Anthropic's 20, stored history gets at most 13.
|
|
192
|
+
*/
|
|
193
|
+
const RESERVED_LOOKBACK_POSITIONS = 6;
|
|
194
|
+
|
|
195
|
+
type LookbackBlock = "tool_use" | "tool_result" | undefined;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Index of the oldest tool result that can be rewritten while the next request
|
|
199
|
+
* still reaches a cache entry, given the model's prompt-cache `lookback` in
|
|
200
|
+
* block positions (a breakpoint counts itself; a run of consecutive `tool_use`
|
|
201
|
+
* blocks, or of consecutive `tool_result` blocks, is one position). A
|
|
202
|
+
* conservative estimate, not an exact count: a skipped prune costs its tokens,
|
|
203
|
+
* a missed lookup the whole cache.
|
|
204
|
+
*
|
|
205
|
+
* Each request writes its tail cache entry at its last block, so rewriting a
|
|
206
|
+
* result invalidates every entry from its issuing assistant turn on, and once a
|
|
207
|
+
* request has 15 user turns its other breakpoint sits on an older decimation
|
|
208
|
+
* checkpoint. The next request's tail breakpoint then reaches the newest
|
|
209
|
+
* surviving entry, which ends the message before the issuing turn, only when
|
|
210
|
+
* the issuing turn, its tool-result batch, everything after and the next input
|
|
211
|
+
* fit in the lookback window; otherwise the lookup falls back to that
|
|
212
|
+
* checkpoint and the whole conversation since is re-written, not the small
|
|
213
|
+
* suffix the token limits budget for. Each entry is projected through
|
|
214
|
+
* `convertToLlm` and counted the way the Anthropic request converter emits it.
|
|
215
|
+
*/
|
|
216
|
+
function cacheLookbackFloor(
|
|
217
|
+
entries: readonly SessionEntry[],
|
|
218
|
+
start: number,
|
|
219
|
+
lookback: number | undefined,
|
|
220
|
+
convertToLlm: ConvertToLlm,
|
|
221
|
+
): number {
|
|
222
|
+
if (lookback === undefined) return start;
|
|
223
|
+
let positions = RESERVED_LOOKBACK_POSITIONS;
|
|
224
|
+
let later: LookbackBlock;
|
|
225
|
+
let runHoistsImages = false;
|
|
226
|
+
let newerIsAssistant = false;
|
|
227
|
+
const add = (block: LookbackBlock): void => {
|
|
228
|
+
if (block === undefined || block !== later) positions++;
|
|
229
|
+
later = block;
|
|
230
|
+
};
|
|
231
|
+
let floor = entries.length;
|
|
232
|
+
for (let i = entries.length - 1; i >= start; i--) {
|
|
233
|
+
const message = getMessageFromEntry(entries[i]);
|
|
234
|
+
const sent = message === undefined ? [] : convertToLlm([message]);
|
|
235
|
+
for (let m = sent.length - 1; m >= 0; m--) {
|
|
236
|
+
const llm = sent[m];
|
|
237
|
+
if (llm.role === "toolResult") {
|
|
238
|
+
if (later !== "tool_result") runHoistsImages = false;
|
|
239
|
+
add("tool_result");
|
|
240
|
+
// Anthropic rejects images in error results, so the converter moves
|
|
241
|
+
// them after the result run behind one explanatory text block.
|
|
242
|
+
let images = 0;
|
|
243
|
+
if (llm.isError) {
|
|
244
|
+
for (const block of llm.content) if (block.type === "image") images++;
|
|
245
|
+
}
|
|
246
|
+
if (images > 0) {
|
|
247
|
+
positions += runHoistsImages ? images : images + 1;
|
|
248
|
+
runHoistsImages = true;
|
|
249
|
+
}
|
|
250
|
+
newerIsAssistant = false;
|
|
251
|
+
} else if (llm.role === "assistant") {
|
|
252
|
+
// The converter pads consecutive assistant turns with a user turn and
|
|
253
|
+
// drops assistant images, blank text, and turns left empty.
|
|
254
|
+
let pad = newerIsAssistant;
|
|
255
|
+
for (let b = llm.content.length - 1; b >= 0; b--) {
|
|
256
|
+
const block = llm.content[b];
|
|
257
|
+
if (block.type === "image" || (block.type === "text" && block.text.trim().length === 0)) continue;
|
|
258
|
+
if (pad) {
|
|
259
|
+
add(undefined);
|
|
260
|
+
pad = false;
|
|
261
|
+
}
|
|
262
|
+
add(block.type === "toolCall" ? "tool_use" : undefined);
|
|
263
|
+
newerIsAssistant = true;
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
// The converter drops blank user/developer turns.
|
|
267
|
+
const blocks =
|
|
268
|
+
typeof llm.content === "string" ? (llm.content.trim().length > 0 ? 1 : 0) : llm.content.length;
|
|
269
|
+
if (blocks === 0) continue;
|
|
270
|
+
for (let b = blocks; b > 0; b--) add(undefined);
|
|
271
|
+
newerIsAssistant = false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (positions >= lookback) return floor;
|
|
275
|
+
if (message?.role === "assistant") floor = i;
|
|
276
|
+
}
|
|
277
|
+
return start;
|
|
278
|
+
}
|
|
279
|
+
|
|
163
280
|
function createPrunedNotice(tokens: number): string {
|
|
164
281
|
return `[Output truncated - ${tokens} tokens]`;
|
|
165
282
|
}
|
|
@@ -387,13 +504,20 @@ export function pruneSupersededToolResults(
|
|
|
387
504
|
} else {
|
|
388
505
|
// Mutating a candidate re-writes its suffix (tokens of every message
|
|
389
506
|
// strictly after it) in the warm cache, so prune only when that suffix is
|
|
390
|
-
// small
|
|
391
|
-
//
|
|
507
|
+
// small and the next request's cache lookup stays in reach. The suffix
|
|
508
|
+
// only grows walking back, so stop at the first index past either limit
|
|
509
|
+
// instead of measuring the whole branch.
|
|
392
510
|
const suffixTokenLimit = config.suffixTokenLimit ?? DEFAULT_SUFFIX_TOKEN_LIMIT;
|
|
511
|
+
const lookbackFloor = cacheLookbackFloor(
|
|
512
|
+
entries,
|
|
513
|
+
boundaryIndex,
|
|
514
|
+
config.cacheLookbackPositions,
|
|
515
|
+
config.convertToLlm ?? defaultConvertToLlm,
|
|
516
|
+
);
|
|
393
517
|
toPrune = [];
|
|
394
518
|
let suffixTokens = 0;
|
|
395
519
|
let next = candidates.length - 1;
|
|
396
|
-
for (let i = entries.length - 1; next >= 0 && suffixTokens <= suffixTokenLimit; i--) {
|
|
520
|
+
for (let i = entries.length - 1; next >= 0 && suffixTokens <= suffixTokenLimit && i >= lookbackFloor; i--) {
|
|
397
521
|
while (next >= 0 && candidates[next].index === i) toPrune.push(candidates[next--]);
|
|
398
522
|
const entry = entries[i];
|
|
399
523
|
if (entry.type === "message") suffixTokens += tokenizer.countMessage(entry.message as AgentMessage);
|
|
@@ -451,6 +575,15 @@ export function pruneToolOutputs(
|
|
|
451
575
|
: undefined;
|
|
452
576
|
|
|
453
577
|
const cacheWarmSuffixTokens = config.cacheWarmSuffixTokens;
|
|
578
|
+
const lookbackFloor =
|
|
579
|
+
cacheWarmSuffixTokens === undefined
|
|
580
|
+
? boundaryIndex
|
|
581
|
+
: cacheLookbackFloor(
|
|
582
|
+
entries,
|
|
583
|
+
boundaryIndex,
|
|
584
|
+
config.cacheLookbackPositions,
|
|
585
|
+
config.convertToLlm ?? defaultConvertToLlm,
|
|
586
|
+
);
|
|
454
587
|
// Tokens of every message strictly after entry `i` (cache guard only).
|
|
455
588
|
let messageSuffix = 0;
|
|
456
589
|
|
|
@@ -470,10 +603,12 @@ export function pruneToolOutputs(
|
|
|
470
603
|
|
|
471
604
|
// Prompt-cache guard: a result whose all-message suffix exceeds the
|
|
472
605
|
// warm-cache window sits in the already-sent cached prefix — mutating it
|
|
473
|
-
// re-writes the whole suffix (cacheWrite premium)
|
|
474
|
-
//
|
|
475
|
-
//
|
|
476
|
-
|
|
606
|
+
// re-writes the whole suffix (cacheWrite premium), or the whole
|
|
607
|
+
// conversation once the next request's cache lookup is out of reach.
|
|
608
|
+
// Both only worsen walking back, so every older result is in the warm
|
|
609
|
+
// prefix too. Deeper, still-cached superseded/useless copies are left for
|
|
610
|
+
// compaction/shake.
|
|
611
|
+
if (cacheWarmSuffixTokens !== undefined && (suffixAfter > cacheWarmSuffixTokens || i < lookbackFloor)) break;
|
|
477
612
|
|
|
478
613
|
if (message.prunedAt !== undefined) {
|
|
479
614
|
accumulatedTokens += tokens;
|
package/src/types.ts
CHANGED
|
@@ -328,6 +328,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
328
328
|
* background.
|
|
329
329
|
*/
|
|
330
330
|
hasBackgroundCompletions?: () => boolean | Promise<boolean>;
|
|
331
|
+
/**
|
|
332
|
+
* Peeks whether a passive aside (an extension or user message queued with
|
|
333
|
+
* `deliverAs: "aside"`) is waiting for injection at the next boundary.
|
|
334
|
+
*
|
|
335
|
+
* Same rules as {@link hasBackgroundCompletions}: non-consuming, ends only
|
|
336
|
+
* *interruptible* waits, never raises {@link ToolCallContext.steeringSignal}.
|
|
337
|
+
* The host still injects the aside once at the boundary; ordinary running
|
|
338
|
+
* tools finish first.
|
|
339
|
+
*/
|
|
340
|
+
hasQueuedAsides?: () => boolean | Promise<boolean>;
|
|
331
341
|
|
|
332
342
|
/**
|
|
333
343
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
@@ -341,7 +351,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
341
351
|
* Returns non-interrupting "aside" messages to inject at a step boundary.
|
|
342
352
|
*
|
|
343
353
|
* Polled after each tool batch (before the next LLM call) AND at the yield
|
|
344
|
-
* check. Unlike steering, these
|
|
354
|
+
* check. Unlike steering, these never abort foreground tools (only an
|
|
355
|
+
* interruptible `wait` ends early, via the peek hooks) — they are passive
|
|
345
356
|
* notifications (e.g. background-job completions, late LSP diagnostics) that
|
|
346
357
|
* should reach the model between requests without waiting for the agent to
|
|
347
358
|
* fully stop. Returned messages are appended to the context with normal
|