@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 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
@@ -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 in-flight tools. See `AgentLoopConfig.getAsideMessages`.
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
- export interface PruneConfig {
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. Undefined =
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
- /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
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
@@ -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 NEVER abort in-flight tools — they are passive
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",
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.4",
42
- "@oh-my-pi/pi-catalog": "18.8.4",
43
- "@oh-my-pi/pi-natives": "18.8.4",
44
- "@oh-my-pi/pi-utils": "18.8.4",
45
- "@oh-my-pi/pi-wire": "18.8.4",
46
- "@oh-my-pi/snapcompact": "18.8.4",
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.4",
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, exited supervised
3309
- // processes) hard-abort interruptible waits only; foreground tools keep
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 !== "background" && softInterrupts) steeringSoftController.abort();
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 = hasIrcInterrupts !== undefined || hasBackgroundCompletions !== undefined;
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 in-flight tools. See `AgentLoopConfig.getAsideMessages`.
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
- export interface PruneConfig {
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. Undefined =
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
- /** Prune a candidate now when all messages after it total at most this many estimated tokens. Default 8 000. */
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. The suffix only grows walking back, so stop at the first index
391
- // past the limit instead of measuring the whole branch.
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). The suffix only grows
474
- // walking back, so every older result is in the warm prefix too. Deeper,
475
- // still-cached superseded/useless copies are left for compaction/shake.
476
- if (cacheWarmSuffixTokens !== undefined && suffixAfter > cacheWarmSuffixTokens) break;
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 NEVER abort in-flight tools — they are passive
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