@oh-my-pi/pi-agent-core 17.2.3 → 17.2.5
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 +19 -0
- package/dist/types/agent-loop.d.ts +13 -5
- package/dist/types/append-only-context.d.ts +0 -2
- package/dist/types/compaction/compaction-v2-streaming.d.ts +1 -0
- package/dist/types/compaction/compaction.d.ts +2 -0
- package/dist/types/compaction/shake.d.ts +6 -1
- package/dist/types/compaction/tool-protection.d.ts +2 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +54 -27
- package/src/agent.ts +4 -7
- package/src/append-only-context.ts +4 -4
- package/src/compaction/compaction-v2-streaming.ts +100 -39
- package/src/compaction/compaction.ts +4 -0
- package/src/compaction/shake.ts +12 -2
- package/src/compaction/tool-protection.ts +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.2.5] - 2026-08-03
|
|
6
|
+
|
|
7
|
+
### Breaking Changes
|
|
8
|
+
|
|
9
|
+
- Tool examples embedded in tool descriptions now always render in Python call syntax, and the `exampleDialect` option has been removed from `AppendOnlyContextManager` build options.
|
|
10
|
+
- Updated `normalizeTools` to accept a `NormalizeToolsOptions` configuration object (`{ injectIntent, pruneDescriptions }`) instead of positional booleans.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Fixed an issue where runs would fail with an error if an Anthropic stream was truncated after complete tool calls were streamed; the agent now recovers and executes those tool calls.
|
|
15
|
+
- Fixed an issue where artifact recovery reads could be incorrectly elided during compaction.
|
|
16
|
+
|
|
17
|
+
## [17.2.4] - 2026-08-01
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Fixed Codex V2 remote compaction bypassing the provider's live WebSocket transport before trying SSE ([#7198](https://github.com/can1357/oh-my-pi/issues/7198)).
|
|
22
|
+
- Tool calls skipped mid-batch to service queued steering/peer input now distinguish calls that never entered `tool.execute` (`SyntheticToolResultDetails`, `executed: false`) from in-flight calls that may have performed partial work (`execution: "started"`), allowing UI/telemetry consumers to render normal steering control flow without misreporting execution state ([#7199](https://github.com/can1357/oh-my-pi/issues/7199)).
|
|
23
|
+
|
|
5
24
|
## [17.2.2] - 2026-07-31
|
|
6
25
|
|
|
7
26
|
### Fixed
|
|
@@ -80,7 +80,13 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
|
|
|
80
80
|
readonly detailed: () => Promise<AgentLoopDetailedResult>;
|
|
81
81
|
};
|
|
82
82
|
export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
|
|
83
|
-
export
|
|
83
|
+
export interface NormalizeToolsOptions {
|
|
84
|
+
/** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
|
|
85
|
+
injectIntent: boolean;
|
|
86
|
+
/** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
|
|
87
|
+
pruneDescriptions?: boolean;
|
|
88
|
+
}
|
|
89
|
+
export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
|
|
84
90
|
/** Resolve the human-readable reason an abort carried. A caller that aborts via
|
|
85
91
|
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
|
|
86
92
|
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
|
|
@@ -101,13 +107,15 @@ export declare function abortReasonText(signal: AbortSignal | undefined): string
|
|
|
101
107
|
* (#4321): a provider-side stream error after tool-call emission (e.g. Codex
|
|
102
108
|
* websocket close) was surfaced by the CLI as if the local tool had failed.
|
|
103
109
|
*
|
|
104
|
-
* `source` names the
|
|
105
|
-
*
|
|
106
|
-
*
|
|
110
|
+
* `source` names the state that prevented execution — either an assistant-side
|
|
111
|
+
* turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
|
|
112
|
+
* still-pending call to service queued steering/peer input (`interrupt_skipped`).
|
|
113
|
+
* `upstreamError` is the provider-reported message when the turn ended with
|
|
114
|
+
* `stopReason === "error"`.
|
|
107
115
|
*/
|
|
108
116
|
export interface SyntheticToolResultDetails {
|
|
109
117
|
__synthetic: true;
|
|
110
|
-
source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length";
|
|
118
|
+
source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length" | "interrupt_skipped";
|
|
111
119
|
executed: false;
|
|
112
120
|
upstreamError?: string;
|
|
113
121
|
}
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
* message delta is a cache miss each turn.
|
|
15
15
|
*/
|
|
16
16
|
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
|
17
|
-
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
18
17
|
import type { AgentContext } from "./types.js";
|
|
19
18
|
/** Frozen system prompt + tool spec snapshot. */
|
|
20
19
|
export interface StablePrefixSnapshot {
|
|
@@ -26,7 +25,6 @@ export interface StablePrefixSnapshot {
|
|
|
26
25
|
export interface BuildOptions {
|
|
27
26
|
/** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
|
28
27
|
intentTracing: boolean;
|
|
29
|
-
exampleDialect?: Dialect;
|
|
30
28
|
/** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
|
|
31
29
|
pruneToolDescriptions?: boolean;
|
|
32
30
|
}
|
|
@@ -68,6 +68,7 @@ export declare function requestCompactionV2Streaming(model: Model, apiKey: strin
|
|
|
68
68
|
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
69
69
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
70
70
|
codexCompaction?: CodexCompactionContext;
|
|
71
|
+
preferWebsockets?: boolean;
|
|
71
72
|
}): Promise<CompactionV2Response>;
|
|
72
73
|
/** Build Codex-style V2 replacement history from prompt input plus compaction output. */
|
|
73
74
|
export declare function buildCompactionV2ReplacementHistory(input: unknown[], compactionItem: Record<string, unknown>, retainedMessageBudget?: number): {
|
|
@@ -200,6 +200,8 @@ export interface SummaryOptions {
|
|
|
200
200
|
promptCacheKey?: string;
|
|
201
201
|
/** Mutable provider state used to keep Codex compaction on the live session identity. */
|
|
202
202
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
203
|
+
/** Whether Codex remote compaction should prefer the provider WebSocket transport. */
|
|
204
|
+
preferWebsockets?: boolean;
|
|
203
205
|
/** Classification shared by every provider request in this logical compaction. */
|
|
204
206
|
codexCompaction?: CodexCompactionContext;
|
|
205
207
|
/** Provider-visible tools for remote compaction transports that replay native tool history. */
|
|
@@ -31,8 +31,13 @@ export interface ShakeConfig {
|
|
|
31
31
|
}
|
|
32
32
|
/** Auto-shake config: protects the live tail, conservative thresholds. */
|
|
33
33
|
export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Manual `/shake`: aggressive — drops every eligible region across history,
|
|
36
|
+
* artifact recovery reads included (the user's full escape hatch).
|
|
37
|
+
*/
|
|
35
38
|
export declare const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig;
|
|
39
|
+
/** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
|
|
40
|
+
export declare const RESCUE_SHAKE_CONFIG: ShakeConfig;
|
|
36
41
|
/** A located eligible region. */
|
|
37
42
|
export interface ToolResultShakeRegion {
|
|
38
43
|
kind: "toolResult";
|
|
@@ -14,4 +14,6 @@ export declare function collectToolCallsById(entries: readonly SessionEntry[]):
|
|
|
14
14
|
*/
|
|
15
15
|
export declare function getReadToolPath({ toolResult, toolCall }: ProtectedToolContext): string | undefined;
|
|
16
16
|
export declare function isSkillReadToolResult(context: ProtectedToolContext): boolean;
|
|
17
|
+
/** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
|
|
18
|
+
export declare function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean;
|
|
17
19
|
export declare function isProtectedToolResult(toolResult: ToolResultMessage, toolCall: AgentToolCall | undefined, matchers: readonly ProtectedToolMatcher[]): boolean;
|
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.2.
|
|
4
|
+
"version": "17.2.5",
|
|
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.2.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.2.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.2.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.2.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.2.
|
|
38
|
+
"@oh-my-pi/pi-ai": "17.2.5",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "17.2.5",
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.2.5",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.2.5",
|
|
42
|
+
"@oh-my-pi/pi-wire": "17.2.5",
|
|
43
|
+
"@oh-my-pi/snapcompact": "17.2.5",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -42,7 +42,6 @@ import {
|
|
|
42
42
|
recoverHarmonyToolCall,
|
|
43
43
|
signalListLabel,
|
|
44
44
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
45
|
-
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
46
45
|
import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
47
46
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
48
47
|
import { agentPauseGate } from "./pause";
|
|
@@ -829,13 +828,16 @@ function injectIntentIntoSchema(
|
|
|
829
828
|
};
|
|
830
829
|
}
|
|
831
830
|
|
|
832
|
-
export
|
|
833
|
-
|
|
834
|
-
injectIntent: boolean
|
|
835
|
-
|
|
836
|
-
pruneDescriptions
|
|
837
|
-
|
|
838
|
-
|
|
831
|
+
export interface NormalizeToolsOptions {
|
|
832
|
+
/** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
|
|
833
|
+
injectIntent: boolean;
|
|
834
|
+
/** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
|
|
835
|
+
pruneDescriptions?: boolean;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"] {
|
|
839
|
+
const pruneDescriptions = options.pruneDescriptions === true;
|
|
840
|
+
const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
|
|
839
841
|
return tools?.map(t => {
|
|
840
842
|
const intentMode = resolveIntentMode(t.intent);
|
|
841
843
|
const doInjectIntent = injectIntent && intentMode !== "omit";
|
|
@@ -853,9 +855,7 @@ export function normalizeTools(
|
|
|
853
855
|
let parameters = toolWireSchema(t) as TSchema;
|
|
854
856
|
if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
|
|
855
857
|
const description = t.description ?? "";
|
|
856
|
-
const examplesBlock =
|
|
857
|
-
? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
|
|
858
|
-
: "";
|
|
858
|
+
const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
|
|
859
859
|
const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
|
|
860
860
|
return { ...t, parameters, description: finalDescription };
|
|
861
861
|
});
|
|
@@ -1498,21 +1498,22 @@ async function prepareProviderCall(
|
|
|
1498
1498
|
const llmMessages = await config.convertToLlm(messages);
|
|
1499
1499
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1500
1500
|
const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1501
|
-
const exampleDialect = ownedDialect ?? preferredDialect(model.id);
|
|
1502
1501
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1503
1502
|
let llmContext: Context;
|
|
1504
1503
|
if (config.appendOnlyContext) {
|
|
1505
1504
|
config.appendOnlyContext.syncMessages(normalizedMessages);
|
|
1506
1505
|
llmContext = config.appendOnlyContext.build(context, {
|
|
1507
1506
|
intentTracing: !!config.intentTracing,
|
|
1508
|
-
exampleDialect,
|
|
1509
1507
|
pruneToolDescriptions,
|
|
1510
1508
|
});
|
|
1511
1509
|
} else {
|
|
1512
1510
|
llmContext = {
|
|
1513
1511
|
systemPrompt: context.systemPrompt,
|
|
1514
1512
|
messages: normalizedMessages,
|
|
1515
|
-
tools: normalizeTools(context.tools,
|
|
1513
|
+
tools: normalizeTools(context.tools, {
|
|
1514
|
+
injectIntent: !!config.intentTracing,
|
|
1515
|
+
pruneDescriptions: pruneToolDescriptions,
|
|
1516
|
+
}),
|
|
1516
1517
|
};
|
|
1517
1518
|
}
|
|
1518
1519
|
if (config.transformProviderContext) {
|
|
@@ -1928,8 +1929,10 @@ function recoverTransientErrorToolTurn(
|
|
|
1928
1929
|
if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
|
|
1929
1930
|
}
|
|
1930
1931
|
if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
|
|
1932
|
+
const errorText = `${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`;
|
|
1931
1933
|
if (
|
|
1932
|
-
!AIError.isStreamReadErrorText(
|
|
1934
|
+
!AIError.isStreamReadErrorText(errorText) &&
|
|
1935
|
+
!AIError.isStreamEnvelopeErrorText(errorText) &&
|
|
1933
1936
|
!AIError.isTransientStreamParseError(message.errorMessage) &&
|
|
1934
1937
|
!AIError.isTransientStreamParseError(message.stopDetails?.explanation)
|
|
1935
1938
|
)
|
|
@@ -2454,6 +2457,7 @@ async function executeToolCalls(
|
|
|
2454
2457
|
let isError = false;
|
|
2455
2458
|
let caughtError: unknown;
|
|
2456
2459
|
let completedToolExecution = false;
|
|
2460
|
+
let executionStarted = false;
|
|
2457
2461
|
|
|
2458
2462
|
await runInActiveSpan(toolSpan, async () => {
|
|
2459
2463
|
try {
|
|
@@ -2487,6 +2491,7 @@ async function executeToolCalls(
|
|
|
2487
2491
|
providerMetadata: toolCall.providerMetadata,
|
|
2488
2492
|
})
|
|
2489
2493
|
: undefined;
|
|
2494
|
+
executionStarted = true;
|
|
2490
2495
|
const rawResult = await tool.execute(
|
|
2491
2496
|
toolCall.id,
|
|
2492
2497
|
executionArgs,
|
|
@@ -2557,12 +2562,12 @@ async function executeToolCalls(
|
|
|
2557
2562
|
const interrupted = interruptState.triggered;
|
|
2558
2563
|
const perToolAborted = record.signal.aborted;
|
|
2559
2564
|
const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
|
|
2560
|
-
if (interrupted &&
|
|
2561
|
-
// This tool's own signal fired AND it failed to produce a result
|
|
2562
|
-
//
|
|
2563
|
-
//
|
|
2565
|
+
if (interrupted && abortedDuringExecution) {
|
|
2566
|
+
// This tool's own signal fired AND it failed to produce a result. The
|
|
2567
|
+
// execution may already have performed partial work before throwing on
|
|
2568
|
+
// abort, so preserve that distinction in the placeholder metadata.
|
|
2564
2569
|
record.skipped = true;
|
|
2565
|
-
emitToolResult(record, createSkippedToolResult(interruptState.source), true);
|
|
2570
|
+
emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
|
|
2566
2571
|
} else {
|
|
2567
2572
|
// No interrupt on this signal, or the tool finished before the interrupt landed
|
|
2568
2573
|
// (`completedToolExecution`) — even if the signal aborted around completion. Keep
|
|
@@ -2703,7 +2708,7 @@ async function executeToolCalls(
|
|
|
2703
2708
|
toolName: record.toolCall.name,
|
|
2704
2709
|
status: "skipped",
|
|
2705
2710
|
});
|
|
2706
|
-
emitToolResult(record, createSkippedToolResult(interruptState.source), true);
|
|
2711
|
+
emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
|
|
2707
2712
|
}
|
|
2708
2713
|
}
|
|
2709
2714
|
|
|
@@ -2723,17 +2728,34 @@ async function executeToolCalls(
|
|
|
2723
2728
|
* (#4321): a provider-side stream error after tool-call emission (e.g. Codex
|
|
2724
2729
|
* websocket close) was surfaced by the CLI as if the local tool had failed.
|
|
2725
2730
|
*
|
|
2726
|
-
* `source` names the
|
|
2727
|
-
*
|
|
2728
|
-
*
|
|
2731
|
+
* `source` names the state that prevented execution — either an assistant-side
|
|
2732
|
+
* turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
|
|
2733
|
+
* still-pending call to service queued steering/peer input (`interrupt_skipped`).
|
|
2734
|
+
* `upstreamError` is the provider-reported message when the turn ended with
|
|
2735
|
+
* `stopReason === "error"`.
|
|
2729
2736
|
*/
|
|
2730
2737
|
export interface SyntheticToolResultDetails {
|
|
2731
2738
|
__synthetic: true;
|
|
2732
|
-
source:
|
|
2739
|
+
source:
|
|
2740
|
+
| "assistant_stop_aborted"
|
|
2741
|
+
| "assistant_stop_error"
|
|
2742
|
+
| "assistant_stop_skipped"
|
|
2743
|
+
| "assistant_stop_length"
|
|
2744
|
+
| "interrupt_skipped";
|
|
2733
2745
|
executed: false;
|
|
2734
2746
|
upstreamError?: string;
|
|
2735
2747
|
}
|
|
2736
2748
|
|
|
2749
|
+
/**
|
|
2750
|
+
* Metadata for an interrupt-aborted call that entered `tool.execute()` but
|
|
2751
|
+
* threw before returning a usable result. It may have performed partial work.
|
|
2752
|
+
*/
|
|
2753
|
+
interface InterruptedToolResultDetails {
|
|
2754
|
+
__interrupted: true;
|
|
2755
|
+
source: "interrupt_skipped";
|
|
2756
|
+
execution: "started";
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2737
2759
|
/**
|
|
2738
2760
|
* Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
|
|
2739
2761
|
* a tool_result emitted for a tool call the assistant never invoked (see
|
|
@@ -2844,7 +2866,10 @@ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unk
|
|
|
2844
2866
|
};
|
|
2845
2867
|
}
|
|
2846
2868
|
|
|
2847
|
-
function createSkippedToolResult(
|
|
2869
|
+
function createSkippedToolResult(
|
|
2870
|
+
source: SteeringInterruptSource | "irc" | undefined,
|
|
2871
|
+
executionStarted: boolean,
|
|
2872
|
+
): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
|
|
2848
2873
|
let reason = "pending steering message";
|
|
2849
2874
|
let blocker = "queued message";
|
|
2850
2875
|
if (source === "user") {
|
|
@@ -2864,6 +2889,8 @@ function createSkippedToolResult(source: SteeringInterruptSource | "irc" | undef
|
|
|
2864
2889
|
text: `Skipped due to ${reason}. Do not count this skipped result as completed work or verification. After the ${blocker} is handled on the next step, retry the skipped tool if it is still needed.`,
|
|
2865
2890
|
},
|
|
2866
2891
|
],
|
|
2867
|
-
details:
|
|
2892
|
+
details: executionStarted
|
|
2893
|
+
? { __interrupted: true, source: "interrupt_skipped", execution: "started" }
|
|
2894
|
+
: { __synthetic: true, source: "interrupt_skipped", executed: false },
|
|
2868
2895
|
};
|
|
2869
2896
|
}
|
package/src/agent.ts
CHANGED
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
} from "@oh-my-pi/pi-ai";
|
|
25
25
|
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
26
26
|
import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
27
|
-
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
28
27
|
import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
|
|
29
28
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
30
29
|
import {
|
|
@@ -771,12 +770,10 @@ export class Agent {
|
|
|
771
770
|
const messages = normalizeMessagesForProvider(llmMessages, model);
|
|
772
771
|
const tools = ownedDialect
|
|
773
772
|
? []
|
|
774
|
-
: (normalizeTools(
|
|
775
|
-
this.#
|
|
776
|
-
this.#
|
|
777
|
-
|
|
778
|
-
this.#pruneToolDescriptions,
|
|
779
|
-
) ?? []);
|
|
773
|
+
: (normalizeTools(this.#toolsForModel(model), {
|
|
774
|
+
injectIntent: this.#intentTracing,
|
|
775
|
+
pruneDescriptions: this.#pruneToolDescriptions,
|
|
776
|
+
}) ?? []);
|
|
780
777
|
let context: Context = { systemPrompt, messages, tools };
|
|
781
778
|
if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
|
|
782
779
|
return context;
|
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
|
|
18
|
-
import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
|
|
19
18
|
import { normalizeTools } from "./agent-loop";
|
|
20
19
|
import type { AgentContext } from "./types";
|
|
21
20
|
|
|
@@ -34,7 +33,6 @@ export interface StablePrefixSnapshot {
|
|
|
34
33
|
export interface BuildOptions {
|
|
35
34
|
/** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
|
|
36
35
|
intentTracing: boolean;
|
|
37
|
-
exampleDialect?: Dialect;
|
|
38
36
|
/** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
|
|
39
37
|
pruneToolDescriptions?: boolean;
|
|
40
38
|
}
|
|
@@ -317,7 +315,10 @@ export class AppendOnlyContextManager {
|
|
|
317
315
|
function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
|
|
318
316
|
const systemPrompt = [...context.systemPrompt];
|
|
319
317
|
const tools =
|
|
320
|
-
normalizeTools(context.tools,
|
|
318
|
+
normalizeTools(context.tools, {
|
|
319
|
+
injectIntent: options.intentTracing,
|
|
320
|
+
pruneDescriptions: options.pruneToolDescriptions,
|
|
321
|
+
}) ?? [];
|
|
321
322
|
return {
|
|
322
323
|
systemPrompt,
|
|
323
324
|
tools,
|
|
@@ -337,7 +338,6 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
|
|
|
337
338
|
cw: t.customWireName,
|
|
338
339
|
})),
|
|
339
340
|
i: options.intentTracing,
|
|
340
|
-
ex: options.exampleDialect,
|
|
341
341
|
pd: options.pruneToolDescriptions,
|
|
342
342
|
});
|
|
343
343
|
let hash = 0;
|
|
@@ -13,7 +13,9 @@ import { applyCodexResponsesLiteShape } from "@oh-my-pi/pi-ai/providers/openai-c
|
|
|
13
13
|
import {
|
|
14
14
|
createOpenAICodexCompactionRequestContext,
|
|
15
15
|
createOpenAICodexCompatibilityMetadata,
|
|
16
|
+
type OpenAICodexCompactionBody,
|
|
16
17
|
type OpenAICodexCompatibilityMetadata,
|
|
18
|
+
openCodexCompactionEventStream,
|
|
17
19
|
} from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
18
20
|
import {
|
|
19
21
|
getOpenAIPromptCacheKey,
|
|
@@ -128,6 +130,14 @@ function isOpenAiV2CompatibleModel(model: Model): boolean {
|
|
|
128
130
|
return api === "openai-responses" || api === "azure-openai-responses" || api === "openai-codex-responses";
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
function shouldUseCodexProviderTransport(model: Model): model is Model<"openai-codex-responses"> {
|
|
134
|
+
return (
|
|
135
|
+
model.api === "openai-codex-responses" &&
|
|
136
|
+
model.remoteCompaction?.v2Endpoint === undefined &&
|
|
137
|
+
model.remoteCompaction?.streamingEndpoint === undefined
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
function resolveOpenAiResponsesEndpoint(baseUrl: string | undefined): string {
|
|
132
142
|
const rawBase = baseUrl && baseUrl.length > 0 ? baseUrl : "https://api.openai.com/v1";
|
|
133
143
|
const normalizedBase = rawBase.replace(/\/+$/, "");
|
|
@@ -228,6 +238,7 @@ export async function requestCompactionV2Streaming(
|
|
|
228
238
|
retryWait?: (delayMs: number, signal?: AbortSignal) => Promise<void>;
|
|
229
239
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
230
240
|
codexCompaction?: CodexCompactionContext;
|
|
241
|
+
preferWebsockets?: boolean;
|
|
231
242
|
},
|
|
232
243
|
): Promise<CompactionV2Response> {
|
|
233
244
|
const endpoint = getCompactionV2Endpoint(model);
|
|
@@ -238,31 +249,29 @@ export async function requestCompactionV2Streaming(
|
|
|
238
249
|
const fetchImpl = options?.fetch ?? globalThis.fetch;
|
|
239
250
|
const retryWait = options?.retryWait ?? ((delayMs: number) => Bun.sleep(delayMs));
|
|
240
251
|
const isCodexResponses = compactionV2Api(model) === "openai-codex-responses" || model.provider === "openai-codex";
|
|
241
|
-
const codexMetadata =
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
+
const codexMetadata =
|
|
253
|
+
isCodexResponses && !shouldUseCodexProviderTransport(model)
|
|
254
|
+
? createOpenAICodexCompatibilityMetadata({
|
|
255
|
+
sessionId: request.sessionId,
|
|
256
|
+
providerSessionState: options?.providerSessionState,
|
|
257
|
+
requestKind: "compaction",
|
|
258
|
+
compaction: createOpenAICodexCompactionRequestContext({
|
|
259
|
+
context: options?.codexCompaction,
|
|
260
|
+
implementation: "responses_compaction_v2",
|
|
261
|
+
}),
|
|
262
|
+
})
|
|
263
|
+
: undefined;
|
|
252
264
|
let lastError: Error | undefined;
|
|
253
265
|
|
|
254
266
|
for (let attempt = 0; attempt <= V2_COMPACTION_MAX_RETRIES; attempt++) {
|
|
255
267
|
const timeoutSignal = withRequestTimeout(signal, options?.timeoutMs ?? V2_COMPACTION_TIMEOUT_MS);
|
|
256
268
|
try {
|
|
257
|
-
return await attemptCompactionV2Streaming(
|
|
258
|
-
endpoint,
|
|
259
|
-
apiKey,
|
|
260
|
-
model,
|
|
261
|
-
request,
|
|
262
|
-
fetchImpl,
|
|
263
|
-
timeoutSignal,
|
|
269
|
+
return await attemptCompactionV2Streaming(endpoint, apiKey, model, request, fetchImpl, timeoutSignal, {
|
|
264
270
|
codexMetadata,
|
|
265
|
-
|
|
271
|
+
providerSessionState: options?.providerSessionState,
|
|
272
|
+
codexCompaction: options?.codexCompaction,
|
|
273
|
+
preferWebsockets: options?.preferWebsockets,
|
|
274
|
+
});
|
|
266
275
|
} catch (err) {
|
|
267
276
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
268
277
|
if (signal?.aborted) throw error;
|
|
@@ -292,15 +301,20 @@ async function attemptCompactionV2Streaming(
|
|
|
292
301
|
model: Model,
|
|
293
302
|
request: CompactionV2Request,
|
|
294
303
|
fetchImpl: FetchImpl,
|
|
295
|
-
signal
|
|
296
|
-
|
|
304
|
+
signal: AbortSignal | undefined,
|
|
305
|
+
options: {
|
|
306
|
+
codexMetadata?: OpenAICodexCompatibilityMetadata;
|
|
307
|
+
providerSessionState?: Map<string, ProviderSessionState>;
|
|
308
|
+
codexCompaction?: CodexCompactionContext;
|
|
309
|
+
preferWebsockets?: boolean;
|
|
310
|
+
},
|
|
297
311
|
): Promise<CompactionV2Response> {
|
|
298
312
|
// Faithful to Codex: append the compaction trigger as the final input item
|
|
299
313
|
// of an otherwise-normal Responses request, then stream the result. `store`
|
|
300
314
|
// stays false — compaction must never persist a server-side response object.
|
|
301
315
|
const cacheOptions = { sessionId: request.sessionId, promptCacheKey: request.promptCacheKey };
|
|
302
316
|
const promptCacheKey = getOpenAIPromptCacheKey(cacheOptions);
|
|
303
|
-
const body:
|
|
317
|
+
const body: OpenAICodexCompactionBody = {
|
|
304
318
|
model: request.model,
|
|
305
319
|
input: [...request.input, COMPACTION_TRIGGER_ITEM],
|
|
306
320
|
instructions: request.instructions,
|
|
@@ -318,8 +332,8 @@ async function attemptCompactionV2Streaming(
|
|
|
318
332
|
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
|
319
333
|
...(request.tools && request.tools.length > 0 ? { tools: request.tools, tool_choice: "auto" } : {}),
|
|
320
334
|
};
|
|
321
|
-
if (codexMetadata) {
|
|
322
|
-
body.client_metadata = codexMetadata.clientMetadata;
|
|
335
|
+
if (options.codexMetadata) {
|
|
336
|
+
body.client_metadata = options.codexMetadata.clientMetadata;
|
|
323
337
|
}
|
|
324
338
|
// Responses Lite models take the same rewrite on the compaction stream:
|
|
325
339
|
// instructions/tools ride as input items (codex-rs `compact_remote_v2`
|
|
@@ -327,9 +341,27 @@ async function attemptCompactionV2Streaming(
|
|
|
327
341
|
if (model.useResponsesLite) {
|
|
328
342
|
applyCodexResponsesLiteShape(body);
|
|
329
343
|
}
|
|
344
|
+
|
|
345
|
+
if (shouldUseCodexProviderTransport(model)) {
|
|
346
|
+
const eventStream = await openCodexCompactionEventStream(model, body, {
|
|
347
|
+
apiKey,
|
|
348
|
+
signal,
|
|
349
|
+
fetch: fetchImpl,
|
|
350
|
+
sessionId: request.sessionId,
|
|
351
|
+
providerSessionState: options.providerSessionState,
|
|
352
|
+
preferWebsockets: options.preferWebsockets,
|
|
353
|
+
responsesLite: model.useResponsesLite,
|
|
354
|
+
codexCompaction: createOpenAICodexCompactionRequestContext({
|
|
355
|
+
context: options.codexCompaction,
|
|
356
|
+
implementation: "responses_compaction_v2",
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
return collectCompactionV2Events(eventStream, request);
|
|
360
|
+
}
|
|
361
|
+
|
|
330
362
|
const response = await fetchImpl(endpoint, {
|
|
331
363
|
method: "POST",
|
|
332
|
-
headers: buildCompactionV2Headers(model, apiKey, request, codexMetadata),
|
|
364
|
+
headers: buildCompactionV2Headers(model, apiKey, request, options.codexMetadata),
|
|
333
365
|
body: stringifyJson(body),
|
|
334
366
|
signal,
|
|
335
367
|
});
|
|
@@ -400,6 +432,33 @@ function buildCompactionV2Headers(
|
|
|
400
432
|
return headers;
|
|
401
433
|
}
|
|
402
434
|
|
|
435
|
+
interface CompactionV2CollectionState {
|
|
436
|
+
outputItemCount: number;
|
|
437
|
+
compactionItems: Array<Record<string, unknown>>;
|
|
438
|
+
sawCompleted: boolean;
|
|
439
|
+
usage: CompactionV2Usage | undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function createCompactionV2CollectionState(): CompactionV2CollectionState {
|
|
443
|
+
return {
|
|
444
|
+
outputItemCount: 0,
|
|
445
|
+
compactionItems: [],
|
|
446
|
+
sawCompleted: false,
|
|
447
|
+
usage: undefined,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function collectCompactionV2Events(
|
|
452
|
+
events: AsyncIterable<Record<string, unknown>>,
|
|
453
|
+
request: CompactionV2Request,
|
|
454
|
+
): Promise<CompactionV2Response> {
|
|
455
|
+
const state = createCompactionV2CollectionState();
|
|
456
|
+
for await (const event of events) {
|
|
457
|
+
handleCompactionV2Event(event, undefined, state);
|
|
458
|
+
}
|
|
459
|
+
return finishCompactionV2Collection(state, request);
|
|
460
|
+
}
|
|
461
|
+
|
|
403
462
|
async function collectCompactionV2Output(
|
|
404
463
|
response: Response,
|
|
405
464
|
request: CompactionV2Request,
|
|
@@ -409,13 +468,7 @@ async function collectCompactionV2Output(
|
|
|
409
468
|
throw new Error("No response body for V2 compaction streaming");
|
|
410
469
|
}
|
|
411
470
|
|
|
412
|
-
const state =
|
|
413
|
-
outputItemCount: 0,
|
|
414
|
-
compactionItems: [] as Array<Record<string, unknown>>,
|
|
415
|
-
sawCompleted: false,
|
|
416
|
-
usage: undefined as CompactionV2Usage | undefined,
|
|
417
|
-
};
|
|
418
|
-
|
|
471
|
+
const state = createCompactionV2CollectionState();
|
|
419
472
|
try {
|
|
420
473
|
const decoder = new TextDecoder();
|
|
421
474
|
let buffer = "";
|
|
@@ -462,6 +515,13 @@ async function collectCompactionV2Output(
|
|
|
462
515
|
reader.releaseLock();
|
|
463
516
|
}
|
|
464
517
|
|
|
518
|
+
return finishCompactionV2Collection(state, request);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function finishCompactionV2Collection(
|
|
522
|
+
state: CompactionV2CollectionState,
|
|
523
|
+
request: CompactionV2Request,
|
|
524
|
+
): CompactionV2Response {
|
|
465
525
|
if (!state.sawCompleted) {
|
|
466
526
|
throw new Error("V2 compaction stream closed before response.completed");
|
|
467
527
|
}
|
|
@@ -477,7 +537,6 @@ async function collectCompactionV2Output(
|
|
|
477
537
|
compactionItem,
|
|
478
538
|
request.retainedMessageBudget,
|
|
479
539
|
);
|
|
480
|
-
|
|
481
540
|
return {
|
|
482
541
|
compactionItem,
|
|
483
542
|
replacementHistory,
|
|
@@ -490,12 +549,7 @@ async function collectCompactionV2Output(
|
|
|
490
549
|
function handleCompactionV2SseEvent(
|
|
491
550
|
data: string,
|
|
492
551
|
eventName: string | undefined,
|
|
493
|
-
state:
|
|
494
|
-
outputItemCount: number;
|
|
495
|
-
compactionItems: Array<Record<string, unknown>>;
|
|
496
|
-
sawCompleted: boolean;
|
|
497
|
-
usage: CompactionV2Usage | undefined;
|
|
498
|
-
},
|
|
552
|
+
state: CompactionV2CollectionState,
|
|
499
553
|
): void {
|
|
500
554
|
if (data === "[DONE]") return;
|
|
501
555
|
let event: Record<string, unknown>;
|
|
@@ -504,7 +558,14 @@ function handleCompactionV2SseEvent(
|
|
|
504
558
|
} catch (err) {
|
|
505
559
|
throw new Error(`V2 compaction stream parse failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
506
560
|
}
|
|
561
|
+
handleCompactionV2Event(event, eventName, state);
|
|
562
|
+
}
|
|
507
563
|
|
|
564
|
+
function handleCompactionV2Event(
|
|
565
|
+
event: Record<string, unknown>,
|
|
566
|
+
eventName: string | undefined,
|
|
567
|
+
state: CompactionV2CollectionState,
|
|
568
|
+
): void {
|
|
508
569
|
const type = typeof event.type === "string" ? event.type : eventName;
|
|
509
570
|
if (type === "response.output_item.done") {
|
|
510
571
|
state.outputItemCount++;
|
|
@@ -809,6 +809,8 @@ export interface SummaryOptions {
|
|
|
809
809
|
promptCacheKey?: string;
|
|
810
810
|
/** Mutable provider state used to keep Codex compaction on the live session identity. */
|
|
811
811
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
812
|
+
/** Whether Codex remote compaction should prefer the provider WebSocket transport. */
|
|
813
|
+
preferWebsockets?: boolean;
|
|
812
814
|
/** Classification shared by every provider request in this logical compaction. */
|
|
813
815
|
codexCompaction?: CodexCompactionContext;
|
|
814
816
|
/** Provider-visible tools for remote compaction transports that replay native tool history. */
|
|
@@ -1432,6 +1434,7 @@ export async function compact(
|
|
|
1432
1434
|
sessionId: options?.sessionId,
|
|
1433
1435
|
promptCacheKey: options?.promptCacheKey,
|
|
1434
1436
|
providerSessionState: options?.providerSessionState,
|
|
1437
|
+
preferWebsockets: options?.preferWebsockets,
|
|
1435
1438
|
codexCompaction: options?.codexCompaction,
|
|
1436
1439
|
tools: options?.tools,
|
|
1437
1440
|
fetch: options?.fetch,
|
|
@@ -1509,6 +1512,7 @@ export async function compact(
|
|
|
1509
1512
|
requestCompactionV2Streaming(model, key, request, signal, {
|
|
1510
1513
|
fetch: summaryOptions.fetch,
|
|
1511
1514
|
providerSessionState: summaryOptions.providerSessionState,
|
|
1515
|
+
preferWebsockets: summaryOptions.preferWebsockets,
|
|
1512
1516
|
codexCompaction: summaryOptions.codexCompaction,
|
|
1513
1517
|
}),
|
|
1514
1518
|
{ signal },
|
package/src/compaction/shake.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type { CustomMessageEntry, SessionEntry, SessionMessageEntry } from "./en
|
|
|
18
18
|
import { invalidateMessageCache } from "./message-cache";
|
|
19
19
|
import {
|
|
20
20
|
collectToolCallsById,
|
|
21
|
+
isArtifactRecoveryToolResult,
|
|
21
22
|
isProtectedToolResult,
|
|
22
23
|
isSkillReadToolResult,
|
|
23
24
|
type ProtectedToolMatcher,
|
|
@@ -46,11 +47,14 @@ export interface ShakeConfig {
|
|
|
46
47
|
export const DEFAULT_SHAKE_CONFIG: ShakeConfig = {
|
|
47
48
|
protectTokens: 16_000,
|
|
48
49
|
minSavings: 4_000,
|
|
49
|
-
protectedTools: ["skill", isSkillReadToolResult],
|
|
50
|
+
protectedTools: ["skill", isSkillReadToolResult, isArtifactRecoveryToolResult],
|
|
50
51
|
fenceMinTokens: 400,
|
|
51
52
|
};
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Manual `/shake`: aggressive — drops every eligible region across history,
|
|
56
|
+
* artifact recovery reads included (the user's full escape hatch).
|
|
57
|
+
*/
|
|
54
58
|
export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
|
|
55
59
|
protectTokens: 0,
|
|
56
60
|
minSavings: 0,
|
|
@@ -58,6 +62,12 @@ export const AGGRESSIVE_SHAKE_CONFIG: ShakeConfig = {
|
|
|
58
62
|
fenceMinTokens: 400,
|
|
59
63
|
};
|
|
60
64
|
|
|
65
|
+
/** Compaction dead-end rescue: aggressive reach, but artifact recovery reads stay protected. */
|
|
66
|
+
export const RESCUE_SHAKE_CONFIG: ShakeConfig = {
|
|
67
|
+
...AGGRESSIVE_SHAKE_CONFIG,
|
|
68
|
+
protectedTools: [...AGGRESSIVE_SHAKE_CONFIG.protectedTools, isArtifactRecoveryToolResult],
|
|
69
|
+
};
|
|
70
|
+
|
|
61
71
|
/** Rough token cost of a placeholder line; used only for the savings gate. */
|
|
62
72
|
const PLACEHOLDER_TOKEN_ESTIMATE = 16;
|
|
63
73
|
|
|
@@ -39,6 +39,16 @@ export function isSkillReadToolResult(context: ProtectedToolContext): boolean {
|
|
|
39
39
|
return getReadToolPath(context)?.startsWith(SKILL_INTERNAL_URL_PREFIX) ?? false;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
const ARTIFACT_INTERNAL_URL_PREFIX = "artifact://";
|
|
43
|
+
|
|
44
|
+
/** Recovery reads of session artifacts — eliding one only mints another artifact and can repeat indefinitely. */
|
|
45
|
+
export function isArtifactRecoveryToolResult(context: ProtectedToolContext): boolean {
|
|
46
|
+
if (getReadToolPath(context)?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX)) return true;
|
|
47
|
+
const meta = (context.toolResult.details as { meta?: { source?: { type?: string; value?: string } } } | undefined)
|
|
48
|
+
?.meta;
|
|
49
|
+
return meta?.source?.type === "internal" && (meta.source.value?.startsWith(ARTIFACT_INTERNAL_URL_PREFIX) ?? false);
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
export function isProtectedToolResult(
|
|
43
53
|
toolResult: ToolResultMessage,
|
|
44
54
|
toolCall: AgentToolCall | undefined,
|