@oh-my-pi/pi-agent-core 18.12.5 → 18.13.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 CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.13.0] - 2026-10-26
6
+
7
+ ### Added
8
+
9
+ - Added `SummaryOptions.remotePromptCacheKey` and `remoteCompactionPolicyModel()` so remote compaction can carry a cache key gated against the request it actually sends ([#16988](https://github.com/can1357/oh-my-pi/pull/16988) by [@rafalopilowski1](https://github.com/rafalopilowski1))
10
+
11
+ ### Changed
12
+
13
+ - Remote compaction now sends cache and session headers for the endpoint and model the request actually uses, so a configured compaction override no longer inherits the live turn's routing ([#16988](https://github.com/can1357/oh-my-pi/pull/16988) by [@rafalopilowski1](https://github.com/rafalopilowski1))
14
+ - Aside providers (`AgentLoopConfig.getAsideMessages`, `Agent.setAsideMessageProvider`) now receive which boundary is polled, `"work"` after tool calls or `"stop"` at the end of a turn, so they can keep a message out of a finished turn ([#16583](https://github.com/can1357/oh-my-pi/pull/16583) by [@will-bogusz](https://github.com/will-bogusz))
15
+
16
+ ### Fixed
17
+
18
+ - Fixed `--max-time` values above about 596 hours (such as `600h`) aborting the first turn with "Deadline exceeded" right away ([#17054](https://github.com/can1357/oh-my-pi/pull/17054) by [@trukhinyuri](https://github.com/trukhinyuri))
19
+ - Fixed repeated compaction on small or provider-capped context windows: compaction keeps only as much recent history as fits under the configured trigger, and no longer counts stale pre-compaction usage ([#16904](https://github.com/can1357/oh-my-pi/pull/16904) by [@eggpeat](https://github.com/eggpeat))
20
+ - Fixed native compaction token estimates counting opaque encrypted-state bytes as conversation text ([#16904](https://github.com/can1357/oh-my-pi/pull/16904) by [@eggpeat](https://github.com/eggpeat))
21
+ - Fixed split-turn compaction leaving `session.compacting` context out of the turn-prefix summary ([#16707](https://github.com/can1357/oh-my-pi/pull/16707) by [@integrITsolutions](https://github.com/integrITsolutions))
22
+ - Fixed compaction failing as a whole, and discarding the completed summary, when the optional short summary fails or returns no answer; the compaction now completes without a short summary, and cancellation still cancels it ([#16942](https://github.com/can1357/oh-my-pi/pull/16942) by [@Mouriya-Emma](https://github.com/Mouriya-Emma))
23
+
5
24
  ## [18.12.4] - 2026-10-20
6
25
 
7
26
  ### Fixed
@@ -3,7 +3,7 @@ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context.js";
5
5
  import { Tokenizer } from "./tokenizer.js";
6
- import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
6
+ import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideBoundary, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
7
7
  export declare class AgentBusyError extends Error {
8
8
  constructor(message?: string);
9
9
  }
@@ -469,7 +469,7 @@ export declare class Agent {
469
469
  * aborts foreground tools; an interruptible `wait` may end early through the
470
470
  * peek hooks. See `AgentLoopConfig.getAsideMessages`.
471
471
  */
472
- setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
472
+ setAsideMessageProvider(fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
473
473
  emitExternalEvent(event: AgentEvent): void;
474
474
  setSystemPrompt(v: string[] | string): void;
475
475
  setModel(model: Model): void;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import type { Api, CodexCompactionContext, FetchImpl, Model, ProviderSessionState } from "@oh-my-pi/pi-ai";
10
10
  import { type OpenAICodexCompactionBody } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
11
+ import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types";
11
12
  /** Retained-message budget Codex uses after streamed V2 compaction. */
12
13
  export declare const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64000;
13
14
  /** Max retries for V2 streaming compaction on transient stream errors. */
@@ -46,6 +47,15 @@ export declare function getCompactionV2Endpoint(model: Model): string | undefine
46
47
  export declare function shouldUseCompactionV2Streaming(model: Model): model is Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">;
47
48
  /** APIs that speak the OpenAI Responses compaction protocol (V1 `/responses/compact` and V2 streaming). */
48
49
  export declare function isOpenAiRemoteCompactionApi(api: Api | undefined): boolean;
50
+ /**
51
+ * Catalog-policy view of a remote compaction request (V1 and V2): the
52
+ * effective adapter (`remoteCompaction.api`) and the wire id the body sends
53
+ * (`remoteCompaction.model`). Under a model override the active lane's
54
+ * `requestModelId` and thinking classification describe a model that never
55
+ * reaches the wire, so they are dropped; `reasoning` stays because the
56
+ * compaction body derives its reasoning config from the active model.
57
+ */
58
+ export declare function remoteCompactionPolicyModel(model: Model): CatalogPolicyInputs;
49
59
  /** Clamp the retained-message budget to Codex's known-safe 64K ceiling. */
50
60
  export declare function resolveCompactionV2RetainedMessageBudget(value: number | undefined): number;
51
61
  /** Build a V2 streaming compaction request from Responses-native history. */
@@ -67,6 +67,18 @@ export interface CompactionSettings {
67
67
  }
68
68
  /** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
69
69
  export declare const DEFAULT_RESERVE_TOKENS = 16384;
70
+ /**
71
+ * Hysteresis band under the compaction trigger. Maintenance counts as having
72
+ * resolved threshold pressure only when residual context lands at or below
73
+ * `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against the raw threshold
74
+ * lets a pass keep reclaiming a trickle of the previous turn's output and land
75
+ * just under the line every turn, sustaining the auto-continue dead loop
76
+ * reported in #2275; the same band stops the context-full / snapcompact tail
77
+ * from re-firing on a history whose single most-recent kept turn already
78
+ * exceeds the threshold. {@link prepareCompaction} sizes the kept tail to land
79
+ * inside it.
80
+ */
81
+ export declare const COMPACTION_RECOVERY_BAND = 0.8;
70
82
  /**
71
83
  * Hard ceiling on a generated compaction summary.
72
84
  *
@@ -231,8 +243,15 @@ export interface SummaryOptions {
231
243
  thinkingLevel?: ThinkingLevel;
232
244
  /** Session routing key for remote compaction transports with sticky provider sessions. */
233
245
  sessionId?: string;
234
- /** Prompt-cache key for remote compaction transports that support provider prefix caching. */
246
+ /** Prompt-cache key for conventional summary requests, gated against the summarizing model. */
235
247
  promptCacheKey?: string;
248
+ /**
249
+ * Prompt-cache key for remote compaction requests, gated against the request
250
+ * they send (`remoteCompaction.api`/`model` override, see
251
+ * `remoteCompactionPolicyModel`). Defaults to {@link promptCacheKey} only when
252
+ * omitted; an explicit `undefined` means the remote target gets no key.
253
+ */
254
+ remotePromptCacheKey?: string;
236
255
  /** Mutable provider state used to keep Codex compaction on the live session identity. */
237
256
  providerSessionState?: Map<string, ProviderSessionState>;
238
257
  /** Whether Codex remote compaction should prefer the provider WebSocket transport. */
@@ -30,8 +30,8 @@ export declare const REMOTE_COMPACTION_TIMEOUT_MS = 300000;
30
30
  export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
31
31
  /**
32
32
  * Tokens of stored Responses replacement history. Retained `input_image` parts
33
- * count at their image estimate rather than as base64 text; everything else,
34
- * opaque compaction state included, counts as decoded text.
33
+ * count at their image estimate, not their base64 size. Opaque encrypted state
34
+ * stays excluded, matching ordinary message estimates and remote fit checks.
35
35
  */
36
36
  export declare function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number;
37
37
  export interface TrimRemoteCompactionInputResult {
@@ -87,6 +87,13 @@ export interface OpenAiRemoteCompactionRequest {
87
87
  model: string;
88
88
  input: Array<Record<string, unknown>>;
89
89
  instructions: string;
90
+ /**
91
+ * Responses body cache identity. Mirrors the V2 body: the explicit
92
+ * prompt-cache key when supplied (normalized to the 64-char wire limit),
93
+ * else the session-derived cache id — retention-gated through
94
+ * `getOpenAIPromptCacheKey`; absent when it resolves to nothing.
95
+ */
96
+ prompt_cache_key?: string;
90
97
  reasoning?: {
91
98
  context?: string;
92
99
  [key: string]: unknown;
@@ -124,6 +131,13 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
124
131
  fetch?: FetchImpl;
125
132
  timeoutMs?: number;
126
133
  sessionId?: string;
134
+ /**
135
+ * Explicit prompt-cache identity. Feeds the Go family affinity header
136
+ * (`x-opencode-session`) and the Responses body `prompt_cache_key`
137
+ * under the same KDL gate as the V2 request; an absent/empty key keeps
138
+ * the child conversation id.
139
+ */
140
+ promptCacheKey?: string;
127
141
  providerSessionState?: Map<string, ProviderSessionState>;
128
142
  codexCompaction?: CodexCompactionContext;
129
143
  /** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
@@ -33,6 +33,8 @@ export type CommittableAsideMessage = AgentMessage & {
33
33
  * (e.g. dropping late diagnostics a newer edit superseded).
34
34
  */
35
35
  export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
36
+ /** Where the loop polls asides: after a tool batch (`"work"`) or after a turn without tool calls (`"stop"`). */
37
+ export type AsideBoundary = "work" | "stop";
36
38
  export interface AgentTurnEndContext {
37
39
  /** Assistant/user message that just completed this turn boundary. */
38
40
  message: AgentMessage;
@@ -308,8 +310,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
308
310
  * should reach the model between requests without waiting for the agent to
309
311
  * fully stop. Returned messages are appended to the context with normal
310
312
  * message events and keep the loop running so the model can react.
313
+ *
314
+ * `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
315
+ * its turn without tool calls; a provider may hold back messages that should
316
+ * not extend a finished turn.
311
317
  */
312
- getAsideMessages?: () => Promise<AsideMessage[]>;
318
+ getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
313
319
  /**
314
320
  * Hook fired right before the loop would exit.
315
321
  *
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.12.5",
4
+ "version": "18.13.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.12.5",
42
- "@oh-my-pi/pi-catalog": "18.12.5",
43
- "@oh-my-pi/pi-natives": "18.12.5",
44
- "@oh-my-pi/pi-utils": "18.12.5",
45
- "@oh-my-pi/pi-wire": "18.12.5",
46
- "@oh-my-pi/snapcompact": "18.12.5",
41
+ "@oh-my-pi/pi-ai": "18.13.0",
42
+ "@oh-my-pi/pi-catalog": "18.13.0",
43
+ "@oh-my-pi/pi-natives": "18.13.0",
44
+ "@oh-my-pi/pi-utils": "18.13.0",
45
+ "@oh-my-pi/pi-wire": "18.13.0",
46
+ "@oh-my-pi/snapcompact": "18.13.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.12.5",
50
+ "@oh-my-pi/omptype": "18.13.0",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -1125,6 +1125,9 @@ interface StepCounter {
1125
1125
  count: number;
1126
1126
  }
1127
1127
 
1128
+ /** Largest setTimeout delay before 32-bit overflow (Node/Bun fire in ~1 ms past this). */
1129
+ const MAX_TIMER_MS = 2_147_483_647;
1130
+
1128
1131
  function isDeadlineExceeded(deadline: number | undefined): boolean {
1129
1132
  return deadline !== undefined && Date.now() >= deadline;
1130
1133
  }
@@ -1211,16 +1214,17 @@ async function runLoopBody(
1211
1214
  ): Promise<void> {
1212
1215
  let deadlineTimer: Timer | undefined;
1213
1216
  if (config.deadline !== undefined) {
1217
+ const deadline = config.deadline;
1214
1218
  const deadlineAbortController = new AbortController();
1215
1219
  const deadlineReason = new DOMException("Deadline exceeded", "TimeoutError");
1216
- const delay = config.deadline - Date.now();
1217
- if (delay <= 0) {
1218
- deadlineAbortController.abort(deadlineReason);
1219
- } else {
1220
- deadlineTimer = setTimeout(() => {
1221
- deadlineAbortController.abort(deadlineReason);
1222
- }, delay);
1223
- }
1220
+ // setTimeout overflows past 2^31-1 ms and fires in ~1 ms, so a valid `--max-time 600h`
1221
+ // aborted the first turn at once: wait in bounded slices and recheck the deadline.
1222
+ const armDeadline = (): void => {
1223
+ const delay = deadline - Date.now();
1224
+ if (delay <= 0) deadlineAbortController.abort(deadlineReason);
1225
+ else deadlineTimer = setTimeout(armDeadline, Math.min(delay, MAX_TIMER_MS));
1226
+ };
1227
+ armDeadline();
1224
1228
  signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
1225
1229
  }
1226
1230
 
@@ -1789,7 +1793,7 @@ async function runLoopBody(
1789
1793
  : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1790
1794
  if (hasMoreToolCalls) {
1791
1795
  // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1792
- const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1796
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("work"));
1793
1797
  pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1794
1798
  } else {
1795
1799
  // Stop boundary: only steering (live user input) forces another turn here. Leave
@@ -1817,7 +1821,7 @@ async function runLoopBody(
1817
1821
  // above and this yield point (e.g. queued while onBeforeYield ran). Without
1818
1822
  // this poll it would strand in the queue until the next manual prompt.
1819
1823
  const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1820
- const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1824
+ const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("stop"));
1821
1825
  const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || [];
1822
1826
  if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
1823
1827
  // Set as pending so the inner loop processes them before stopping.
package/src/agent.ts CHANGED
@@ -58,6 +58,7 @@ import type {
58
58
  AgentTool,
59
59
  AgentToolContext,
60
60
  AgentTurnEndContext,
61
+ AsideBoundary,
61
62
  AsideMessage,
62
63
  PrepareQueuedMessages,
63
64
  QueuedMessageQueue,
@@ -499,7 +500,7 @@ export class Agent {
499
500
  #onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
500
501
  #beforeModelCall?: AgentBeforeModelCall;
501
502
  #additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
502
- #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
503
+ #asideMessageProvider?: (boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>;
503
504
  #telemetry?: AgentLoopConfig["telemetry"];
504
505
  #appendOnlyContext?: AppendOnlyContextManager;
505
506
  #beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise<void> | void>();
@@ -1204,7 +1205,9 @@ export class Agent {
1204
1205
  * aborts foreground tools; an interruptible `wait` may end early through the
1205
1206
  * peek hooks. See `AgentLoopConfig.getAsideMessages`.
1206
1207
  */
1207
- setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
1208
+ setAsideMessageProvider(
1209
+ fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined,
1210
+ ): void {
1208
1211
  this.#asideMessageProvider = fn;
1209
1212
  }
1210
1213
 
@@ -1981,7 +1984,7 @@ export class Agent {
1981
1984
  hasBackgroundCompletions: this.hasBackgroundCompletions,
1982
1985
  hasQueuedAsides: this.hasQueuedAsides,
1983
1986
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1984
- getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1987
+ getAsideMessages: async boundary => (await this.#asideMessageProvider?.(boundary)) ?? [],
1985
1988
  onBeforeYield: () => this.#onBeforeYield?.(),
1986
1989
  onDetachedToolUpdate: event => this.emitExternalEvent(event),
1987
1990
  telemetry: this.#telemetry,
@@ -24,6 +24,7 @@ import {
24
24
  resolveOpenAIRequestSetup,
25
25
  } from "@oh-my-pi/pi-ai/providers/openai-shared";
26
26
  import { captureOpenAIHttpError } from "@oh-my-pi/pi-ai/utils/openai-http";
27
+ import type { CatalogPolicyInputs } from "@oh-my-pi/pi-catalog/types";
27
28
  import { isBedrockOpenAIUrl } from "@oh-my-pi/pi-catalog/hosts";
28
29
  import {
29
30
  applyCodexResidencyHeader,
@@ -131,7 +132,8 @@ export function shouldUseCompactionV2Streaming(
131
132
  return getCompactionV2Endpoint(model) !== undefined;
132
133
  }
133
134
 
134
- function compactionV2Api(model: Model): Api | undefined {
135
+ /** Effective compaction request adapter: the `remoteCompaction.api` override, else the model's own API. Always defined — `Model.api` is required. */
136
+ function compactionV2Api(model: Model): Api {
135
137
  return model.remoteCompaction?.api ?? model.api;
136
138
  }
137
139
 
@@ -171,6 +173,23 @@ function resolveCompactionV2Model(model: Model): string {
171
173
  return mappedDeployment ?? requestModel;
172
174
  }
173
175
 
176
+ /**
177
+ * Catalog-policy view of a remote compaction request (V1 and V2): the
178
+ * effective adapter (`remoteCompaction.api`) and the wire id the body sends
179
+ * (`remoteCompaction.model`). Under a model override the active lane's
180
+ * `requestModelId` and thinking classification describe a model that never
181
+ * reaches the wire, so they are dropped; `reasoning` stays because the
182
+ * compaction body derives its reasoning config from the active model.
183
+ */
184
+ export function remoteCompactionPolicyModel(model: Model): CatalogPolicyInputs {
185
+ const api = compactionV2Api(model);
186
+ const overrideId = model.remoteCompaction?.model;
187
+ const base = { provider: model.provider, providerType: model.providerType, api, reasoning: model.reasoning };
188
+ return overrideId == null
189
+ ? { ...base, id: model.id, requestModelId: model.requestModelId, thinking: model.thinking }
190
+ : { ...base, id: overrideId };
191
+ }
192
+
174
193
  // ============================================================================
175
194
  // Request Building
176
195
  // ============================================================================
@@ -413,8 +432,9 @@ function buildCompactionV2Headers(
413
432
  "content-type": "application/json",
414
433
  ...resolveOpenAIRequestSetup(
415
434
  {
416
- provider: model.provider,
417
- id: model.id,
435
+ // `on-api` scoped cache policy must see the request this
436
+ // compaction actually sends, not the active lane.
437
+ ...remoteCompactionPolicyModel(model),
418
438
  baseUrl: model.baseUrl,
419
439
  headers: model.headers,
420
440
  compat: {
@@ -422,7 +442,16 @@ function buildCompactionV2Headers(
422
442
  model.compat && "sessionHeader" in model.compat ? model.compat.sessionHeader : undefined,
423
443
  },
424
444
  },
425
- { apiKey, messages: [], sessionId: request.sessionId ?? routingSessionId, promptCacheSessionId },
445
+ {
446
+ apiKey,
447
+ messages: [],
448
+ sessionId: request.sessionId ?? routingSessionId,
449
+ promptCacheSessionId,
450
+ // The family affinity key rides only an explicit
451
+ // prompt-cache identity; the sessionId-derived cache-id
452
+ // fallback never does.
453
+ promptCacheAffinityKey: request.promptCacheKey ? promptCacheSessionId : undefined,
454
+ },
426
455
  ).headers,
427
456
  };
428
457
  if (api === "openai-codex-responses" || model.provider === "openai-codex") {
@@ -200,6 +200,19 @@ export interface CompactionSettings {
200
200
  /** Reserve applied when {@link CompactionSettings.reserveTokens} is unset. */
201
201
  export const DEFAULT_RESERVE_TOKENS = 16384;
202
202
 
203
+ /**
204
+ * Hysteresis band under the compaction trigger. Maintenance counts as having
205
+ * resolved threshold pressure only when residual context lands at or below
206
+ * `COMPACTION_RECOVERY_BAND × threshold`. Re-checking against the raw threshold
207
+ * lets a pass keep reclaiming a trickle of the previous turn's output and land
208
+ * just under the line every turn, sustaining the auto-continue dead loop
209
+ * reported in #2275; the same band stops the context-full / snapcompact tail
210
+ * from re-firing on a history whose single most-recent kept turn already
211
+ * exceeds the threshold. {@link prepareCompaction} sizes the kept tail to land
212
+ * inside it.
213
+ */
214
+ export const COMPACTION_RECOVERY_BAND = 0.8;
215
+
203
216
  /**
204
217
  * Hard ceiling on a generated compaction summary.
205
218
  *
@@ -728,8 +741,15 @@ export interface SummaryOptions {
728
741
  thinkingLevel?: ThinkingLevel;
729
742
  /** Session routing key for remote compaction transports with sticky provider sessions. */
730
743
  sessionId?: string;
731
- /** Prompt-cache key for remote compaction transports that support provider prefix caching. */
744
+ /** Prompt-cache key for conventional summary requests, gated against the summarizing model. */
732
745
  promptCacheKey?: string;
746
+ /**
747
+ * Prompt-cache key for remote compaction requests, gated against the request
748
+ * they send (`remoteCompaction.api`/`model` override, see
749
+ * `remoteCompactionPolicyModel`). Defaults to {@link promptCacheKey} only when
750
+ * omitted; an explicit `undefined` means the remote target gets no key.
751
+ */
752
+ remotePromptCacheKey?: string;
733
753
  /** Mutable provider state used to keep Codex compaction on the live session identity. */
734
754
  providerSessionState?: Map<string, ProviderSessionState>;
735
755
  /** Whether Codex remote compaction should prefer the provider WebSocket transport. */
@@ -1205,6 +1225,14 @@ export async function generateHandoff(
1205
1225
  );
1206
1226
  }
1207
1227
 
1228
+ /** Result of the short-summary request before it is mapped to the optional field. */
1229
+ type ShortSummaryOutcome = { kind: "text"; text: string } | { kind: "failed"; error: string } | { kind: "aborted" };
1230
+
1231
+ /**
1232
+ * Generate the optional display summary. Cancellation propagates as an
1233
+ * `AbortError`; every other failure or an empty answer yields `undefined`, so
1234
+ * a completed full summary is never discarded for this display-only field.
1235
+ */
1208
1236
  async function generateShortSummary(
1209
1237
  recentMessages: AgentMessage[],
1210
1238
  historySummary: string | undefined,
@@ -1213,7 +1241,7 @@ async function generateShortSummary(
1213
1241
  apiKey: ApiKey,
1214
1242
  signal?: AbortSignal,
1215
1243
  options?: SummaryOptions,
1216
- ): Promise<string> {
1244
+ ): Promise<string | undefined> {
1217
1245
  const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
1218
1246
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
1219
1247
  const conversationText = serializeConversationForSummary(
@@ -1229,57 +1257,97 @@ async function generateShortSummary(
1229
1257
  promptText += formatAdditionalContext(options?.extraContext);
1230
1258
  promptText += SHORT_SUMMARY_PROMPT;
1231
1259
 
1232
- if (options?.remoteEndpoint) {
1233
- const endpoint = options.remoteEndpoint;
1234
- const remote = await withAuth(
1235
- apiKey,
1236
- key =>
1237
- requestRemoteCompaction(
1238
- endpoint,
1239
- { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1260
+ let outcome: ShortSummaryOutcome;
1261
+ try {
1262
+ if (options?.remoteEndpoint) {
1263
+ const endpoint = options.remoteEndpoint;
1264
+ const remote = await withAuth(
1265
+ apiKey,
1266
+ key =>
1267
+ requestRemoteCompaction(
1268
+ endpoint,
1269
+ { systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText, maxTokens },
1270
+ signal,
1271
+ { fetch: options?.fetch, model, apiKey: key },
1272
+ ),
1273
+ { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1274
+ );
1275
+ outcome = { kind: "text", text: remote.summary };
1276
+ } else {
1277
+ const response = await instrumentedCompleteSimple(
1278
+ model,
1279
+ {
1280
+ systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1281
+ messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1282
+ },
1283
+ {
1284
+ maxTokens,
1240
1285
  signal,
1241
- { fetch: options?.fetch, model, apiKey: key },
1242
- ),
1243
- { signal, missingKeyMessage: "Remote compaction credentials unavailable" },
1244
- );
1245
- return remote.summary;
1286
+ apiKey,
1287
+ reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1288
+ initiatorOverride: options?.initiatorOverride,
1289
+ metadata: options?.metadata,
1290
+ fetch: options?.fetch,
1291
+ sessionId: options?.sessionId,
1292
+ promptCacheKey: options?.promptCacheKey,
1293
+ providerSessionState: options?.providerSessionState,
1294
+ codexCompaction: localCodexCompaction(options),
1295
+ },
1296
+ {
1297
+ telemetry: options?.telemetry,
1298
+ oneshotKind: "compaction_short_summary",
1299
+ completeImpl: options?.completeImpl,
1300
+ retry: summaryOneshotRetry(options),
1301
+ },
1302
+ );
1303
+ switch (response.stopReason) {
1304
+ case "aborted":
1305
+ outcome = { kind: "aborted" };
1306
+ break;
1307
+ case "error":
1308
+ outcome = { kind: "failed", error: response.errorMessage || "Unknown error" };
1309
+ break;
1310
+ case "stop":
1311
+ case "length":
1312
+ case "toolUse":
1313
+ outcome = {
1314
+ kind: "text",
1315
+ text: response.content
1316
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
1317
+ .map(c => c.text)
1318
+ .join("\n"),
1319
+ };
1320
+ break;
1321
+ }
1322
+ }
1323
+ } catch (err) {
1324
+ outcome = signal?.aborted
1325
+ ? { kind: "aborted" }
1326
+ : { kind: "failed", error: err instanceof Error ? err.message : String(err) };
1246
1327
  }
1247
1328
 
1248
- const response = await instrumentedCompleteSimple(
1249
- model,
1250
- {
1251
- systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
1252
- messages: [{ role: "user", content: [{ type: "text", text: promptText }], timestamp: Date.now() }],
1253
- },
1254
- {
1255
- maxTokens,
1256
- signal,
1257
- apiKey,
1258
- reasoning: resolveCompactionEffort(model, options?.thinkingLevel),
1259
- initiatorOverride: options?.initiatorOverride,
1260
- metadata: options?.metadata,
1261
- fetch: options?.fetch,
1262
- sessionId: options?.sessionId,
1263
- promptCacheKey: options?.promptCacheKey,
1264
- providerSessionState: options?.providerSessionState,
1265
- codexCompaction: localCodexCompaction(options),
1266
- },
1267
- {
1268
- telemetry: options?.telemetry,
1269
- oneshotKind: "compaction_short_summary",
1270
- completeImpl: options?.completeImpl,
1271
- retry: summaryOneshotRetry(options),
1272
- },
1273
- );
1274
-
1275
- if (response.stopReason === "error") {
1276
- throw createSummarizationError("Short summary failed", response);
1329
+ if (signal?.aborted) outcome = { kind: "aborted" };
1330
+ switch (outcome.kind) {
1331
+ case "aborted":
1332
+ throw new AIError.AbortError("Short summary aborted", { cause: signal?.reason });
1333
+ case "failed":
1334
+ logger.warn("Compaction short summary skipped", {
1335
+ error: outcome.error,
1336
+ model: model.id,
1337
+ provider: model.provider,
1338
+ });
1339
+ return undefined;
1340
+ case "text":
1341
+ if (outcome.text.trim() === "") {
1342
+ logger.warn("Compaction short summary skipped", {
1343
+ error: "empty answer",
1344
+ model: model.id,
1345
+ provider: model.provider,
1346
+ });
1347
+ return undefined;
1348
+ }
1349
+ return outcome.text;
1277
1350
  }
1278
-
1279
- return response.content
1280
- .filter((c): c is { type: "text"; text: string } => c.type === "text")
1281
- .map(c => c.text)
1282
- .join("\n");
1283
1351
  }
1284
1352
 
1285
1353
  // ============================================================================
@@ -1457,9 +1525,27 @@ export function prepareCompaction(
1457
1525
  compactionMessages.push(message);
1458
1526
  }
1459
1527
 
1460
- const lastUsage = getLastAssistantUsage(pathEntries);
1528
+ const postCompactionStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
1529
+ const lastUsage = getLastAssistantUsage(
1530
+ postCompactionStart > 0 ? pathEntries.slice(postCompactionStart) : pathEntries,
1531
+ );
1461
1532
  const tokensBefore = lastUsage ? calculateContextTokens(lastUsage) : 0;
1462
1533
  let keepRecentTokens = settings.keepRecentTokens;
1534
+ if (activeModel?.contextWindow && activeModel.contextWindow > 0) {
1535
+ // The compacted context (kept tail + summary/prompt overhead) must land in
1536
+ // the recovery band under the configured trigger, or it fires again at once.
1537
+ // The trigger is the user's choice, so the kept tail gives way, not the trigger:
1538
+ // it gets what the band leaves after the reserve floor (an upper bound on the
1539
+ // summary), or half the band when the floor alone fills it.
1540
+ const contextWindow = activeModel.contextWindow;
1541
+ const recoveryBand = Math.floor(resolveThresholdTokens(contextWindow, settings) * COMPACTION_RECOVERY_BAND);
1542
+ const reserveFloor = Math.min(
1543
+ resolveBudgetReserveTokens(contextWindow, settings),
1544
+ settings.reserveTokens ?? DEFAULT_RESERVE_TOKENS,
1545
+ );
1546
+ const tailBudget = recoveryBand > reserveFloor ? recoveryBand - reserveFloor : Math.floor(recoveryBand / 2);
1547
+ keepRecentTokens = Math.min(keepRecentTokens, Math.max(1, tailBudget));
1548
+ }
1463
1549
  if (lastUsage) {
1464
1550
  const estimatedTokens = tokenizer.countMessages(compactionMessages);
1465
1551
  const promptTokens = calculatePromptTokens(lastUsage);
@@ -1677,6 +1763,10 @@ export async function compact(
1677
1763
  thinkingLevel: options?.thinkingLevel,
1678
1764
  sessionId: options?.sessionId,
1679
1765
  promptCacheKey: options?.promptCacheKey,
1766
+ remotePromptCacheKey:
1767
+ options && Object.hasOwn(options, "remotePromptCacheKey")
1768
+ ? options.remotePromptCacheKey
1769
+ : options?.promptCacheKey,
1680
1770
  providerSessionState: options?.providerSessionState,
1681
1771
  preferWebsockets: options?.preferWebsockets,
1682
1772
  codexCompaction: options?.codexCompaction,
@@ -1815,7 +1905,7 @@ export async function compact(
1815
1905
  forceReasoningOff: summaryOptions.thinkingLevel === ThinkingLevel.Off,
1816
1906
  responsesLite: model.useResponsesLite,
1817
1907
  sessionId: summaryOptions.sessionId,
1818
- promptCacheKey: summaryOptions.promptCacheKey,
1908
+ promptCacheKey: summaryOptions.remotePromptCacheKey,
1819
1909
  providerSessionState: summaryOptions.providerSessionState,
1820
1910
  codexCompaction: createOpenAICodexCompactionRequestContext({
1821
1911
  context: summaryOptions.codexCompaction,
@@ -1888,7 +1978,7 @@ export async function compact(
1888
1978
  assertRemoteCompactionInputFits(trimmed, model);
1889
1979
  const requestOptions = {
1890
1980
  sessionId: summaryOptions.sessionId,
1891
- promptCacheKey: summaryOptions.promptCacheKey,
1981
+ promptCacheKey: summaryOptions.remotePromptCacheKey,
1892
1982
  retainedMessageBudget: settings.v2RetainedMessageBudget,
1893
1983
  retainedUserItems,
1894
1984
  };
@@ -1970,6 +2060,7 @@ export async function compact(
1970
2060
  {
1971
2061
  fetch: summaryOptions.fetch,
1972
2062
  sessionId: summaryOptions.sessionId,
2063
+ promptCacheKey: summaryOptions.remotePromptCacheKey,
1973
2064
  providerSessionState: summaryOptions.providerSessionState,
1974
2065
  codexCompaction: summaryOptions.codexCompaction,
1975
2066
  rewriteOutputs,
@@ -2272,7 +2363,7 @@ export async function compact(
2272
2363
 
2273
2364
  return {
2274
2365
  summary,
2275
- shortSummary,
2366
+ ...(shortSummary !== undefined ? { shortSummary } : {}),
2276
2367
  firstKeptEntryId: nativeSummary !== undefined ? nativeFirstKeptEntryId : firstKeptEntryId,
2277
2368
  tokensBefore,
2278
2369
  details: { readFiles, modifiedFiles } as CompactionDetails,
@@ -2300,7 +2391,7 @@ async function generateTurnPrefixSummary(
2300
2391
  preferredDialect(model.id),
2301
2392
  resolveCatalogPolicy(model).classifierCotExtraction === true,
2302
2393
  );
2303
- const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
2394
+ const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${formatAdditionalContext(options?.extraContext)}${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
2304
2395
  const summarizationMessages = [
2305
2396
  {
2306
2397
  role: "user" as const,
@@ -20,9 +20,14 @@ import { getCodexAttestationHeader } from "@oh-my-pi/pi-ai/providers/openai-code
20
20
  import { createOpenAICodexCompactionRequestContext } from "@oh-my-pi/pi-ai/providers/openai-codex-compaction";
21
21
  import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-codex/request-transformer";
22
22
  import { createOpenAICodexCompatibilityMetadata } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
23
- import { applySessionHeader } from "@oh-my-pi/pi-ai/providers/inference-headers";
23
+ import {
24
+ applyInferenceHeaders,
25
+ applySessionHeader,
26
+ resolveOpenCodeGoCacheAffinity,
27
+ } from "@oh-my-pi/pi-ai/providers/inference-headers";
24
28
  import {
25
29
  encodeResponsesToolResultOutput,
30
+ getOpenAIPromptCacheKey,
26
31
  hoistInterleavedResponsesToolBatchMessages,
27
32
  parseAzureDeploymentNameMap,
28
33
  parseTextSignature,
@@ -59,7 +64,7 @@ import { dataUrlImageSize } from "../image-tokens";
59
64
  import { Tokenizer } from "../tokenizer";
60
65
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
61
66
  import { prepareBedrockCompactionRequest } from "./bedrock";
62
- import { isOpenAiRemoteCompactionApi } from "./compaction-v2-streaming";
67
+ import { isOpenAiRemoteCompactionApi, remoteCompactionPolicyModel } from "./compaction-v2-streaming";
63
68
  import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
64
69
 
65
70
  export * from "./compaction-v2-streaming";
@@ -100,18 +105,13 @@ interface RemoteEstimateParts {
100
105
  imageTokens: number;
101
106
  }
102
107
 
103
- function collectRemoteCompactionEstimate(
104
- value: unknown,
105
- tokenizer: Tokenizer,
106
- dropEncrypted: boolean,
107
- parts: RemoteEstimateParts,
108
- ): void {
108
+ function collectRemoteCompactionEstimate(value: unknown, tokenizer: Tokenizer, parts: RemoteEstimateParts): void {
109
109
  if (typeof value === "string") {
110
110
  if (value) parts.fragments.push(value);
111
111
  return;
112
112
  }
113
113
  if (Array.isArray(value)) {
114
- for (const item of value) collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
114
+ for (const item of value) collectRemoteCompactionEstimate(item, tokenizer, parts);
115
115
  return;
116
116
  }
117
117
  if (value === null || typeof value !== "object") {
@@ -141,9 +141,9 @@ function collectRemoteCompactionEstimate(
141
141
  // Opaque encrypted reasoning/compaction state: its local base64 size far
142
142
  // exceeds what the provider bills, so it stays out of the fit estimate
143
143
  // (same policy as `MessageCountOptions.excludeEncryptedReasoning`).
144
- if (item === undefined || (dropEncrypted && key === "encrypted_content" && typeof item === "string")) continue;
144
+ if (item === undefined || (key === "encrypted_content" && typeof item === "string")) continue;
145
145
  parts.framing.push(key);
146
- collectRemoteCompactionEstimate(item, tokenizer, dropEncrypted, parts);
146
+ collectRemoteCompactionEstimate(item, tokenizer, parts);
147
147
  }
148
148
  }
149
149
 
@@ -155,21 +155,20 @@ function collectRemoteCompactionEstimate(
155
155
  function remoteCompactionEstimateParts(
156
156
  value: unknown,
157
157
  tokenizer: Tokenizer,
158
- dropEncrypted: boolean,
159
158
  ): { fragments: string[]; imageTokens: number } {
160
159
  const parts: RemoteEstimateParts = { fragments: [], framing: [], imageTokens: 0 };
161
- collectRemoteCompactionEstimate(value, tokenizer, dropEncrypted, parts);
160
+ collectRemoteCompactionEstimate(value, tokenizer, parts);
162
161
  if (parts.framing.length > 0) parts.fragments.push(parts.framing.join(" "));
163
162
  return { fragments: parts.fragments, imageTokens: parts.imageTokens };
164
163
  }
165
164
 
166
165
  /**
167
166
  * Tokens of stored Responses replacement history. Retained `input_image` parts
168
- * count at their image estimate rather than as base64 text; everything else,
169
- * opaque compaction state included, counts as decoded text.
167
+ * count at their image estimate, not their base64 size. Opaque encrypted state
168
+ * stays excluded, matching ordinary message estimates and remote fit checks.
170
169
  */
171
170
  export function countResponsesHistoryTokens(items: unknown[], tokenizer: Tokenizer): number {
172
- const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer, false);
171
+ const { fragments, imageTokens } = remoteCompactionEstimateParts(items, tokenizer);
173
172
  return (fragments.length === 0 ? 0 : tokenizer.countTokens(fragments)) + imageTokens;
174
173
  }
175
174
 
@@ -214,7 +213,6 @@ function probeRemoteCompactionInputBudget(
214
213
  const { fragments, imageTokens } = remoteCompactionEstimateParts(
215
214
  { instructions, input, ...(tools ? { tools } : {}) },
216
215
  tokenizer,
217
- true,
218
216
  );
219
217
  const flatTokens = imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
220
218
  if (!contextWindow || contextWindow <= 0) {
@@ -360,6 +358,13 @@ export interface OpenAiRemoteCompactionRequest {
360
358
  model: string;
361
359
  input: Array<Record<string, unknown>>;
362
360
  instructions: string;
361
+ /**
362
+ * Responses body cache identity. Mirrors the V2 body: the explicit
363
+ * prompt-cache key when supplied (normalized to the 64-char wire limit),
364
+ * else the session-derived cache id — retention-gated through
365
+ * `getOpenAIPromptCacheKey`; absent when it resolves to nothing.
366
+ */
367
+ prompt_cache_key?: string;
363
368
  reasoning?: {
364
369
  context?: string;
365
370
  [key: string]: unknown;
@@ -858,6 +863,13 @@ export async function requestOpenAiRemoteCompaction(
858
863
  fetch?: FetchImpl;
859
864
  timeoutMs?: number;
860
865
  sessionId?: string;
866
+ /**
867
+ * Explicit prompt-cache identity. Feeds the Go family affinity header
868
+ * (`x-opencode-session`) and the Responses body `prompt_cache_key`
869
+ * under the same KDL gate as the V2 request; an absent/empty key keeps
870
+ * the child conversation id.
871
+ */
872
+ promptCacheKey?: string;
861
873
  providerSessionState?: Map<string, ProviderSessionState>;
862
874
  codexCompaction?: CodexCompactionContext;
863
875
  /** Forwarded to {@link trimRemoteCompactionInputToContextWindow}. */
@@ -889,6 +901,12 @@ export async function requestOpenAiRemoteCompaction(
889
901
  });
890
902
  }
891
903
  assertRemoteCompactionInputFits(trimmed, model);
904
+ // Same cache identity as the V2 request: the resolved id prefers the
905
+ // explicit prompt-cache key and falls back to the session id, all
906
+ // retention-gated by getOpenAIPromptCacheKey (PI_CACHE_RETENTION=none
907
+ // resolves to nothing here, which then also drops the affinity below).
908
+ const cacheOptions = { sessionId: opts?.sessionId, promptCacheKey: opts?.promptCacheKey };
909
+ const promptCacheSessionId = getOpenAIPromptCacheKey(cacheOptions);
892
910
  const request: OpenAiRemoteCompactionRequest = {
893
911
  model: requestModel,
894
912
  // Preserve the native transcript. Only oversized trailing tool outputs are
@@ -896,6 +914,7 @@ export async function requestOpenAiRemoteCompaction(
896
914
  // reasoning, or call/result pairing.
897
915
  input: trimmed.input,
898
916
  instructions,
917
+ ...(promptCacheSessionId ? { prompt_cache_key: promptCacheSessionId } : {}),
899
918
  };
900
919
  const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
901
920
  const isCodexResponses =
@@ -911,6 +930,22 @@ export async function requestOpenAiRemoteCompaction(
911
930
  Authorization: `Bearer ${apiKey}`,
912
931
  ...model.headers,
913
932
  };
933
+ // Same gate as the V2 request, against the request this compaction actually
934
+ // sends. Azure keeps its plain header shape (V2 skips the cascade there too).
935
+ // Only an EXPLICIT prompt-cache key feeds the affinity; the sessionId-derived
936
+ // cache id never does. `promptCacheSessionId` is already retention-gated.
937
+ if (!isAzureOpenAiResponses) {
938
+ applyInferenceHeaders(headers, {
939
+ provider: model.provider,
940
+ providerType: model.providerType,
941
+ protocol: "openai",
942
+ sessionId: opts?.sessionId,
943
+ cacheAffinity: resolveOpenCodeGoCacheAffinity(
944
+ remoteCompactionPolicyModel(model),
945
+ opts?.promptCacheKey ? promptCacheSessionId : undefined,
946
+ ),
947
+ });
948
+ }
914
949
  applySessionHeader(headers, model.compat, opts?.sessionId);
915
950
 
916
951
  // Codex endpoints require additional auth headers
package/src/types.ts CHANGED
@@ -68,6 +68,9 @@ export type CommittableAsideMessage = AgentMessage & {
68
68
  */
69
69
  export type AsideMessage = CommittableAsideMessage | (() => CommittableAsideMessage | null);
70
70
 
71
+ /** Where the loop polls asides: after a tool batch (`"work"`) or after a turn without tool calls (`"stop"`). */
72
+ export type AsideBoundary = "work" | "stop";
73
+
71
74
  export interface AgentTurnEndContext {
72
75
  /** Assistant/user message that just completed this turn boundary. */
73
76
  message: AgentMessage;
@@ -368,8 +371,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
368
371
  * should reach the model between requests without waiting for the agent to
369
372
  * fully stop. Returned messages are appended to the context with normal
370
373
  * message events and keep the loop running so the model can react.
374
+ *
375
+ * `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
376
+ * its turn without tool calls; a provider may hold back messages that should
377
+ * not extend a finished turn.
371
378
  */
372
- getAsideMessages?: () => Promise<AsideMessage[]>;
379
+ getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
373
380
  /**
374
381
  * Hook fired right before the loop would exit.
375
382
  *