@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 +25 -0
- package/dist/types/agent.d.ts +4 -2
- package/dist/types/compaction/compaction-v2-streaming.d.ts +10 -0
- package/dist/types/compaction/compaction.d.ts +20 -1
- package/dist/types/compaction/openai.d.ts +16 -2
- package/dist/types/speculative-execution.d.ts +3 -0
- package/dist/types/types.d.ts +9 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +53 -13
- package/src/agent.ts +18 -3
- package/src/compaction/compaction-v2-streaming.ts +33 -4
- package/src/compaction/compaction.ts +145 -54
- package/src/compaction/openai.ts +52 -17
- package/src/speculative-execution.ts +7 -0
- package/src/types.ts +11 -1
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
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
34
|
-
*
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.
|
|
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.
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
|
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
|
-
|
|
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(() =>
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
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
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
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
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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,
|
package/src/compaction/openai.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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 || (
|
|
144
|
+
if (item === undefined || (key === "encrypted_content" && typeof item === "string")) continue;
|
|
145
145
|
parts.framing.push(key);
|
|
146
|
-
collectRemoteCompactionEstimate(item, tokenizer,
|
|
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,
|
|
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
|
|
169
|
-
*
|
|
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
|
|
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
|
*
|