@oh-my-pi/pi-agent-core 18.12.6 → 18.13.1

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,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.13.1] - 2026-10-27
6
+
7
+ ### Added
8
+
9
+ - Hosts can distinguish safely paused agents from turns still draining with `Agent.isPaused` ([#17073](https://github.com/can1357/oh-my-pi/pull/17073) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy)).
10
+
11
+ ## [18.13.0] - 2026-10-26
12
+
13
+ ### Added
14
+
15
+ - 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))
16
+
17
+ ### Changed
18
+
19
+ - 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))
20
+ - 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))
21
+
22
+ ### Fixed
23
+
24
+ - 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))
25
+ - 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))
26
+ - 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))
27
+ - 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))
28
+ - 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))
29
+
5
30
  ## [18.12.4] - 2026-10-20
6
31
 
7
32
  ### 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
  }
@@ -240,6 +240,8 @@ export interface AgentPromptOptions {
240
240
  }
241
241
  export declare class Agent {
242
242
  #private;
243
+ /** True while this unfinished turn is safely parked by the process pause gate. */
244
+ get isPaused(): boolean;
243
245
  streamFn: StreamFn;
244
246
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
245
247
  /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
@@ -469,7 +471,7 @@ export declare class Agent {
469
471
  * aborts foreground tools; an interruptible `wait` may end early through the
470
472
  * peek hooks. See `AgentLoopConfig.getAsideMessages`.
471
473
  */
472
- setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
474
+ setAsideMessageProvider(fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void;
473
475
  emitExternalEvent(event: AgentEvent): void;
474
476
  setSystemPrompt(v: string[] | string): void;
475
477
  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}. */
@@ -23,6 +23,9 @@ export declare class SpeculativeOperationCoordinator {
23
23
  #private;
24
24
  readonly config: SpeculativeToolExecutionConfig;
25
25
  readonly environment?: CoordinatorEnvironment | undefined;
26
+ /** Observer of existing speculative execution draining; no separate work ledger. */
27
+ onIdle?: () => void;
28
+ get isRunning(): boolean;
26
29
  constructor(config: SpeculativeToolExecutionConfig, environment?: CoordinatorEnvironment | undefined);
27
30
  get maxInFlight(): number;
28
31
  get size(): number;
@@ -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;
@@ -124,6 +126,8 @@ export interface SteeringQueueState {
124
126
  */
125
127
  export interface AgentLoopConfig extends SimpleStreamOptions {
126
128
  model: Model;
129
+ /** Reports whether this loop has reached a safe process-pause boundary. */
130
+ onPauseChange?: (paused: boolean) => void;
127
131
  /**
128
132
  * When to interrupt tool execution for steering messages.
129
133
  * - "immediate" = cut interruptible waits short and raise the cooperative
@@ -308,8 +312,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
308
312
  * should reach the model between requests without waiting for the agent to
309
313
  * fully stop. Returned messages are appended to the context with normal
310
314
  * message events and keep the loop running so the model can react.
315
+ *
316
+ * `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
317
+ * its turn without tool calls; a provider may hold back messages that should
318
+ * not extend a finished turn.
311
319
  */
312
- getAsideMessages?: () => Promise<AsideMessage[]>;
320
+ getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
313
321
  /**
314
322
  * Hook fired right before the loop would exit.
315
323
  *
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.6",
4
+ "version": "18.13.1",
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.6",
42
- "@oh-my-pi/pi-catalog": "18.12.6",
43
- "@oh-my-pi/pi-natives": "18.12.6",
44
- "@oh-my-pi/pi-utils": "18.12.6",
45
- "@oh-my-pi/pi-wire": "18.12.6",
46
- "@oh-my-pi/snapcompact": "18.12.6",
41
+ "@oh-my-pi/pi-ai": "18.13.1",
42
+ "@oh-my-pi/pi-catalog": "18.13.1",
43
+ "@oh-my-pi/pi-natives": "18.13.1",
44
+ "@oh-my-pi/pi-utils": "18.13.1",
45
+ "@oh-my-pi/pi-wire": "18.13.1",
46
+ "@oh-my-pi/snapcompact": "18.13.1",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.12.6",
50
+ "@oh-my-pi/omptype": "18.13.1",
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
@@ -110,6 +110,17 @@ import {
110
110
  SPECULATIVE_STREAM_SESSION,
111
111
  } from "./types";
112
112
  import { yieldIfDue } from "./utils/yield";
113
+
114
+ async function waitForProcessPause(config: AgentLoopConfig, signal?: AbortSignal): Promise<void> {
115
+ try {
116
+ while (agentPauseGate.paused && !signal?.aborted) {
117
+ config.onPauseChange?.(true);
118
+ await agentPauseGate.waitUntilResumed(signal);
119
+ }
120
+ } finally {
121
+ config.onPauseChange?.(false);
122
+ }
123
+ }
113
124
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
114
125
  export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
115
126
 
@@ -1125,6 +1136,9 @@ interface StepCounter {
1125
1136
  count: number;
1126
1137
  }
1127
1138
 
1139
+ /** Largest setTimeout delay before 32-bit overflow (Node/Bun fire in ~1 ms past this). */
1140
+ const MAX_TIMER_MS = 2_147_483_647;
1141
+
1128
1142
  function isDeadlineExceeded(deadline: number | undefined): boolean {
1129
1143
  return deadline !== undefined && Date.now() >= deadline;
1130
1144
  }
@@ -1211,16 +1225,17 @@ async function runLoopBody(
1211
1225
  ): Promise<void> {
1212
1226
  let deadlineTimer: Timer | undefined;
1213
1227
  if (config.deadline !== undefined) {
1228
+ const deadline = config.deadline;
1214
1229
  const deadlineAbortController = new AbortController();
1215
1230
  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
- }
1231
+ // setTimeout overflows past 2^31-1 ms and fires in ~1 ms, so a valid `--max-time 600h`
1232
+ // aborted the first turn at once: wait in bounded slices and recheck the deadline.
1233
+ const armDeadline = (): void => {
1234
+ const delay = deadline - Date.now();
1235
+ if (delay <= 0) deadlineAbortController.abort(deadlineReason);
1236
+ else deadlineTimer = setTimeout(armDeadline, Math.min(delay, MAX_TIMER_MS));
1237
+ };
1238
+ armDeadline();
1224
1239
  signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
1225
1240
  }
1226
1241
 
@@ -1327,7 +1342,7 @@ async function runLoopBody(
1327
1342
  // Park at the turn boundary while the process-wide pause gate is
1328
1343
  // engaged (host /pause). An external abort releases the park so a
1329
1344
  // cancelled run still unwinds while everything else stays frozen.
1330
- if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
1345
+ if (agentPauseGate.paused) await waitForProcessPause(config, signal);
1331
1346
 
1332
1347
  // Build the provider-bound context before opening the turn. Queue
1333
1348
  // messages are added now but their events remain deferred until
@@ -1439,6 +1454,7 @@ async function runLoopBody(
1439
1454
  let recovered: HarmonyRecoveredToolCall | undefined;
1440
1455
  let message: AssistantMessage;
1441
1456
  try {
1457
+ if (agentPauseGate.paused) await waitForProcessPause(config, signal);
1442
1458
  message = await streamAssistantResponse(
1443
1459
  currentContext,
1444
1460
  config,
@@ -1789,7 +1805,7 @@ async function runLoopBody(
1789
1805
  : [...live, ...((await config.getSteeringMessages?.(signal)) || [])];
1790
1806
  if (hasMoreToolCalls) {
1791
1807
  // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1792
- const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1808
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("work"));
1793
1809
  pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1794
1810
  } else {
1795
1811
  // Stop boundary: only steering (live user input) forces another turn here. Leave
@@ -1817,7 +1833,7 @@ async function runLoopBody(
1817
1833
  // above and this yield point (e.g. queued while onBeforeYield ran). Without
1818
1834
  // this poll it would strand in the queue until the next manual prompt.
1819
1835
  const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1820
- const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1836
+ const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.("stop"));
1821
1837
  const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || [];
1822
1838
  if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
1823
1839
  // Set as pending so the inner loop processes them before stopping.
@@ -3363,6 +3379,18 @@ async function executeToolCalls(
3363
3379
  };
3364
3380
  });
3365
3381
 
3382
+ // Derive batch readiness from existing dispatch records, not extra participants.
3383
+ const refreshPauseState = (): void => {
3384
+ config.onPauseChange?.(
3385
+ agentPauseGate.paused &&
3386
+ !signal?.aborted &&
3387
+ !speculationCoordinator?.isRunning &&
3388
+ records.some(record => !record.started && !record.skipped && !record.toolResultMessage) &&
3389
+ !records.some(record => record.started && !record.toolResultMessage),
3390
+ );
3391
+ };
3392
+ if (speculationCoordinator) speculationCoordinator.onIdle = refreshPauseState;
3393
+
3366
3394
  const checkAsideInterrupts = async (): Promise<void> => {
3367
3395
  // Asides only fire once: an interrupt already recorded on interruptState
3368
3396
  // must not re-abort, and (unlike steering) never re-consumes a queue.
@@ -3513,7 +3541,11 @@ async function executeToolCalls(
3513
3541
  // Park before starting this tool while the process-wide pause gate is
3514
3542
  // engaged. Tools already executing are unaffected (pausing never aborts);
3515
3543
  // a batch interrupted mid-pause unwinds via the signal checks below.
3516
- if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
3544
+ while (agentPauseGate.paused && !record.signal.aborted) {
3545
+ refreshPauseState();
3546
+ await agentPauseGate.waitUntilResumed(record.signal);
3547
+ }
3548
+ config.onPauseChange?.(false);
3517
3549
 
3518
3550
  const { toolCall, tool } = record;
3519
3551
  // Validation (and the beforeToolCall hook) ran in the prepare phase; a
@@ -3847,7 +3879,13 @@ async function executeToolCalls(
3847
3879
  concurrency = concurrencyMode ?? "shared";
3848
3880
  }
3849
3881
  const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
3850
- const task = start.then(() => runTool(record, index));
3882
+ const task = start.then(async () => {
3883
+ try {
3884
+ await runTool(record, index);
3885
+ } finally {
3886
+ refreshPauseState();
3887
+ }
3888
+ });
3851
3889
  tasks.push(task);
3852
3890
  if (concurrency === "exclusive") {
3853
3891
  lastExclusive = task;
@@ -3859,6 +3897,8 @@ async function executeToolCalls(
3859
3897
  try {
3860
3898
  await Promise.allSettled(tasks);
3861
3899
  } finally {
3900
+ if (speculationCoordinator) speculationCoordinator.onIdle = undefined;
3901
+ config.onPauseChange?.(false);
3862
3902
  steeringWatchAbortController.abort();
3863
3903
  await steeringWatchPromise?.catch(() => undefined);
3864
3904
  clearInterval(steeringWatchTimer);
package/src/agent.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  unpairedToolCallTail,
40
40
  } from "./agent-loop";
41
41
  import type { AppendOnlyContextManager } from "./append-only-context";
42
+ import { agentPauseGate } from "./pause";
42
43
  import { isProviderRefusalMessage } from "./replay-policy";
43
44
  import { SentToolDefinitions } from "./sent-tool-definitions";
44
45
  import { Tokenizer } from "./tokenizer";
@@ -58,6 +59,7 @@ import type {
58
59
  AgentTool,
59
60
  AgentToolContext,
60
61
  AgentTurnEndContext,
62
+ AsideBoundary,
61
63
  AsideMessage,
62
64
  PrepareQueuedMessages,
63
65
  QueuedMessageQueue,
@@ -473,6 +475,12 @@ export class Agent {
473
475
  #cwdResolver?: () => string | undefined;
474
476
 
475
477
  #runningPrompt?: Promise<void>;
478
+ #isPaused = false;
479
+
480
+ /** True while this unfinished turn is safely parked by the process pause gate. */
481
+ get isPaused(): boolean {
482
+ return this.#isPaused && agentPauseGate.paused && !this.#abortController?.signal.aborted;
483
+ }
476
484
  #resolveRunningPrompt?: () => void;
477
485
  #kimiApiFormat?: "openai" | "anthropic";
478
486
  #preferWebsockets?: boolean;
@@ -499,7 +507,7 @@ export class Agent {
499
507
  #onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
500
508
  #beforeModelCall?: AgentBeforeModelCall;
501
509
  #additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
502
- #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
510
+ #asideMessageProvider?: (boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>;
503
511
  #telemetry?: AgentLoopConfig["telemetry"];
504
512
  #appendOnlyContext?: AppendOnlyContextManager;
505
513
  #beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise<void> | void>();
@@ -1204,7 +1212,9 @@ export class Agent {
1204
1212
  * aborts foreground tools; an interruptible `wait` may end early through the
1205
1213
  * peek hooks. See `AgentLoopConfig.getAsideMessages`.
1206
1214
  */
1207
- setAsideMessageProvider(fn: (() => AsideMessage[] | Promise<AsideMessage[]>) | undefined): void {
1215
+ setAsideMessageProvider(
1216
+ fn: ((boundary: AsideBoundary) => AsideMessage[] | Promise<AsideMessage[]>) | undefined,
1217
+ ): void {
1208
1218
  this.#asideMessageProvider = fn;
1209
1219
  }
1210
1220
 
@@ -1780,6 +1790,7 @@ export class Agent {
1780
1790
  this.#state.isStreaming = true;
1781
1791
  this.#state.streamMessage = null;
1782
1792
  this.#state.error = undefined;
1793
+ this.#isPaused = false;
1783
1794
 
1784
1795
  // Clear Cursor tool result buffer at start of each run
1785
1796
  this.#cursorToolResultBuffer = [];
@@ -1952,6 +1963,9 @@ export class Agent {
1952
1963
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1953
1964
  onHarmonyLeak: this.#onHarmonyLeak,
1954
1965
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1966
+ onPauseChange: paused => {
1967
+ this.#isPaused = paused;
1968
+ },
1955
1969
  getToolChoice,
1956
1970
  softToolRequirementState: this.#softToolRequirementState,
1957
1971
  onToolChoiceRejected: () => {
@@ -1981,7 +1995,7 @@ export class Agent {
1981
1995
  hasBackgroundCompletions: this.hasBackgroundCompletions,
1982
1996
  hasQueuedAsides: this.hasQueuedAsides,
1983
1997
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1984
- getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1998
+ getAsideMessages: async boundary => (await this.#asideMessageProvider?.(boundary)) ?? [],
1985
1999
  onBeforeYield: () => this.#onBeforeYield?.(),
1986
2000
  onDetachedToolUpdate: event => this.emitExternalEvent(event),
1987
2001
  telemetry: this.#telemetry,
@@ -2202,6 +2216,7 @@ export class Agent {
2202
2216
  this.#emit({ type: "agent_end", messages: [errorMsg] });
2203
2217
  }
2204
2218
  } finally {
2219
+ this.#isPaused = false;
2205
2220
  this.#restoreUndeliveredQueuedMessages(loopAbortController);
2206
2221
  resolveRun?.();
2207
2222
  if (this.#abortController === loopAbortController) {
@@ -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
@@ -239,6 +239,12 @@ export class SpeculativeOperationCoordinator {
239
239
  #closed = false;
240
240
  #admissionsFinalized = false;
241
241
  #running = 0;
242
+ /** Observer of existing speculative execution draining; no separate work ledger. */
243
+ onIdle?: () => void;
244
+
245
+ get isRunning(): boolean {
246
+ return this.#running > 0;
247
+ }
242
248
 
243
249
  constructor(
244
250
  readonly config: SpeculativeToolExecutionConfig,
@@ -955,6 +961,7 @@ export class SpeculativeOperationCoordinator {
955
961
  } finally {
956
962
  this.#running--;
957
963
  this.#drain();
964
+ if (this.#running === 0) this.onIdle?.();
958
965
  }
959
966
  })();
960
967
  }
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;
@@ -172,6 +175,9 @@ export interface SteeringQueueState {
172
175
  export interface AgentLoopConfig extends SimpleStreamOptions {
173
176
  model: Model;
174
177
 
178
+ /** Reports whether this loop has reached a safe process-pause boundary. */
179
+ onPauseChange?: (paused: boolean) => void;
180
+
175
181
  /**
176
182
  * When to interrupt tool execution for steering messages.
177
183
  * - "immediate" = cut interruptible waits short and raise the cooperative
@@ -368,8 +374,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
368
374
  * should reach the model between requests without waiting for the agent to
369
375
  * fully stop. Returned messages are appended to the context with normal
370
376
  * message events and keep the loop running so the model can react.
377
+ *
378
+ * `boundary` is `"work"` after a tool batch and `"stop"` when the model ended
379
+ * its turn without tool calls; a provider may hold back messages that should
380
+ * not extend a finished turn.
371
381
  */
372
- getAsideMessages?: () => Promise<AsideMessage[]>;
382
+ getAsideMessages?: (boundary: AsideBoundary) => Promise<AsideMessage[]>;
373
383
  /**
374
384
  * Hook fired right before the loop would exit.
375
385
  *