@oh-my-pi/pi-agent-core 17.1.2 → 17.1.4
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 +14 -0
- package/dist/types/agent.d.ts +10 -1
- package/dist/types/compaction/openai.d.ts +14 -0
- package/dist/types/tokenizer.d.ts +1 -0
- package/dist/types/types.d.ts +7 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +67 -16
- package/src/agent.ts +116 -23
- package/src/compaction/compaction.ts +36 -15
- package/src/compaction/openai.ts +154 -6
- package/src/compaction/prompts/context-window-truncated-output.md +1 -0
- package/src/tokenizer.ts +10 -0
- package/src/types.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.4] - 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Steering is now woken by an event instead of polled on a fixed interval while a tool batch runs. `AgentLoopConfig.waitForSteeringMessages` resolves when a steer is enqueued, so an interruption is observed as soon as it arrives rather than at the next tick, and idle batches stop burning wakeups. The interval timer remains for the IRC interrupt queue, which has no wake callback, and checks only IRC while the event watcher owns steering. Waits are raced against local abort, so a callback that does not observe its signal cannot hang batch teardown.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed a Cursor tool result being lost when a custom `cursorOnToolResult` transformer was still pending as the turn closed. The provider dispatches decoded messages without awaiting them, so a `message_end` from the same chunk could drain the buffer before the transformer resolved, dropping the result and leaving its `toolCall` block to be stripped as dangling on replay. The entry is now reserved synchronously and patched in place once the transformer resolves, preserving buffer order.
|
|
14
|
+
- Fixed an async `cursorOnToolResult` transformer's rewrite being silently discarded when it resolved after the buffer drain. The reservation kept the call from dangling but the late patch mutated a detached entry, so the already-persisted message kept the pre-transform payload. The drain now awaits any transformer still in flight before persisting, matching the awaited exec-channel paths. A rejecting transformer is swallowed and the reserved payload stands in, so a failing hook cannot take the turn down or cost the result.
|
|
15
|
+
- Fixed Cursor tool results being dropped for hosts that pass neither `cursorExecHandlers` nor `cursorOnToolResult`. Both are optional, but the Cursor provider resolves native todo calls server-side and synthesizes exec blocks regardless, marking both as resolved so no placeholder result is emitted for them. The result buffer callback was only installed when one of the options was present, so a bare SDK host discarded the provider's paired result and every rebuilt transcript stripped the interaction. It is now installed unconditionally.
|
|
16
|
+
- Fixed an async `cursorOnToolResult` rewrite being lost when the provider errored mid-transform. The normal drain waits for a pending transformer, but the error path snapshotted the buffer without that await, so a transform still in flight patched an entry the catch path had already detached and the pre-transform payload was persisted. A provider error is exactly when a transform is most likely to be mid-flight.
|
|
17
|
+
- Reduced oversized OpenAI native compaction requests by replacing only trailing tool-output bodies that exceed the model context window, while preserving calls, assistant history, and reasoning.
|
|
18
|
+
|
|
5
19
|
## [17.1.2] - 2026-07-24
|
|
6
20
|
|
|
7
21
|
### Added
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -166,7 +166,16 @@ export interface AgentOptions {
|
|
|
166
166
|
/** Additional tools Cursor executes through its MCP request-context bridge, resolved before each provider call. */
|
|
167
167
|
getCursorTools?: () => AgentTool[];
|
|
168
168
|
/**
|
|
169
|
-
*
|
|
169
|
+
* Optional rewrite of Cursor exec-channel tool results. May return a Promise.
|
|
170
|
+
*
|
|
171
|
+
* The Agent reserves the original result in its Cursor result buffer first,
|
|
172
|
+
* then awaits this hook and patches the reserved entry in place. That keeps
|
|
173
|
+
* the call paired even if `message_end` arrives while the Promise is still
|
|
174
|
+
* pending, and `#emitCursorSplitAssistantMessage` waits for any transformer
|
|
175
|
+
* still in flight before persisting, so a late rewrite is not lost. A
|
|
176
|
+
* rejecting transformer is swallowed and the reserved payload stands in.
|
|
177
|
+
* Hosts that only pass `cursorExecHandlers` (the coding-agent path) never
|
|
178
|
+
* hit this hook.
|
|
170
179
|
*/
|
|
171
180
|
cursorOnToolResult?: CursorToolResultHandler;
|
|
172
181
|
/** Current working directory used by local tool execution. */
|
|
@@ -26,6 +26,20 @@ export declare const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompac
|
|
|
26
26
|
* behind it). On timeout the caller falls back to local summarization.
|
|
27
27
|
*/
|
|
28
28
|
export declare const REMOTE_COMPACTION_TIMEOUT_MS = 180000;
|
|
29
|
+
export declare const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: string;
|
|
30
|
+
export interface TrimRemoteCompactionInputResult {
|
|
31
|
+
input: Array<Record<string, unknown>>;
|
|
32
|
+
rewrittenOutputs: number;
|
|
33
|
+
estimatedTokensBefore: number;
|
|
34
|
+
estimatedTokensAfter: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
38
|
+
* remote compaction request beyond the model window. Replacing only those
|
|
39
|
+
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
40
|
+
* matching Codex's recovery path for oversized tool turns.
|
|
41
|
+
*/
|
|
42
|
+
export declare function trimRemoteCompactionInputToContextWindow(input: Array<Record<string, unknown>>, contextWindow: number | null | undefined, instructions: string, tools?: unknown[]): TrimRemoteCompactionInputResult;
|
|
29
43
|
export type OpenAiRemoteCompactionItem = {
|
|
30
44
|
type: "compaction" | "compaction_summary";
|
|
31
45
|
encrypted_content?: string;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -181,6 +181,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
181
181
|
* messages are still delivered at the next injection boundary.
|
|
182
182
|
*/
|
|
183
183
|
hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
|
|
184
|
+
/**
|
|
185
|
+
* Wakes the in-flight tool interrupt watcher when a steering message is queued.
|
|
186
|
+
* The callback must not consume the queue; the loop still calls
|
|
187
|
+
* {@link hasSteeringMessages} before aborting and injects through
|
|
188
|
+
* {@link getSteeringMessages}.
|
|
189
|
+
*/
|
|
190
|
+
waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
|
|
184
191
|
/**
|
|
185
192
|
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
186
193
|
*
|
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": "17.1.
|
|
4
|
+
"version": "17.1.4",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "17.1.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.1.
|
|
38
|
+
"@oh-my-pi/pi-ai": "17.1.4",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "17.1.4",
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.1.4",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.1.4",
|
|
42
|
+
"@oh-my-pi/pi-wire": "17.1.4",
|
|
43
|
+
"@oh-my-pi/snapcompact": "17.1.4",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -2004,7 +2004,7 @@ async function executeToolCalls(
|
|
|
2004
2004
|
// external + IRC aborts. Every other tool sees ONLY the external signal:
|
|
2005
2005
|
// neither queued steering nor a peer IRC ever hard-kills a partially
|
|
2006
2006
|
// side-effecting foreground tool (e.g. `bash`) — those get the cooperative
|
|
2007
|
-
// steeringSignal above, and the message injects at the next boundary.
|
|
2007
|
+
// `steeringSignal` above, and the message injects at the next boundary.
|
|
2008
2008
|
const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
|
|
2009
2009
|
const interruptibleSignal: AbortSignal = signal
|
|
2010
2010
|
? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
|
|
@@ -2050,6 +2050,21 @@ async function executeToolCalls(
|
|
|
2050
2050
|
};
|
|
2051
2051
|
});
|
|
2052
2052
|
|
|
2053
|
+
const checkIrcInterrupts = async (): Promise<void> => {
|
|
2054
|
+
// IRC only fires once: a peer interrupt already recorded on interruptState
|
|
2055
|
+
// must not re-abort, and (unlike steering) never re-consumes a queue.
|
|
2056
|
+
if (!shouldInterruptImmediately || signal?.aborted || interruptState.triggered) return;
|
|
2057
|
+
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
|
|
2058
|
+
// Peer IRC hard-aborts interruptible waits only; foreground tools keep
|
|
2059
|
+
// running (no partial side effects) but get the cooperative soft
|
|
2060
|
+
// signal so backgroundable work can step aside for the peer message.
|
|
2061
|
+
interruptState.triggered = true;
|
|
2062
|
+
interruptState.source = "irc";
|
|
2063
|
+
ircAbortController.abort();
|
|
2064
|
+
steeringSoftController.abort();
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
|
|
2053
2068
|
const checkSteering = async (): Promise<void> => {
|
|
2054
2069
|
// `signal` (external/user abort) is checked separately from the internal
|
|
2055
2070
|
// abort controllers: once the run is externally aborted it is unwinding
|
|
@@ -2087,18 +2102,7 @@ async function executeToolCalls(
|
|
|
2087
2102
|
}
|
|
2088
2103
|
return;
|
|
2089
2104
|
}
|
|
2090
|
-
|
|
2091
|
-
// must not re-abort, and (unlike steering above) never re-consume a queue.
|
|
2092
|
-
if (interruptState.triggered) return;
|
|
2093
|
-
if (hasIrcInterrupts && (await hasIrcInterrupts())) {
|
|
2094
|
-
// Peer IRC hard-aborts interruptible waits only; foreground tools keep
|
|
2095
|
-
// running (no partial side effects) but get the cooperative soft
|
|
2096
|
-
// signal so backgroundable work can step aside for the peer message.
|
|
2097
|
-
interruptState.triggered = true;
|
|
2098
|
-
interruptState.source = "irc";
|
|
2099
|
-
ircAbortController.abort();
|
|
2100
|
-
steeringSoftController.abort();
|
|
2101
|
-
}
|
|
2105
|
+
await checkIrcInterrupts();
|
|
2102
2106
|
};
|
|
2103
2107
|
|
|
2104
2108
|
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
@@ -2443,13 +2447,60 @@ async function executeToolCalls(
|
|
|
2443
2447
|
// mode; checkSteering is idempotent (no-op once triggered).
|
|
2444
2448
|
const watchSteeringWhileRunning =
|
|
2445
2449
|
shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
|
|
2446
|
-
const
|
|
2447
|
-
|
|
2450
|
+
const eventDrivenSteeringWatch =
|
|
2451
|
+
watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
|
|
2452
|
+
const steeringWatchAbortController = new AbortController();
|
|
2453
|
+
const steeringWatchSignal = signal
|
|
2454
|
+
? AbortSignal.any([signal, steeringWatchAbortController.signal])
|
|
2455
|
+
: steeringWatchAbortController.signal;
|
|
2456
|
+
// Race every wait against one local abort promise. The callback contract does
|
|
2457
|
+
// not require an implementation to observe the signal, and one that resolves
|
|
2458
|
+
// only on the next queue event would otherwise never settle once the batch
|
|
2459
|
+
// finishes, so awaiting it during teardown would hang a batch with no steer.
|
|
2460
|
+
const { promise: watchAborted, resolve: resolveWatchAbort } = Promise.withResolvers<void>();
|
|
2461
|
+
if (steeringWatchSignal.aborted) {
|
|
2462
|
+
resolveWatchAbort();
|
|
2463
|
+
} else {
|
|
2464
|
+
steeringWatchSignal.addEventListener("abort", () => resolveWatchAbort(), { once: true });
|
|
2465
|
+
}
|
|
2466
|
+
const watchAbortedFalse = watchAborted.then(() => false);
|
|
2467
|
+
const steeringWatchPromise = eventDrivenSteeringWatch
|
|
2468
|
+
? (async (): Promise<void> => {
|
|
2469
|
+
while (!steeringWatchSignal.aborted) {
|
|
2470
|
+
// Subscribe before checking queue state. This closes the edge
|
|
2471
|
+
// race where a steer arrives after a check but before listener
|
|
2472
|
+
// registration: the subsequent check observes queued state,
|
|
2473
|
+
// while later arrivals resolve this already-installed wait.
|
|
2474
|
+
const steeringQueued = config.waitForSteeringMessages?.(steeringWatchSignal).then(
|
|
2475
|
+
() => true,
|
|
2476
|
+
() => false,
|
|
2477
|
+
);
|
|
2478
|
+
const steeringChecked = checkSteering().then(
|
|
2479
|
+
() => true,
|
|
2480
|
+
() => false,
|
|
2481
|
+
);
|
|
2482
|
+
if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
|
|
2483
|
+
if (steeringWatchSignal.aborted || interruptState.triggered) return;
|
|
2484
|
+
if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
|
|
2485
|
+
}
|
|
2486
|
+
})()
|
|
2448
2487
|
: undefined;
|
|
2488
|
+
// IRC interrupt records have a separate session-owned queue and no wake
|
|
2489
|
+
// callback. Keep its established timer fallback when that queue is present;
|
|
2490
|
+
// system steering uses the event-driven path above and does not poll.
|
|
2491
|
+
const steeringWatchTimer =
|
|
2492
|
+
watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasIrcInterrupts !== undefined)
|
|
2493
|
+
? setInterval(
|
|
2494
|
+
() => void (eventDrivenSteeringWatch ? checkIrcInterrupts() : checkSteering()),
|
|
2495
|
+
STEERING_INTERRUPT_POLL_MS,
|
|
2496
|
+
)
|
|
2497
|
+
: undefined;
|
|
2449
2498
|
try {
|
|
2450
2499
|
await Promise.allSettled(tasks);
|
|
2451
2500
|
} finally {
|
|
2452
|
-
|
|
2501
|
+
steeringWatchAbortController.abort();
|
|
2502
|
+
await steeringWatchPromise?.catch(() => undefined);
|
|
2503
|
+
clearInterval(steeringWatchTimer);
|
|
2453
2504
|
}
|
|
2454
2505
|
// Yield after batch tool execution to let GC and I/O catch up,
|
|
2455
2506
|
// especially when tool results are large (e.g. bash output).
|
package/src/agent.ts
CHANGED
|
@@ -276,7 +276,16 @@ export interface AgentOptions {
|
|
|
276
276
|
getCursorTools?: () => AgentTool[];
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
*
|
|
279
|
+
* Optional rewrite of Cursor exec-channel tool results. May return a Promise.
|
|
280
|
+
*
|
|
281
|
+
* The Agent reserves the original result in its Cursor result buffer first,
|
|
282
|
+
* then awaits this hook and patches the reserved entry in place. That keeps
|
|
283
|
+
* the call paired even if `message_end` arrives while the Promise is still
|
|
284
|
+
* pending, and `#emitCursorSplitAssistantMessage` waits for any transformer
|
|
285
|
+
* still in flight before persisting, so a late rewrite is not lost. A
|
|
286
|
+
* rejecting transformer is swallowed and the reserved payload stands in.
|
|
287
|
+
* Hosts that only pass `cursorExecHandlers` (the coding-agent path) never
|
|
288
|
+
* hit this hook.
|
|
280
289
|
*/
|
|
281
290
|
cursorOnToolResult?: CursorToolResultHandler;
|
|
282
291
|
|
|
@@ -330,6 +339,13 @@ export interface AgentPromptOptions {
|
|
|
330
339
|
/** Buffered Cursor exec-channel tool result waiting to be emitted after the assistant message. */
|
|
331
340
|
interface CursorToolResultEntry {
|
|
332
341
|
toolResult: ToolResultMessage;
|
|
342
|
+
/**
|
|
343
|
+
* Set while an async `cursorOnToolResult` transformer is still running for
|
|
344
|
+
* this entry, and cleared once it settles. The drain awaits it so a
|
|
345
|
+
* transformer that rewrites the payload is not silently discarded when
|
|
346
|
+
* `message_end` lands in the same chunk as the tool result.
|
|
347
|
+
*/
|
|
348
|
+
pending?: Promise<void>;
|
|
333
349
|
}
|
|
334
350
|
|
|
335
351
|
export class Agent {
|
|
@@ -353,6 +369,8 @@ export class Agent {
|
|
|
353
369
|
#transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
|
|
354
370
|
#steeringQueue: AgentMessage[] = [];
|
|
355
371
|
#followUpQueue: AgentMessage[] = [];
|
|
372
|
+
#steeringWaiters = new Set<() => void>();
|
|
373
|
+
|
|
356
374
|
#steeringMode: "all" | "one-at-a-time";
|
|
357
375
|
#followUpMode: "all" | "one-at-a-time";
|
|
358
376
|
#interruptMode: "immediate" | "wait";
|
|
@@ -869,6 +887,7 @@ export class Agent {
|
|
|
869
887
|
replaceQueues(steering: AgentMessage[], followUp: AgentMessage[]) {
|
|
870
888
|
this.#steeringQueue = steering.slice();
|
|
871
889
|
this.#followUpQueue = followUp.slice();
|
|
890
|
+
this.#notifySteeringWaiters();
|
|
872
891
|
}
|
|
873
892
|
|
|
874
893
|
appendMessage(m: AgentMessage) {
|
|
@@ -889,6 +908,7 @@ export class Agent {
|
|
|
889
908
|
*/
|
|
890
909
|
steer(m: AgentMessage) {
|
|
891
910
|
this.#steeringQueue.push(m);
|
|
911
|
+
this.#notifySteeringWaiters();
|
|
892
912
|
}
|
|
893
913
|
|
|
894
914
|
/**
|
|
@@ -901,6 +921,7 @@ export class Agent {
|
|
|
901
921
|
|
|
902
922
|
clearSteeringQueue() {
|
|
903
923
|
this.#steeringQueue = [];
|
|
924
|
+
this.#notifySteeringWaiters();
|
|
904
925
|
}
|
|
905
926
|
|
|
906
927
|
clearFollowUpQueue() {
|
|
@@ -910,6 +931,7 @@ export class Agent {
|
|
|
910
931
|
clearAllQueues() {
|
|
911
932
|
this.#steeringQueue = [];
|
|
912
933
|
this.#followUpQueue = [];
|
|
934
|
+
this.#notifySteeringWaiters();
|
|
913
935
|
}
|
|
914
936
|
|
|
915
937
|
hasQueuedMessages(): boolean {
|
|
@@ -990,6 +1012,29 @@ export class Agent {
|
|
|
990
1012
|
return this.#runningPrompt ?? Promise.resolve();
|
|
991
1013
|
}
|
|
992
1014
|
|
|
1015
|
+
/**
|
|
1016
|
+
* Wait for a steering message without consuming the steering queue.
|
|
1017
|
+
*
|
|
1018
|
+
* The signal releases the waiter when the prompt ends, so an in-flight
|
|
1019
|
+
* tool watcher never survives the tool batch that owns it.
|
|
1020
|
+
*/
|
|
1021
|
+
#waitForSteeringMessages(signal?: AbortSignal): Promise<void> {
|
|
1022
|
+
if (this.#steeringQueue.length > 0 || signal?.aborted) return Promise.resolve();
|
|
1023
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
1024
|
+
const onAbort = (): void => resolve();
|
|
1025
|
+
this.#steeringWaiters.add(resolve);
|
|
1026
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1027
|
+
return promise.finally(() => {
|
|
1028
|
+
this.#steeringWaiters.delete(resolve);
|
|
1029
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
#notifySteeringWaiters(): void {
|
|
1034
|
+
const waiters = [...this.#steeringWaiters];
|
|
1035
|
+
for (const resolve of waiters) resolve();
|
|
1036
|
+
}
|
|
1037
|
+
|
|
993
1038
|
reset() {
|
|
994
1039
|
this.#state.messages.length = 0;
|
|
995
1040
|
this.#state.isStreaming = false;
|
|
@@ -998,6 +1043,7 @@ export class Agent {
|
|
|
998
1043
|
this.#state.error = undefined;
|
|
999
1044
|
this.#steeringQueue = [];
|
|
1000
1045
|
this.#followUpQueue = [];
|
|
1046
|
+
this.#notifySteeringWaiters();
|
|
1001
1047
|
}
|
|
1002
1048
|
|
|
1003
1049
|
/** Send a prompt with an AgentMessage */
|
|
@@ -1127,26 +1173,51 @@ export class Agent {
|
|
|
1127
1173
|
tools: this.#state.tools,
|
|
1128
1174
|
};
|
|
1129
1175
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1176
|
+
// Installed unconditionally. Both `cursorExecHandlers` and
|
|
1177
|
+
// `cursorOnToolResult` are optional, but the Cursor provider resolves
|
|
1178
|
+
// native todo calls server-side and synthesizes exec blocks regardless,
|
|
1179
|
+
// marking both `kCursorExecResolved` — `agent-loop.ts` then emits no
|
|
1180
|
+
// placeholder result for them. The provider always offers a paired result
|
|
1181
|
+
// for those blocks (its todo fallback, and every `resolveExecHandler`
|
|
1182
|
+
// exit including the no-handler one), but only through this sink: without
|
|
1183
|
+
// it the result is dropped on the floor, the assistant block is left
|
|
1184
|
+
// unpaired, and `buildSessionContext` strips the whole interaction on
|
|
1185
|
+
// replay. A non-Cursor provider never calls this, so the closure costs
|
|
1186
|
+
// nothing.
|
|
1187
|
+
const cursorOnToolResult = async (message: ToolResultMessage) => {
|
|
1188
|
+
// Cursor executes tools server-side during streaming. We buffer each
|
|
1189
|
+
// toolResult and emit them right after the assistant message closes
|
|
1190
|
+
// (see `#emitCursorSplitAssistantMessage`), so replay receives
|
|
1191
|
+
// (assistant with interleaved toolCall blocks) → results.
|
|
1192
|
+
//
|
|
1193
|
+
// The entry is reserved SYNCHRONOUSLY, before awaiting the optional
|
|
1194
|
+
// transformer. The provider's data loop dispatches messages with
|
|
1195
|
+
// `void handleServerMessage(...)`, so a `message_end` decoded from the
|
|
1196
|
+
// same chunk can drain the buffer while a transformer is still pending
|
|
1197
|
+
// — pushing afterwards would drop the result and strip its toolCall
|
|
1198
|
+
// block as dangling on replay.
|
|
1199
|
+
//
|
|
1200
|
+
// The transformer's in-flight promise is recorded on the entry so the
|
|
1201
|
+
// drain can await it (`#emitCursorSplitAssistantMessage`). Without
|
|
1202
|
+
// that, a transformer resolving after the swap would patch a detached
|
|
1203
|
+
// object while the persisted result kept the original payload — the
|
|
1204
|
+
// rewrite silently lost.
|
|
1205
|
+
const entry: CursorToolResultEntry = { toolResult: message };
|
|
1206
|
+
this.#cursorToolResultBuffer.push(entry);
|
|
1207
|
+
const transform = this.#cursorOnToolResult;
|
|
1208
|
+
if (transform) {
|
|
1209
|
+
const pending = (async () => {
|
|
1210
|
+
try {
|
|
1211
|
+
const updated = await transform(message);
|
|
1212
|
+
if (updated) entry.toolResult = updated;
|
|
1213
|
+
} catch {}
|
|
1214
|
+
})();
|
|
1215
|
+
entry.pending = pending;
|
|
1216
|
+
await pending;
|
|
1217
|
+
entry.pending = undefined;
|
|
1218
|
+
}
|
|
1219
|
+
return entry.toolResult;
|
|
1220
|
+
};
|
|
1150
1221
|
|
|
1151
1222
|
const getToolChoice = (): ToolChoiceDirective | undefined => {
|
|
1152
1223
|
const queued = this.#getToolChoice?.();
|
|
@@ -1241,6 +1312,7 @@ export class Agent {
|
|
|
1241
1312
|
}
|
|
1242
1313
|
return { queued: true, source: "system" };
|
|
1243
1314
|
},
|
|
1315
|
+
waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
|
|
1244
1316
|
hasIrcInterrupts: this.hasIrcInterrupts,
|
|
1245
1317
|
getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
|
|
1246
1318
|
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
|
@@ -1277,7 +1349,7 @@ export class Agent {
|
|
|
1277
1349
|
// Check if this is an assistant message with buffered Cursor tool results.
|
|
1278
1350
|
// If so, split the message to emit tool results at the correct position.
|
|
1279
1351
|
if (event.message.role === "assistant" && this.#cursorToolResultBuffer.length > 0) {
|
|
1280
|
-
this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage);
|
|
1352
|
+
await this.#emitCursorSplitAssistantMessage(event.message as AssistantMessage);
|
|
1281
1353
|
continue; // Skip default emit - split method handles everything
|
|
1282
1354
|
}
|
|
1283
1355
|
this.#state.streamMessage = null;
|
|
@@ -1334,6 +1406,15 @@ export class Agent {
|
|
|
1334
1406
|
const shouldEmitVisibleError = !stoppedForAbort;
|
|
1335
1407
|
const assistantPartial = partial?.role === "assistant" ? partial : undefined;
|
|
1336
1408
|
const hadAssistantStart = assistantPartial !== undefined;
|
|
1409
|
+
// Same contract as the normal drain in `#emitCursorSplitAssistantMessage`:
|
|
1410
|
+
// a transformer still in flight must be awaited before the payload is
|
|
1411
|
+
// snapshotted, or its rewrite patches an entry this catch path already
|
|
1412
|
+
// detached and the original is persisted instead. A provider error is
|
|
1413
|
+
// exactly when a transform is most likely to be mid-flight.
|
|
1414
|
+
const pendingTransforms = this.#cursorToolResultBuffer
|
|
1415
|
+
.filter(entry => entry.pending !== undefined)
|
|
1416
|
+
.map(entry => entry.pending);
|
|
1417
|
+
if (pendingTransforms.length > 0) await Promise.all(pendingTransforms);
|
|
1337
1418
|
const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
|
|
1338
1419
|
const retainedToolCallIds = new Set(completedToolCallIds);
|
|
1339
1420
|
for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
|
|
@@ -1459,10 +1540,22 @@ export class Agent {
|
|
|
1459
1540
|
* toolCall blocks; it also copied `preambleText` into every text block on
|
|
1460
1541
|
* multi-text turns, producing duplicated text on replay.
|
|
1461
1542
|
*/
|
|
1462
|
-
#emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): void {
|
|
1543
|
+
async #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): Promise<void> {
|
|
1544
|
+
// Snapshot and detach immediately so a still-pending `cursorOnToolResult`
|
|
1545
|
+
// cannot push into a drained buffer. Entries already reserved stay paired
|
|
1546
|
+
// with their toolCall.
|
|
1463
1547
|
const buffer = this.#cursorToolResultBuffer;
|
|
1464
1548
|
this.#cursorToolResultBuffer = [];
|
|
1465
1549
|
|
|
1550
|
+
// Await any transformer still running for a reserved entry before reading
|
|
1551
|
+
// its payload. The provider dispatches with `void handleServerMessage(…)`,
|
|
1552
|
+
// so a `message_end` from the same chunk can reach this point while a
|
|
1553
|
+
// transformer is mid-flight; without the await its rewrite would land on
|
|
1554
|
+
// the detached entry after the original was already appended and emitted.
|
|
1555
|
+
// Each `pending` swallows its own rejection, so this cannot throw.
|
|
1556
|
+
const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
|
|
1557
|
+
if (pending.length > 0) await Promise.all(pending);
|
|
1558
|
+
|
|
1466
1559
|
this.#state.streamMessage = null;
|
|
1467
1560
|
this.appendMessage(assistantMessage);
|
|
1468
1561
|
this.#emit({ type: "message_end", message: assistantMessage });
|
|
@@ -28,7 +28,7 @@ import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
|
|
|
28
28
|
import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
|
|
29
29
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
30
30
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
31
|
-
import { logger, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
31
|
+
import { isRecord, logger, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
|
|
32
32
|
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
33
33
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
34
34
|
import { ThinkingLevel } from "../thinking";
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
requestOpenAiRemoteCompaction,
|
|
52
52
|
requestRemoteCompaction,
|
|
53
53
|
shouldUseOpenAiRemoteCompaction,
|
|
54
|
+
trimRemoteCompactionInputToContextWindow,
|
|
54
55
|
withOpenAiRemoteCompactionPreserveData,
|
|
55
56
|
} from "./openai";
|
|
56
57
|
import autoHandoffThresholdFocusPrompt from "./prompts/auto-handoff-threshold-focus.md" with { type: "text" };
|
|
@@ -1290,7 +1291,7 @@ function buildOpenAiResponsesCompactionInput(
|
|
|
1290
1291
|
messages: Message[],
|
|
1291
1292
|
model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
|
|
1292
1293
|
previousReplacementHistory: Array<Record<string, unknown>> | undefined,
|
|
1293
|
-
): unknown
|
|
1294
|
+
): Array<Record<string, unknown>> {
|
|
1294
1295
|
const input = buildResponsesInput({
|
|
1295
1296
|
model,
|
|
1296
1297
|
context: { messages },
|
|
@@ -1300,7 +1301,14 @@ function buildOpenAiResponsesCompactionInput(
|
|
|
1300
1301
|
includeThinkingSignatures: true,
|
|
1301
1302
|
repairOrphanOutputs: true,
|
|
1302
1303
|
});
|
|
1303
|
-
|
|
1304
|
+
const nativeInput: Array<Record<string, unknown>> = [];
|
|
1305
|
+
for (const item of input) {
|
|
1306
|
+
if (!isRecord(item)) {
|
|
1307
|
+
throw new Error("OpenAI Responses compaction input contains a non-object item");
|
|
1308
|
+
}
|
|
1309
|
+
nativeInput.push(item);
|
|
1310
|
+
}
|
|
1311
|
+
return previousReplacementHistory ? [...previousReplacementHistory, ...nativeInput] : nativeInput;
|
|
1304
1312
|
}
|
|
1305
1313
|
|
|
1306
1314
|
/**
|
|
@@ -1415,20 +1423,33 @@ export async function compact(
|
|
|
1415
1423
|
);
|
|
1416
1424
|
if (remoteHistory.length > 0) {
|
|
1417
1425
|
try {
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1426
|
+
const instructions = summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT;
|
|
1427
|
+
const tools = summaryOptions.tools
|
|
1428
|
+
? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
|
|
1429
|
+
: undefined;
|
|
1430
|
+
const trimmed = trimRemoteCompactionInputToContextWindow(
|
|
1420
1431
|
remoteHistory,
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
|
|
1425
|
-
: undefined,
|
|
1426
|
-
reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
|
|
1427
|
-
sessionId: summaryOptions.sessionId,
|
|
1428
|
-
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1429
|
-
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1430
|
-
},
|
|
1432
|
+
model.contextWindow,
|
|
1433
|
+
instructions,
|
|
1434
|
+
tools,
|
|
1431
1435
|
);
|
|
1436
|
+
if (trimmed.rewrittenOutputs > 0) {
|
|
1437
|
+
logger.info("Rewrote trailing tool outputs before OpenAI V2 remote compaction", {
|
|
1438
|
+
model: model.id,
|
|
1439
|
+
provider: model.provider,
|
|
1440
|
+
rewrittenOutputs: trimmed.rewrittenOutputs,
|
|
1441
|
+
estimatedTokensBefore: trimmed.estimatedTokensBefore,
|
|
1442
|
+
estimatedTokensAfter: trimmed.estimatedTokensAfter,
|
|
1443
|
+
contextWindow: model.contextWindow,
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
const request = buildCompactionV2Request(model, trimmed.input, instructions, {
|
|
1447
|
+
tools,
|
|
1448
|
+
reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
|
|
1449
|
+
sessionId: summaryOptions.sessionId,
|
|
1450
|
+
promptCacheKey: summaryOptions.promptCacheKey,
|
|
1451
|
+
retainedMessageBudget: settings.v2RetainedMessageBudget,
|
|
1452
|
+
});
|
|
1432
1453
|
const remote = await withAuth(
|
|
1433
1454
|
apiKey,
|
|
1434
1455
|
key =>
|
package/src/compaction/openai.ts
CHANGED
|
@@ -44,7 +44,9 @@ import {
|
|
|
44
44
|
OPENAI_HEADER_VALUES,
|
|
45
45
|
OPENAI_HEADERS,
|
|
46
46
|
} from "@oh-my-pi/pi-catalog/wire/codex";
|
|
47
|
-
import { $env, logger, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
47
|
+
import { $env, isRecord, logger, prompt, stringifyJson, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
48
|
+
import { countTokensConservatively } from "../tokenizer";
|
|
49
|
+
import contextWindowTruncatedOutputPrompt from "./prompts/context-window-truncated-output.md" with { type: "text" };
|
|
48
50
|
|
|
49
51
|
export * from "./compaction-v2-streaming";
|
|
50
52
|
|
|
@@ -66,6 +68,141 @@ export const REMOTE_COMPACTION_TIMEOUT_MS = 180_000;
|
|
|
66
68
|
|
|
67
69
|
const DEFAULT_AZURE_API_VERSION = "v1";
|
|
68
70
|
|
|
71
|
+
export const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE = prompt.render(contextWindowTruncatedOutputPrompt);
|
|
72
|
+
|
|
73
|
+
const REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS = 256;
|
|
74
|
+
const REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE = 12_000;
|
|
75
|
+
const TOOL_RESULT_IMAGE_ATTACHMENT_TEXT = "Attached image(s) from tool result:";
|
|
76
|
+
|
|
77
|
+
interface NormalizedEstimateValue {
|
|
78
|
+
value: unknown;
|
|
79
|
+
imageTokens: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeRemoteCompactionEstimateValue(value: unknown): NormalizedEstimateValue {
|
|
83
|
+
if (Array.isArray(value)) {
|
|
84
|
+
const normalized: unknown[] = [];
|
|
85
|
+
let imageTokens = 0;
|
|
86
|
+
for (const item of value) {
|
|
87
|
+
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
88
|
+
normalized.push(result.value);
|
|
89
|
+
imageTokens += result.imageTokens;
|
|
90
|
+
}
|
|
91
|
+
return { value: normalized, imageTokens };
|
|
92
|
+
}
|
|
93
|
+
if (!value || typeof value !== "object") return { value, imageTokens: 0 };
|
|
94
|
+
|
|
95
|
+
const record = value as Record<string, unknown>;
|
|
96
|
+
if (record.type === "input_image") {
|
|
97
|
+
return {
|
|
98
|
+
value: { ...record, image_url: "<image>" },
|
|
99
|
+
imageTokens: REMOTE_COMPACTION_IMAGE_TOKEN_ESTIMATE,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const normalized: Record<string, unknown> = {};
|
|
104
|
+
let imageTokens = 0;
|
|
105
|
+
for (const [key, item] of Object.entries(record)) {
|
|
106
|
+
const result = normalizeRemoteCompactionEstimateValue(item);
|
|
107
|
+
normalized[key] = result.value;
|
|
108
|
+
imageTokens += result.imageTokens;
|
|
109
|
+
}
|
|
110
|
+
return { value: normalized, imageTokens };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface TrimRemoteCompactionInputResult {
|
|
114
|
+
input: Array<Record<string, unknown>>;
|
|
115
|
+
rewrittenOutputs: number;
|
|
116
|
+
estimatedTokensBefore: number;
|
|
117
|
+
estimatedTokensAfter: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function estimateRemoteCompactionInputTokens(
|
|
121
|
+
input: Array<Record<string, unknown>>,
|
|
122
|
+
instructions: string,
|
|
123
|
+
tools?: unknown[],
|
|
124
|
+
): number {
|
|
125
|
+
const normalized = normalizeRemoteCompactionEstimateValue({ instructions, input, ...(tools ? { tools } : {}) });
|
|
126
|
+
const serialized = stringifyJson(normalized.value) ?? "";
|
|
127
|
+
return countTokensConservatively(serialized) + normalized.imageTokens + REMOTE_COMPACTION_REQUEST_OVERHEAD_TOKENS;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function rewriteToolOutputForContextWindow(item: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
131
|
+
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
|
|
132
|
+
return { ...item, output: CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE };
|
|
133
|
+
}
|
|
134
|
+
if (item.type === "tool_search_output") {
|
|
135
|
+
return { ...item, tools: [] };
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function isToolResultImageAttachment(item: Record<string, unknown>): boolean {
|
|
141
|
+
if (item.type !== "message" || item.role !== "user" || !Array.isArray(item.content)) return false;
|
|
142
|
+
|
|
143
|
+
let hasLabel = false;
|
|
144
|
+
let hasImage = false;
|
|
145
|
+
for (const block of item.content) {
|
|
146
|
+
if (!isRecord(block)) continue;
|
|
147
|
+
if (block.type === "input_text" && block.text === TOOL_RESULT_IMAGE_ATTACHMENT_TEXT) hasLabel = true;
|
|
148
|
+
if (block.type === "input_image") hasImage = true;
|
|
149
|
+
}
|
|
150
|
+
return hasLabel && hasImage;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Preserve the full native transcript unless trailing tool outputs alone push a
|
|
155
|
+
* remote compaction request beyond the model window. Replacing only those
|
|
156
|
+
* outputs keeps call/result pairing and all earlier assistant/reasoning history,
|
|
157
|
+
* matching Codex's recovery path for oversized tool turns.
|
|
158
|
+
*/
|
|
159
|
+
export function trimRemoteCompactionInputToContextWindow(
|
|
160
|
+
input: Array<Record<string, unknown>>,
|
|
161
|
+
contextWindow: number | null | undefined,
|
|
162
|
+
instructions: string,
|
|
163
|
+
tools?: unknown[],
|
|
164
|
+
): TrimRemoteCompactionInputResult {
|
|
165
|
+
const estimatedTokensBefore = estimateRemoteCompactionInputTokens(input, instructions, tools);
|
|
166
|
+
if (!contextWindow || contextWindow <= 0 || estimatedTokensBefore <= contextWindow) {
|
|
167
|
+
return {
|
|
168
|
+
input,
|
|
169
|
+
rewrittenOutputs: 0,
|
|
170
|
+
estimatedTokensBefore,
|
|
171
|
+
estimatedTokensAfter: estimatedTokensBefore,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let rewrittenInput: Array<Record<string, unknown>> | undefined;
|
|
176
|
+
let estimatedTokensAfter = estimatedTokensBefore;
|
|
177
|
+
let rewrittenOutputs = 0;
|
|
178
|
+
for (let index = input.length - 1; index >= 0 && estimatedTokensAfter > contextWindow; index--) {
|
|
179
|
+
const item = input[index];
|
|
180
|
+
if (isToolResultImageAttachment(item)) continue;
|
|
181
|
+
const rewritten = rewriteToolOutputForContextWindow(item);
|
|
182
|
+
if (!rewritten) break;
|
|
183
|
+
rewrittenInput ??= input.slice();
|
|
184
|
+
rewrittenInput[index] = rewritten;
|
|
185
|
+
rewrittenOutputs++;
|
|
186
|
+
estimatedTokensAfter = estimateRemoteCompactionInputTokens(rewrittenInput, instructions, tools);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!rewrittenInput || estimatedTokensAfter > contextWindow) {
|
|
190
|
+
return {
|
|
191
|
+
input,
|
|
192
|
+
rewrittenOutputs: 0,
|
|
193
|
+
estimatedTokensBefore,
|
|
194
|
+
estimatedTokensAfter: estimatedTokensBefore,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
input: rewrittenInput,
|
|
200
|
+
rewrittenOutputs,
|
|
201
|
+
estimatedTokensBefore,
|
|
202
|
+
estimatedTokensAfter,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
69
206
|
/** Race the caller's signal against the request timeout; `timeoutMs <= 0` disables the watchdog. */
|
|
70
207
|
function withRequestTimeout(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal | undefined {
|
|
71
208
|
if (timeoutMs <= 0) return signal;
|
|
@@ -583,7 +720,7 @@ export function buildOpenAiNativeHistory(
|
|
|
583
720
|
|
|
584
721
|
if (hasImages && model.input.includes("image")) {
|
|
585
722
|
const contentBlocks: Array<Record<string, unknown>> = [
|
|
586
|
-
{ type: "input_text", text:
|
|
723
|
+
{ type: "input_text", text: TOOL_RESULT_IMAGE_ATTACHMENT_TEXT },
|
|
587
724
|
];
|
|
588
725
|
for (const block of message.content) {
|
|
589
726
|
if (block.type !== "image") continue;
|
|
@@ -622,12 +759,23 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
622
759
|
): Promise<OpenAiRemoteCompactionResponse> {
|
|
623
760
|
const endpoint = resolveOpenAiCompactEndpoint(model);
|
|
624
761
|
const requestModel = resolveOpenAiCompactModel(model);
|
|
762
|
+
const trimmed = trimRemoteCompactionInputToContextWindow(compactInput, model.contextWindow, instructions);
|
|
763
|
+
if (trimmed.rewrittenOutputs > 0) {
|
|
764
|
+
logger.info("Rewrote trailing tool outputs before OpenAI remote compaction", {
|
|
765
|
+
model: model.id,
|
|
766
|
+
provider: model.provider,
|
|
767
|
+
rewrittenOutputs: trimmed.rewrittenOutputs,
|
|
768
|
+
estimatedTokensBefore: trimmed.estimatedTokensBefore,
|
|
769
|
+
estimatedTokensAfter: trimmed.estimatedTokensAfter,
|
|
770
|
+
contextWindow: model.contextWindow,
|
|
771
|
+
});
|
|
772
|
+
}
|
|
625
773
|
const request: OpenAiRemoteCompactionRequest = {
|
|
626
774
|
model: requestModel,
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
input:
|
|
775
|
+
// Preserve the native transcript. Only oversized trailing tool outputs are
|
|
776
|
+
// rewritten above, reducing the request without losing assistant turns,
|
|
777
|
+
// reasoning, or call/result pairing.
|
|
778
|
+
input: trimmed.input,
|
|
631
779
|
instructions,
|
|
632
780
|
};
|
|
633
781
|
const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Output exceeded the available model context and was truncated
|
package/src/tokenizer.ts
CHANGED
|
@@ -15,3 +15,13 @@ export function countTokens(text: string | string[]): number {
|
|
|
15
15
|
return estimateTokens(text);
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
+
|
|
19
|
+
export function countTokensConservatively(text: string | string[]): number {
|
|
20
|
+
if (accurate) {
|
|
21
|
+
return countTokensNat(text);
|
|
22
|
+
} else if (Array.isArray(text)) {
|
|
23
|
+
return text.reduce((sum, value) => sum + Buffer.byteLength(value, "utf-8"), 0);
|
|
24
|
+
} else {
|
|
25
|
+
return Buffer.byteLength(text, "utf-8");
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -223,6 +223,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
223
223
|
*/
|
|
224
224
|
hasSteeringMessages?: () => boolean | SteeringQueueState | Promise<boolean | SteeringQueueState>;
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Wakes the in-flight tool interrupt watcher when a steering message is queued.
|
|
228
|
+
* The callback must not consume the queue; the loop still calls
|
|
229
|
+
* {@link hasSteeringMessages} before aborting and injects through
|
|
230
|
+
* {@link getSteeringMessages}.
|
|
231
|
+
*/
|
|
232
|
+
waitForSteeringMessages?: (signal?: AbortSignal) => Promise<void>;
|
|
233
|
+
|
|
226
234
|
/**
|
|
227
235
|
* Peeks whether IRC messages should interrupt an interruptible waiting tool.
|
|
228
236
|
*
|