@linxiraos/pi-agent-core 1.1.5 → 1.1.7
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 +8 -5
- package/dist/types/agent-loop.d.ts +15 -2
- package/dist/types/compaction/openai.d.ts +1 -1
- package/dist/types/compaction/shake.d.ts +13 -9
- package/dist/types/tokenizer.d.ts +8 -8
- package/package.json +8 -8
- package/src/agent-loop.ts +63 -3
- package/src/agent.ts +10 -0
- package/src/compaction/compaction.ts +1 -0
- package/src/compaction/messages.ts +10 -1
- package/src/compaction/openai.ts +4 -22
- package/src/compaction/shake.ts +39 -19
- package/src/tokenizer.ts +48 -23
package/CHANGELOG.md
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [1.1.
|
|
5
|
+
## [1.1.7] - 2026-09-01
|
|
6
6
|
|
|
7
|
-
- 同步上游 OMP v18.0.
|
|
7
|
+
- 同步上游 OMP v18.0.11(`b8ce33a58911c26bed1d84f0db9a5e2e727c49a2`)。
|
|
8
8
|
|
|
9
|
-
## [1.1.
|
|
9
|
+
## [1.1.6] - 2026-08-30
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- 同步上游 OMP v18.0.10(`33cc6b9a043a`)。
|
|
12
|
+
- 同步上游 OMP v18.0.9(`cc14e04f075d`)。
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
## [1.1.5] - 2026-08-26
|
|
15
|
+
|
|
16
|
+
- 同步上游 OMP v18.0.5 / v18.0.6(内部运行时与构建改进)。
|
|
14
17
|
|
|
15
18
|
## [1.1.2] - 2026-08-25
|
|
16
19
|
|
|
@@ -38,13 +38,26 @@ export declare function resolveOwnedDialectFromEnv(value: string | undefined): D
|
|
|
38
38
|
* The prompt is added to the context and events are emitted for it.
|
|
39
39
|
*/
|
|
40
40
|
export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
41
|
+
/**
|
|
42
|
+
* Trailing assistant message whose runnable tool calls have no results yet.
|
|
43
|
+
*
|
|
44
|
+
* A harness that strips failed/aborted tool results in order to re-execute
|
|
45
|
+
* the calls (e.g. `AgentSession.retry`'s tool replay) leaves the transcript
|
|
46
|
+
* in this shape; {@link agentLoopContinue} resumes it by running those calls
|
|
47
|
+
* before the next model call. Cursor exec-resolved blocks are excluded — the
|
|
48
|
+
* provider already executed those server-side. `length`-truncated turns never
|
|
49
|
+
* qualify: their trailing call arguments may be incomplete.
|
|
50
|
+
*/
|
|
51
|
+
export declare function unpairedToolCallTail(messages: readonly AgentMessage[]): AssistantMessage | undefined;
|
|
41
52
|
/**
|
|
42
53
|
* Continue an agent loop from the current context without adding a new message.
|
|
43
54
|
* Used for retries - context already has user message or tool results.
|
|
44
55
|
*
|
|
45
56
|
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
46
|
-
* via `convertToLlm
|
|
47
|
-
*
|
|
57
|
+
* via `convertToLlm` — except for an assistant tail with unpaired runnable
|
|
58
|
+
* tool calls (see {@link unpairedToolCallTail}), which resumes by executing
|
|
59
|
+
* those calls first. Any other assistant tail is rejected here; other invalid
|
|
60
|
+
* tails cannot be validated since `convertToLlm` is only called once per turn.
|
|
48
61
|
*/
|
|
49
62
|
export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
|
|
50
63
|
/**
|
|
@@ -87,7 +87,7 @@ export declare function withOpenAiRemoteCompactionPreserveData(preserveData: Rec
|
|
|
87
87
|
* @param previousReplacementHistory - History from a prior compaction whose
|
|
88
88
|
* encrypted reasoning we want to preserve.
|
|
89
89
|
*/
|
|
90
|
-
export declare function buildOpenAiNativeHistory(messages: Message[], model: Model, previousReplacementHistory?: Array<Record<string, unknown
|
|
90
|
+
export declare function buildOpenAiNativeHistory(messages: Message[], model: Model, previousReplacementHistory?: Array<Record<string, unknown>>, supportsImageDetailOriginal?: boolean): Array<Record<string, unknown>>;
|
|
91
91
|
export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: string, compactInput: Array<Record<string, unknown>>, instructions: string, signal?: AbortSignal, opts?: {
|
|
92
92
|
fetch?: FetchImpl;
|
|
93
93
|
timeoutMs?: number;
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Context-reducing surgical compaction ("shake").
|
|
3
3
|
*
|
|
4
|
-
* `shake` drops heavy content out of the live context mechanically:
|
|
5
|
-
* tool-
|
|
6
|
-
* placeholders
|
|
4
|
+
* `shake` drops heavy content out of the live context mechanically:
|
|
5
|
+
* tool-result text and large fenced/XML blocks are replaced with short
|
|
6
|
+
* placeholders while non-text tool-result content is preserved. This module
|
|
7
|
+
* is the pure layer — region detection and in-place
|
|
7
8
|
* mutation only. Artifact offload, persistence, and provider-session teardown
|
|
8
9
|
* are orchestrated by the caller (`AgentSession.shake`).
|
|
9
10
|
*
|
|
@@ -45,6 +46,7 @@ export declare const RESCUE_SHAKE_CONFIG: ShakeConfig;
|
|
|
45
46
|
export interface ToolResultShakeRegion {
|
|
46
47
|
kind: "toolResult";
|
|
47
48
|
entry: SessionMessageEntry;
|
|
49
|
+
/** Estimated tokens in the removable text only; retained images are excluded. */
|
|
48
50
|
tokens: number;
|
|
49
51
|
originalText: string;
|
|
50
52
|
/** Human label for the offload doc (tool name). */
|
|
@@ -68,10 +70,11 @@ export type ShakeRegion = ToolResultShakeRegion | BlockShakeRegion;
|
|
|
68
70
|
* Pure detection: locate every eligible shake region on a branch.
|
|
69
71
|
*
|
|
70
72
|
* Walks the protect-recent window (most recent `protectTokens` of context is
|
|
71
|
-
* kept intact), collects
|
|
72
|
-
* and skipping already-pruned results) and large
|
|
73
|
-
* user/developer/assistant/custom messages.
|
|
74
|
-
* useless
|
|
73
|
+
* kept intact), collects the text from eligible tool-result messages
|
|
74
|
+
* (honoring `protectedTools` and skipping already-pruned results) and large
|
|
75
|
+
* fenced/XML blocks inside user/developer/assistant/custom messages.
|
|
76
|
+
* Contextually useless tool results bypass the protect window — there is
|
|
77
|
+
* nothing recent
|
|
75
78
|
* worth keeping in them. Returns regions in document order.
|
|
76
79
|
*
|
|
77
80
|
* `toolCall` blocks are never touched (tool-call/result pairing is preserved)
|
|
@@ -82,8 +85,9 @@ export declare function collectShakeRegions(entries: SessionEntry[], tokenizer:
|
|
|
82
85
|
/**
|
|
83
86
|
* Pure mutation: replace a single region's content in place.
|
|
84
87
|
*
|
|
85
|
-
* Tool-result: replaces
|
|
86
|
-
*
|
|
88
|
+
* Tool-result: replaces its first non-empty text block with the placeholder,
|
|
89
|
+
* removes its other text blocks, preserves every non-text block, and stamps
|
|
90
|
+
* `prunedAt`. Block: splices `replacement` over `[start, end)` of the
|
|
87
91
|
* target text block. When several block regions share one text block they MUST
|
|
88
92
|
* be applied highest-start-first so earlier offsets stay valid — use
|
|
89
93
|
* {@link applyShakeRegions}, which orders them correctly.
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { Model } from "@linxiraos/pi-ai";
|
|
2
|
-
import
|
|
2
|
+
import * as natives from "@linxiraos/pi-natives";
|
|
3
3
|
import type { AgentMessage } from "./types.js";
|
|
4
4
|
/** Maps the catalog-resolved tokenizer family to its native implementation. */
|
|
5
|
-
export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): Encoding | null;
|
|
5
|
+
export declare function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): natives.Encoding | null;
|
|
6
6
|
/**
|
|
7
|
-
* `strict` always
|
|
8
|
-
*
|
|
7
|
+
* `strict` always tries an exact native count (the catalog-resolved tokenizer
|
|
8
|
+
* when known, o200k_base otherwise), falling back to the byte upper bound when
|
|
9
|
+
* the loaded addon does not recognize that encoding. `approximate` and
|
|
9
10
|
* `upperbound` prefer the same exact count for known tokenizer families or
|
|
10
11
|
* when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
|
|
11
12
|
* `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
|
|
@@ -31,11 +32,10 @@ export interface TokenBudgetCheck {
|
|
|
31
32
|
fits: boolean;
|
|
32
33
|
/**
|
|
33
34
|
* Token count behind the verdict: the exact native count when `exact` is
|
|
34
|
-
* set, otherwise the
|
|
35
|
-
* only an over-estimate of a count known to be under budget).
|
|
35
|
+
* set, otherwise the conservative byte upper bound.
|
|
36
36
|
*/
|
|
37
37
|
tokens: number;
|
|
38
|
-
/** Whether
|
|
38
|
+
/** Whether `tokens` came from the exact native tokenizer. */
|
|
39
39
|
exact: boolean;
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
@@ -50,7 +50,7 @@ export interface TokenBudgetCheck {
|
|
|
50
50
|
export declare class Tokenizer {
|
|
51
51
|
#private;
|
|
52
52
|
constructor(model?: Pick<Model, "tokenizer"> | null);
|
|
53
|
-
get encoding(): Encoding | null;
|
|
53
|
+
get encoding(): natives.Encoding | null;
|
|
54
54
|
countTokens(text: string | string[], mode?: TokenCountMode): number;
|
|
55
55
|
/**
|
|
56
56
|
* Cheap-first budget probe — the way to ask "does this fit in `budget`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@linxiraos/pi-agent-core",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.7",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://linxira-os.github.io/zeta/",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,16 +35,16 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@linxiraos/pi-ai": "1.1.
|
|
39
|
-
"@linxiraos/pi-catalog": "1.1.
|
|
40
|
-
"@linxiraos/pi-natives": "1.1.
|
|
41
|
-
"@linxiraos/pi-utils": "1.1.
|
|
42
|
-
"@linxiraos/pi-wire": "1.1.
|
|
43
|
-
"@linxiraos/pi-snapcompact": "1.1.
|
|
38
|
+
"@linxiraos/pi-ai": "1.1.7",
|
|
39
|
+
"@linxiraos/pi-catalog": "1.1.7",
|
|
40
|
+
"@linxiraos/pi-natives": "1.1.7",
|
|
41
|
+
"@linxiraos/pi-utils": "1.1.7",
|
|
42
|
+
"@linxiraos/pi-wire": "1.1.7",
|
|
43
|
+
"@linxiraos/pi-snapcompact": "1.1.7",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@linxiraos/pi-omptype": "1.1.
|
|
47
|
+
"@linxiraos/pi-omptype": "1.1.7",
|
|
48
48
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
49
49
|
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
50
50
|
"@types/bun": "^1.3.14"
|
package/src/agent-loop.ts
CHANGED
|
@@ -554,13 +554,36 @@ export function agentLoop(
|
|
|
554
554
|
return stream;
|
|
555
555
|
}
|
|
556
556
|
|
|
557
|
+
/**
|
|
558
|
+
* Trailing assistant message whose runnable tool calls have no results yet.
|
|
559
|
+
*
|
|
560
|
+
* A harness that strips failed/aborted tool results in order to re-execute
|
|
561
|
+
* the calls (e.g. `AgentSession.retry`'s tool replay) leaves the transcript
|
|
562
|
+
* in this shape; {@link agentLoopContinue} resumes it by running those calls
|
|
563
|
+
* before the next model call. Cursor exec-resolved blocks are excluded — the
|
|
564
|
+
* provider already executed those server-side. `length`-truncated turns never
|
|
565
|
+
* qualify: their trailing call arguments may be incomplete.
|
|
566
|
+
*/
|
|
567
|
+
export function unpairedToolCallTail(messages: readonly AgentMessage[]): AssistantMessage | undefined {
|
|
568
|
+
const tail = messages[messages.length - 1];
|
|
569
|
+
if (tail?.role !== "assistant") return undefined;
|
|
570
|
+
// Mirrors the loop's `runnableStop` rule for fresh turns.
|
|
571
|
+
if (tail.stopReason !== "toolUse" && tail.stopReason !== "stop") return undefined;
|
|
572
|
+
const hasRunnable = tail.content.some(
|
|
573
|
+
c => c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
574
|
+
);
|
|
575
|
+
return hasRunnable ? tail : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
557
578
|
/**
|
|
558
579
|
* Continue an agent loop from the current context without adding a new message.
|
|
559
580
|
* Used for retries - context already has user message or tool results.
|
|
560
581
|
*
|
|
561
582
|
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
562
|
-
* via `convertToLlm
|
|
563
|
-
*
|
|
583
|
+
* via `convertToLlm` — except for an assistant tail with unpaired runnable
|
|
584
|
+
* tool calls (see {@link unpairedToolCallTail}), which resumes by executing
|
|
585
|
+
* those calls first. Any other assistant tail is rejected here; other invalid
|
|
586
|
+
* tails cannot be validated since `convertToLlm` is only called once per turn.
|
|
564
587
|
*/
|
|
565
588
|
export function agentLoopContinue(
|
|
566
589
|
context: AgentContext,
|
|
@@ -572,7 +595,7 @@ export function agentLoopContinue(
|
|
|
572
595
|
throw new Error("Cannot continue: no messages in context");
|
|
573
596
|
}
|
|
574
597
|
|
|
575
|
-
if (context.messages[context.messages.length - 1].role === "assistant") {
|
|
598
|
+
if (context.messages[context.messages.length - 1].role === "assistant" && !unpairedToolCallTail(context.messages)) {
|
|
576
599
|
throw new Error("Cannot continue from message role: assistant");
|
|
577
600
|
}
|
|
578
601
|
|
|
@@ -1061,6 +1084,43 @@ async function runLoopBody(
|
|
|
1061
1084
|
let softSatisfies: SoftToolRequirement["satisfies"];
|
|
1062
1085
|
let directiveResolvedForTurn = false;
|
|
1063
1086
|
let turnOpen = false;
|
|
1087
|
+
// A trailing assistant message with unpaired tool calls: the harness
|
|
1088
|
+
// stripped the failed/aborted results so this run re-executes the calls
|
|
1089
|
+
// (AgentSession.retry's tool replay). Run them before the first model
|
|
1090
|
+
// call; the loop then continues normally on the fresh results. Queued
|
|
1091
|
+
// steering stays parked until after the batch — injecting a message
|
|
1092
|
+
// between the tool_use blocks and their results would break the
|
|
1093
|
+
// provider's pairing invariant.
|
|
1094
|
+
const resumeTail = unpairedToolCallTail(currentContext.messages);
|
|
1095
|
+
if (resumeTail) {
|
|
1096
|
+
stream.push({ type: "turn_start" });
|
|
1097
|
+
emitInputMessages(stream, messagesToEmit);
|
|
1098
|
+
messagesToEmit = [];
|
|
1099
|
+
turnOpen = true;
|
|
1100
|
+
const executionResult = await executeToolCalls(
|
|
1101
|
+
currentContext,
|
|
1102
|
+
resumeTail,
|
|
1103
|
+
signal,
|
|
1104
|
+
stream,
|
|
1105
|
+
config,
|
|
1106
|
+
telemetry,
|
|
1107
|
+
invokeAgentSpan,
|
|
1108
|
+
);
|
|
1109
|
+
for (const result of executionResult.toolResults) {
|
|
1110
|
+
currentContext.messages.push(result);
|
|
1111
|
+
newMessages.push(result);
|
|
1112
|
+
}
|
|
1113
|
+
await emitTurnEnd(stream, currentContext, resumeTail, executionResult.toolResults, config, signal, {
|
|
1114
|
+
willContinue: !isDeadlineExceeded(config.deadline),
|
|
1115
|
+
});
|
|
1116
|
+
turnOpen = false;
|
|
1117
|
+
// A tool hook may mark its completed result as terminal (e.g. subagent
|
|
1118
|
+
// yield) — same stop-before-next-model-call rule as the main loop.
|
|
1119
|
+
if (signal?.reason === TERMINAL_TOOL_RESULT_ABORT_REASON) {
|
|
1120
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1064
1124
|
|
|
1065
1125
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
1066
1126
|
while (true) {
|
package/src/agent.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
normalizeMessagesForProvider,
|
|
35
35
|
normalizeTools,
|
|
36
36
|
resolveOwnedDialectFromEnv,
|
|
37
|
+
unpairedToolCallTail,
|
|
37
38
|
} from "./agent-loop";
|
|
38
39
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
39
40
|
import { isProviderRefusalMessage } from "./replay-policy";
|
|
@@ -1249,6 +1250,15 @@ export class Agent {
|
|
|
1249
1250
|
throw new Error("No messages to continue from");
|
|
1250
1251
|
}
|
|
1251
1252
|
if (messages[messages.length - 1].role === "assistant") {
|
|
1253
|
+
// A tail with unpaired runnable tool calls resumes by re-executing
|
|
1254
|
+
// them (see `unpairedToolCallTail` in agent-loop). This must win over
|
|
1255
|
+
// queued-message delivery: injecting a message between the tool_use
|
|
1256
|
+
// blocks and their results would break the provider's pairing
|
|
1257
|
+
// invariant. Queued messages drain inside the resumed loop instead.
|
|
1258
|
+
if (unpairedToolCallTail(messages)) {
|
|
1259
|
+
await this.#runLoop(undefined, undefined, signal, true);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1252
1262
|
const queuedSteering = await this.#dequeueSteeringMessagesAfterHooks(dequeueSignal);
|
|
1253
1263
|
if (queuedSteering.length > 0) {
|
|
1254
1264
|
await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true }, signal, true);
|
|
@@ -1674,6 +1674,7 @@ export async function compact(
|
|
|
1674
1674
|
(summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
|
|
1675
1675
|
model,
|
|
1676
1676
|
previousReplacementHistory,
|
|
1677
|
+
openAiCompatSupportsImageDetailOriginal(model),
|
|
1677
1678
|
);
|
|
1678
1679
|
if (remoteHistory.length > 0) {
|
|
1679
1680
|
try {
|
|
@@ -85,7 +85,16 @@ function getPrunedToolResultContent(message: ToolResultMessage): (TextContent |
|
|
|
85
85
|
}
|
|
86
86
|
const textBlocks = message.content.filter((content): content is TextContent => content.type === "text");
|
|
87
87
|
const text = textBlocks.map(block => block.text).join("") || "[Output truncated]";
|
|
88
|
-
|
|
88
|
+
const firstTextIndex = message.content.findIndex(content => content.type === "text");
|
|
89
|
+
if (firstTextIndex < 0) return [{ type: "text", text }, ...message.content];
|
|
90
|
+
|
|
91
|
+
const content: (TextContent | ImageContent)[] = [];
|
|
92
|
+
for (let index = 0; index < message.content.length; index++) {
|
|
93
|
+
const block = message.content[index];
|
|
94
|
+
if (block.type !== "text") content.push(block);
|
|
95
|
+
else if (index === firstTextIndex) content.push({ type: "text", text });
|
|
96
|
+
}
|
|
97
|
+
return content;
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
export function renderBranchSummaryContext(summary: string): string {
|
package/src/compaction/openai.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
getCodexAttestationHeader,
|
|
24
24
|
} from "@linxiraos/pi-ai/providers/openai-codex-responses";
|
|
25
25
|
import {
|
|
26
|
+
encodeResponsesToolResultOutput,
|
|
26
27
|
hoistInterleavedResponsesToolBatchMessages,
|
|
27
28
|
parseAzureDeploymentNameMap,
|
|
28
29
|
parseTextSignature,
|
|
@@ -503,6 +504,7 @@ export function buildOpenAiNativeHistory(
|
|
|
503
504
|
messages: Message[],
|
|
504
505
|
model: Model,
|
|
505
506
|
previousReplacementHistory?: Array<Record<string, unknown>>,
|
|
507
|
+
supportsImageDetailOriginal = false,
|
|
506
508
|
): Array<Record<string, unknown>> {
|
|
507
509
|
const input: Array<Record<string, unknown>> = previousReplacementHistory
|
|
508
510
|
? adaptComputerHistoryForCompaction([...previousReplacementHistory], model.supportsComputerUse === true)
|
|
@@ -691,12 +693,7 @@ export function buildOpenAiNativeHistory(
|
|
|
691
693
|
|
|
692
694
|
if (message.role === "toolResult") {
|
|
693
695
|
const normalized = normalizeResponsesToolCallId(message.toolCallId);
|
|
694
|
-
const
|
|
695
|
-
.filter(block => block.type === "text")
|
|
696
|
-
.map(block => block.text)
|
|
697
|
-
.join("\n");
|
|
698
|
-
const hasImages = message.content.some(block => block.type === "image");
|
|
699
|
-
const outputText = textOutput.length > 0 ? textOutput : hasImages ? "(see attached image)" : "";
|
|
696
|
+
const { output, outputText } = encodeResponsesToolResultOutput(message, model, supportsImageDetailOriginal);
|
|
700
697
|
if (demotedComputerCallIds.has(normalized.callId)) {
|
|
701
698
|
const resultItem =
|
|
702
699
|
message.providerMetadata?.type === "computer"
|
|
@@ -746,23 +743,8 @@ export function buildOpenAiNativeHistory(
|
|
|
746
743
|
input.push({
|
|
747
744
|
type: customCallIds.has(normalized.callId) ? "custom_tool_call_output" : "function_call_output",
|
|
748
745
|
call_id: normalized.callId,
|
|
749
|
-
output
|
|
746
|
+
output,
|
|
750
747
|
});
|
|
751
|
-
|
|
752
|
-
if (hasImages && model.input.includes("image")) {
|
|
753
|
-
const contentBlocks: Array<Record<string, unknown>> = [
|
|
754
|
-
{ type: "input_text", text: TOOL_RESULT_IMAGE_ATTACHMENT_TEXT },
|
|
755
|
-
];
|
|
756
|
-
for (const block of message.content) {
|
|
757
|
-
if (block.type !== "image") continue;
|
|
758
|
-
contentBlocks.push({
|
|
759
|
-
type: "input_image",
|
|
760
|
-
detail: "auto",
|
|
761
|
-
image_url: `data:${block.mimeType};base64,${block.data}`,
|
|
762
|
-
});
|
|
763
|
-
}
|
|
764
|
-
input.push({ type: "message", role: "user", content: contentBlocks });
|
|
765
|
-
}
|
|
766
748
|
}
|
|
767
749
|
|
|
768
750
|
msgIndex++;
|
package/src/compaction/shake.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Context-reducing surgical compaction ("shake").
|
|
3
3
|
*
|
|
4
|
-
* `shake` drops heavy content out of the live context mechanically:
|
|
5
|
-
* tool-
|
|
6
|
-
* placeholders
|
|
4
|
+
* `shake` drops heavy content out of the live context mechanically:
|
|
5
|
+
* tool-result text and large fenced/XML blocks are replaced with short
|
|
6
|
+
* placeholders while non-text tool-result content is preserved. This module
|
|
7
|
+
* is the pure layer — region detection and in-place
|
|
7
8
|
* mutation only. Artifact offload, persistence, and provider-session teardown
|
|
8
9
|
* are orchestrated by the caller (`AgentSession.shake`).
|
|
9
10
|
*
|
|
@@ -80,6 +81,7 @@ const PLACEHOLDER_TOKEN_ESTIMATE = 16;
|
|
|
80
81
|
export interface ToolResultShakeRegion {
|
|
81
82
|
kind: "toolResult";
|
|
82
83
|
entry: SessionMessageEntry;
|
|
84
|
+
/** Estimated tokens in the removable text only; retained images are excluded. */
|
|
83
85
|
tokens: number;
|
|
84
86
|
originalText: string;
|
|
85
87
|
/** Human label for the offload doc (tool name). */
|
|
@@ -114,11 +116,19 @@ function getToolResultMessage(entry: SessionEntry): ToolResultMessage | undefine
|
|
|
114
116
|
return message as ToolResultMessage;
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
function toolResultText(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
function toolResultText(
|
|
120
|
+
message: ToolResultMessage,
|
|
121
|
+
tokenizer: Tokenizer,
|
|
122
|
+
): { originalText: string; tokens: number } | undefined {
|
|
123
|
+
const fragments: string[] = [];
|
|
124
|
+
for (const block of message.content) {
|
|
125
|
+
if (block.type === "text" && block.text.length > 0) fragments.push(block.text);
|
|
126
|
+
}
|
|
127
|
+
if (fragments.length === 0) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
originalText: fragments.join("\n"),
|
|
130
|
+
tokens: tokenizer.countTokens(fragments),
|
|
131
|
+
};
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
/** Estimate the token contribution of an entry for the protect-recent window. */
|
|
@@ -292,10 +302,11 @@ function scanContentBlocks(
|
|
|
292
302
|
* Pure detection: locate every eligible shake region on a branch.
|
|
293
303
|
*
|
|
294
304
|
* Walks the protect-recent window (most recent `protectTokens` of context is
|
|
295
|
-
* kept intact), collects
|
|
296
|
-
* and skipping already-pruned results) and large
|
|
297
|
-
* user/developer/assistant/custom messages.
|
|
298
|
-
* useless
|
|
305
|
+
* kept intact), collects the text from eligible tool-result messages
|
|
306
|
+
* (honoring `protectedTools` and skipping already-pruned results) and large
|
|
307
|
+
* fenced/XML blocks inside user/developer/assistant/custom messages.
|
|
308
|
+
* Contextually useless tool results bypass the protect window — there is
|
|
309
|
+
* nothing recent
|
|
299
310
|
* worth keeping in them. Returns regions in document order.
|
|
300
311
|
*
|
|
301
312
|
* `toolCall` blocks are never touched (tool-call/result pairing is preserved)
|
|
@@ -339,13 +350,13 @@ export function collectShakeRegions(entries: SessionEntry[], tokenizer: Tokenize
|
|
|
339
350
|
if (toolResult.prunedAt !== undefined) continue;
|
|
340
351
|
if (isProtectedToolResult(toolResult, toolCallsById.get(toolResult.toolCallId), config.protectedTools))
|
|
341
352
|
continue;
|
|
342
|
-
const text = toolResultText(toolResult);
|
|
343
|
-
if (text
|
|
353
|
+
const text = toolResultText(toolResult, tokenizer);
|
|
354
|
+
if (!text) continue;
|
|
344
355
|
regions.push({
|
|
345
356
|
kind: "toolResult",
|
|
346
357
|
entry: entry as SessionMessageEntry,
|
|
347
|
-
tokens:
|
|
348
|
-
originalText: text,
|
|
358
|
+
tokens: text.tokens,
|
|
359
|
+
originalText: text.originalText,
|
|
349
360
|
label: toolResult.toolName,
|
|
350
361
|
});
|
|
351
362
|
continue;
|
|
@@ -414,8 +425,9 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
|
|
|
414
425
|
/**
|
|
415
426
|
* Pure mutation: replace a single region's content in place.
|
|
416
427
|
*
|
|
417
|
-
* Tool-result: replaces
|
|
418
|
-
*
|
|
428
|
+
* Tool-result: replaces its first non-empty text block with the placeholder,
|
|
429
|
+
* removes its other text blocks, preserves every non-text block, and stamps
|
|
430
|
+
* `prunedAt`. Block: splices `replacement` over `[start, end)` of the
|
|
419
431
|
* target text block. When several block regions share one text block they MUST
|
|
420
432
|
* be applied highest-start-first so earlier offsets stay valid — use
|
|
421
433
|
* {@link applyShakeRegions}, which orders them correctly.
|
|
@@ -423,7 +435,15 @@ function getBlockTextSlot(entry: SessionMessageEntry | CustomMessageEntry, block
|
|
|
423
435
|
export function applyShakeRegion(region: ShakeRegion, replacement: string): void {
|
|
424
436
|
if (region.kind === "toolResult") {
|
|
425
437
|
const message = region.entry.message as ToolResultMessage;
|
|
426
|
-
message.content
|
|
438
|
+
const replacementIndex = message.content.findIndex(block => block.type === "text" && block.text.length > 0);
|
|
439
|
+
if (replacementIndex < 0) return;
|
|
440
|
+
const kept: typeof message.content = [];
|
|
441
|
+
for (let index = 0; index < message.content.length; index++) {
|
|
442
|
+
const block = message.content[index];
|
|
443
|
+
if (block.type !== "text") kept.push(block);
|
|
444
|
+
else if (index === replacementIndex) kept.push({ type: "text", text: replacement });
|
|
445
|
+
}
|
|
446
|
+
message.content = kept;
|
|
427
447
|
message.prunedAt = Date.now();
|
|
428
448
|
invalidateMessageCache(message as AgentMessage);
|
|
429
449
|
return;
|
package/src/tokenizer.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Model } from "@linxiraos/pi-ai";
|
|
2
2
|
import type { ModelTokenizer } from "@linxiraos/pi-catalog/types";
|
|
3
|
-
import
|
|
3
|
+
import * as natives from "@linxiraos/pi-natives";
|
|
4
4
|
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
5
5
|
import { stringifyJson } from "@linxiraos/pi-utils";
|
|
6
6
|
import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
|
|
@@ -9,25 +9,26 @@ import type { AgentMessage } from "./types";
|
|
|
9
9
|
const testEnv = Bun.env.NODE_ENV === "test";
|
|
10
10
|
const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && !testEnv;
|
|
11
11
|
|
|
12
|
-
const NATIVE_ENCODING: Record<ModelTokenizer, Encoding> = {
|
|
13
|
-
"claude-v3": Encoding.ClaudeV3,
|
|
14
|
-
"claude-v47": Encoding.ClaudeV47,
|
|
15
|
-
"claude-v5": Encoding.ClaudeV5,
|
|
16
|
-
"claude-v5-sonnet": Encoding.ClaudeV5Sonnet,
|
|
17
|
-
qwen3: Encoding.Qwen3,
|
|
18
|
-
"deepseek-v3": Encoding.DeepSeekV3,
|
|
19
|
-
"kimi-k2": Encoding.KimiK2,
|
|
20
|
-
glm5: Encoding.Glm5,
|
|
12
|
+
const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
|
|
13
|
+
"claude-v3": natives.Encoding.ClaudeV3,
|
|
14
|
+
"claude-v47": natives.Encoding.ClaudeV47,
|
|
15
|
+
"claude-v5": natives.Encoding.ClaudeV5,
|
|
16
|
+
"claude-v5-sonnet": natives.Encoding.ClaudeV5Sonnet,
|
|
17
|
+
qwen3: natives.Encoding.Qwen3,
|
|
18
|
+
"deepseek-v3": natives.Encoding.DeepSeekV3,
|
|
19
|
+
"kimi-k2": natives.Encoding.KimiK2,
|
|
20
|
+
glm5: natives.Encoding.Glm5,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
/** Maps the catalog-resolved tokenizer family to its native implementation. */
|
|
24
|
-
export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): Encoding | null {
|
|
24
|
+
export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): natives.Encoding | null {
|
|
25
25
|
return model?.tokenizer ? NATIVE_ENCODING[model.tokenizer] : null;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* `strict` always
|
|
30
|
-
*
|
|
29
|
+
* `strict` always tries an exact native count (the catalog-resolved tokenizer
|
|
30
|
+
* when known, o200k_base otherwise), falling back to the byte upper bound when
|
|
31
|
+
* the loaded addon does not recognize that encoding. `approximate` and
|
|
31
32
|
* `upperbound` prefer the same exact count for known tokenizer families or
|
|
32
33
|
* when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
|
|
33
34
|
* `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
|
|
@@ -61,17 +62,41 @@ function sumFragments(text: string | string[], perFragment: (t: string) => numbe
|
|
|
61
62
|
return Array.isArray(text) ? text.reduce((sum, t) => sum + perFragment(t), 0) : perFragment(text);
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
interface NativeTokenCount {
|
|
66
|
+
tokens: number;
|
|
67
|
+
exact: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function countTokensNat(
|
|
71
|
+
text: string | string[],
|
|
72
|
+
encoding: natives.Encoding | null | undefined,
|
|
73
|
+
mode: TokenCountMode,
|
|
74
|
+
): NativeTokenCount {
|
|
75
|
+
try {
|
|
76
|
+
return { tokens: natives.countTokens(text, encoding), exact: true };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (
|
|
79
|
+
!(error instanceof Error) ||
|
|
80
|
+
(!error.message.includes("does not match any variant of enum") &&
|
|
81
|
+
!error.message.includes("unknown enum variant"))
|
|
82
|
+
) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const tokens = sumFragments(text, mode === "approximate" ? byteEstimate : byteLength);
|
|
86
|
+
return { tokens, exact: false };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
/** Verdict from {@link Tokenizer.checkTokenBudget}. */
|
|
65
91
|
export interface TokenBudgetCheck {
|
|
66
92
|
/** Whether the text fits the budget. */
|
|
67
93
|
fits: boolean;
|
|
68
94
|
/**
|
|
69
95
|
* Token count behind the verdict: the exact native count when `exact` is
|
|
70
|
-
* set, otherwise the
|
|
71
|
-
* only an over-estimate of a count known to be under budget).
|
|
96
|
+
* set, otherwise the conservative byte upper bound.
|
|
72
97
|
*/
|
|
73
98
|
tokens: number;
|
|
74
|
-
/** Whether
|
|
99
|
+
/** Whether `tokens` came from the exact native tokenizer. */
|
|
75
100
|
exact: boolean;
|
|
76
101
|
}
|
|
77
102
|
|
|
@@ -104,7 +129,7 @@ interface MessageEstimate {
|
|
|
104
129
|
* `PI_TOKENIZER_ACCURATE=1`).
|
|
105
130
|
*/
|
|
106
131
|
export class Tokenizer {
|
|
107
|
-
readonly #encoding: Encoding | null;
|
|
132
|
+
readonly #encoding: natives.Encoding | null;
|
|
108
133
|
|
|
109
134
|
/**
|
|
110
135
|
* Per-message estimate memo. Keyed by message identity, deliberately not a
|
|
@@ -120,14 +145,14 @@ export class Tokenizer {
|
|
|
120
145
|
this.#encoding = tokenizerEncodingForModel(model);
|
|
121
146
|
}
|
|
122
147
|
|
|
123
|
-
get encoding(): Encoding | null {
|
|
148
|
+
get encoding(): natives.Encoding | null {
|
|
124
149
|
return this.#encoding;
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
countTokens(text: string | string[], mode: TokenCountMode = "approximate"): number {
|
|
128
|
-
if (mode === "strict") return countTokensNat(text, this.#encoding);
|
|
129
|
-
if (!testEnv && this.#encoding !== null) return countTokensNat(text, this.#encoding);
|
|
130
|
-
if (accurate) return countTokensNat(text);
|
|
153
|
+
if (mode === "strict") return countTokensNat(text, this.#encoding, mode).tokens;
|
|
154
|
+
if (!testEnv && this.#encoding !== null) return countTokensNat(text, this.#encoding, mode).tokens;
|
|
155
|
+
if (accurate) return countTokensNat(text, undefined, mode).tokens;
|
|
131
156
|
return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
|
|
132
157
|
}
|
|
133
158
|
|
|
@@ -145,8 +170,8 @@ export class Tokenizer {
|
|
|
145
170
|
checkTokenBudget(text: string | string[], budget: number): TokenBudgetCheck {
|
|
146
171
|
const bound = sumFragments(text, byteLength);
|
|
147
172
|
if (bound <= budget) return { fits: true, tokens: bound, exact: false };
|
|
148
|
-
const
|
|
149
|
-
return { fits: tokens <= budget, tokens, exact:
|
|
173
|
+
const result = countTokensNat(text, this.#encoding, "strict");
|
|
174
|
+
return { fits: result.tokens <= budget, tokens: result.tokens, exact: result.exact };
|
|
150
175
|
}
|
|
151
176
|
|
|
152
177
|
/**
|