@linxiraos/pi-agent-core 1.1.26 → 1.1.27
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 +23 -2
- package/dist/types/agent.d.ts +12 -5
- package/dist/types/compaction/anthropic.d.ts +14 -8
- package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
- package/dist/types/compaction/compaction.d.ts +14 -0
- package/dist/types/compaction/entries.d.ts +2 -2
- package/dist/types/compaction/openai.d.ts +14 -2
- package/dist/types/compaction/pruning.d.ts +33 -9
- package/dist/types/compaction/utils.d.ts +5 -0
- package/dist/types/image-tokens.d.ts +29 -0
- package/dist/types/telemetry.d.ts +10 -0
- package/dist/types/tokenizer.d.ts +9 -6
- package/package.json +8 -8
- package/src/agent-loop.ts +176 -19
- package/src/agent.ts +77 -17
- package/src/compaction/anthropic.ts +30 -12
- package/src/compaction/compaction-v2-streaming.ts +13 -15
- package/src/compaction/compaction.ts +127 -31
- package/src/compaction/entries.ts +2 -2
- package/src/compaction/openai.ts +63 -28
- package/src/compaction/pruning.ts +81 -21
- package/src/compaction/utils.ts +9 -0
- package/src/image-tokens.ts +77 -0
- package/src/prompts/dsml-tool-call-leak.md +19 -0
- package/src/telemetry.ts +19 -1
- package/src/tokenizer.ts +24 -17
package/src/agent-loop.ts
CHANGED
|
@@ -28,11 +28,13 @@ import {
|
|
|
28
28
|
import {
|
|
29
29
|
type Dialect,
|
|
30
30
|
encodeInbandToolHistory,
|
|
31
|
+
mintToolCallId,
|
|
31
32
|
renderInbandToolPrompt,
|
|
32
33
|
renderToolExamples,
|
|
33
34
|
wrapInbandToolStream,
|
|
34
35
|
} from "@linxiraos/pi-ai/dialect";
|
|
35
36
|
import * as AIError from "@linxiraos/pi-ai/error";
|
|
37
|
+
import { appendDuplicateSuffix, MAX_TOOL_CALL_ID_LENGTH } from "@linxiraos/pi-ai/providers/transform-messages";
|
|
36
38
|
import {
|
|
37
39
|
type CursorExecResolvedCarrier,
|
|
38
40
|
copyCursorExecResolved,
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
recoverHarmonyToolCall,
|
|
52
54
|
signalListLabel,
|
|
53
55
|
} from "@linxiraos/pi-ai/utils/harmony-leak";
|
|
56
|
+
import { isDsmlLeakRecoveryTarget, removeDsmlToolMarkupLeak } from "@linxiraos/pi-ai/utils/dsml-leak";
|
|
54
57
|
import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
|
|
55
58
|
import { INTENT_FIELD } from "@linxiraos/pi-wire";
|
|
56
59
|
import { LiveSteeringChannel } from "./live-steering";
|
|
@@ -74,6 +77,7 @@ import {
|
|
|
74
77
|
startInvokeAgentSpan,
|
|
75
78
|
} from "./telemetry";
|
|
76
79
|
import { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
|
|
80
|
+
import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
|
|
77
81
|
import type {
|
|
78
82
|
AgentContext,
|
|
79
83
|
AgentEvent,
|
|
@@ -114,6 +118,14 @@ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
|
114
118
|
*/
|
|
115
119
|
const MAX_PAUSED_TURN_CONTINUATIONS = 8;
|
|
116
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Cap on consecutive "tool call failed" nudges for a turn that ended with raw
|
|
123
|
+
* DeepSeek DSML tool-call markup in its text and no structured call. A model
|
|
124
|
+
* that keeps leaking after this many corrections is left to yield. Resets
|
|
125
|
+
* whenever a turn carries tool calls.
|
|
126
|
+
*/
|
|
127
|
+
const MAX_DSML_LEAK_NUDGES = 2;
|
|
128
|
+
|
|
117
129
|
/**
|
|
118
130
|
* Cap on consecutive forced escalations for a single soft tool requirement.
|
|
119
131
|
* A forced `toolChoice` guarantees the call, so this is purely defensive: if a
|
|
@@ -998,7 +1010,8 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
|
|
|
998
1010
|
const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
|
|
999
1011
|
return tools?.map(t => {
|
|
1000
1012
|
const intentMode = resolveIntentMode(t.intent);
|
|
1001
|
-
const doInjectIntent =
|
|
1013
|
+
const doInjectIntent =
|
|
1014
|
+
injectIntent && intentMode !== "omit" && !schemaDefinesProperty(toolWireSchema(t), INTENT_FIELD);
|
|
1002
1015
|
// When the full catalog is rendered into the system prompt, ship the tool
|
|
1003
1016
|
// specs without their descriptions (top-level + nested schema annotations)
|
|
1004
1017
|
// so they are not duplicated on the wire. Strip the STABLE wire schema (the
|
|
@@ -1219,6 +1232,7 @@ async function runLoopBody(
|
|
|
1219
1232
|
let harmonyRetryAttempt = 0;
|
|
1220
1233
|
let harmonyTruncateResumeCount = 0;
|
|
1221
1234
|
let pausedTurnContinuations = 0;
|
|
1235
|
+
let dsmlLeakNudges = 0;
|
|
1222
1236
|
|
|
1223
1237
|
// Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
|
|
1224
1238
|
// The host-owned state survives only a gate stop between Agent.prompt calls.
|
|
@@ -1479,6 +1493,15 @@ async function runLoopBody(
|
|
|
1479
1493
|
}
|
|
1480
1494
|
}
|
|
1481
1495
|
if (recovered) {
|
|
1496
|
+
// Mint/repair ids before any snapshot: the recovered message skipped
|
|
1497
|
+
// the streamed prepare (the leak interruption landed first), and the
|
|
1498
|
+
// `message_start`/`message_end` copies below are what persistence and
|
|
1499
|
+
// every consumer retain. Repairing only at executeToolCalls would
|
|
1500
|
+
// leave those copies under a never-materialized id while the result
|
|
1501
|
+
// carries the minted one. The later prepare stays idempotent — the
|
|
1502
|
+
// repaired mark rides the snapshot (object spread), and the minted
|
|
1503
|
+
// ids are already claimed at run scope.
|
|
1504
|
+
ensureUniqueToolCallIds(message, toolCallIdsDispatchedUnder(currentContext));
|
|
1482
1505
|
message = snapshotAssistantMessage(message);
|
|
1483
1506
|
currentContext.messages.push(message);
|
|
1484
1507
|
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
|
|
@@ -1673,6 +1696,7 @@ async function runLoopBody(
|
|
|
1673
1696
|
|
|
1674
1697
|
if (toolCalls.length > 0) {
|
|
1675
1698
|
pausedTurnContinuations = 0;
|
|
1699
|
+
dsmlLeakNudges = 0;
|
|
1676
1700
|
} else if (
|
|
1677
1701
|
!hasMoreToolCalls &&
|
|
1678
1702
|
message.stopReason === "stop" &&
|
|
@@ -1686,6 +1710,25 @@ async function runLoopBody(
|
|
|
1686
1710
|
// mid-work turn.
|
|
1687
1711
|
pausedTurnContinuations++;
|
|
1688
1712
|
hasMoreToolCalls = true;
|
|
1713
|
+
} else if (
|
|
1714
|
+
!hasMoreToolCalls &&
|
|
1715
|
+
message.stopReason === "stop" &&
|
|
1716
|
+
dsmlLeakMessages.has(message) &&
|
|
1717
|
+
dsmlLeakNudges < MAX_DSML_LEAK_NUDGES
|
|
1718
|
+
) {
|
|
1719
|
+
// DeepSeek wrote its tool call as raw DSML text the healer could not
|
|
1720
|
+
// recover (often missing the opening envelope). The markup was
|
|
1721
|
+
// stripped before commit; without a nudge the loop would yield as if
|
|
1722
|
+
// the model were done, so tell it the call failed and re-sample.
|
|
1723
|
+
const nudge = injectExecutionAdditionalContext(
|
|
1724
|
+
currentContext,
|
|
1725
|
+
newMessages,
|
|
1726
|
+
stream,
|
|
1727
|
+
dsmlToolCallLeakPrompt,
|
|
1728
|
+
);
|
|
1729
|
+
if (nudge) additionalMessages.push(nudge);
|
|
1730
|
+
dsmlLeakNudges++;
|
|
1731
|
+
hasMoreToolCalls = true;
|
|
1689
1732
|
}
|
|
1690
1733
|
|
|
1691
1734
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
@@ -1955,6 +1998,7 @@ async function streamAssistantResponse(
|
|
|
1955
1998
|
// per model without touching the shared session `serviceTier`.
|
|
1956
1999
|
const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
|
|
1957
2000
|
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
|
|
2001
|
+
const dsmlLeakRecoveryEnabled = isDsmlLeakRecoveryTarget(model);
|
|
1958
2002
|
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
|
1959
2003
|
const requestSignal = harmonyAbortController
|
|
1960
2004
|
? signal
|
|
@@ -2031,6 +2075,7 @@ async function streamAssistantResponse(
|
|
|
2031
2075
|
const finishChat = async (message: AssistantMessage): Promise<void> => {
|
|
2032
2076
|
await finishChatSpan(telemetry, chatSpan, message, {
|
|
2033
2077
|
stepNumber: chatStepNumber,
|
|
2078
|
+
modelId: model.id,
|
|
2034
2079
|
serviceTier: effectiveServiceTier,
|
|
2035
2080
|
responseHeaders: capturedHeaders,
|
|
2036
2081
|
baseUrl: model.baseUrl,
|
|
@@ -2183,6 +2228,18 @@ async function streamAssistantResponse(
|
|
|
2183
2228
|
}
|
|
2184
2229
|
}
|
|
2185
2230
|
finalMessage = snapshotAssistantMessage(finalMessage);
|
|
2231
|
+
// Unhealed DSML tool-call markup must never reach history: replaying
|
|
2232
|
+
// it teaches the model to keep writing calls as text (#10556). Strip
|
|
2233
|
+
// it before the context, the UI, and the session see the message.
|
|
2234
|
+
// Only DSML-speaking models qualify; from others it is prose.
|
|
2235
|
+
if (
|
|
2236
|
+
dsmlLeakRecoveryEnabled &&
|
|
2237
|
+
finalMessage.stopReason === "stop" &&
|
|
2238
|
+
!finalMessage.content.some(block => block.type === "toolCall") &&
|
|
2239
|
+
removeDsmlToolMarkupLeak(finalMessage)
|
|
2240
|
+
) {
|
|
2241
|
+
dsmlLeakMessages.add(finalMessage);
|
|
2242
|
+
}
|
|
2186
2243
|
// Expand inline macros (and any other registered rewrite) on the
|
|
2187
2244
|
// finalized message before it reaches the context, the UI, or tool
|
|
2188
2245
|
// dispatch — so a single mutation is the source of truth for all three.
|
|
@@ -2447,7 +2504,10 @@ async function streamAssistantResponse(
|
|
|
2447
2504
|
}
|
|
2448
2505
|
|
|
2449
2506
|
try {
|
|
2450
|
-
let trailing =
|
|
2507
|
+
let trailing = recoverTransientErrorToolTurn(
|
|
2508
|
+
retainCompletedToolCalls(await response.result(), completedToolCallIds),
|
|
2509
|
+
context.tools ?? [],
|
|
2510
|
+
);
|
|
2451
2511
|
if (harmonyMitigationEnabled) {
|
|
2452
2512
|
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
|
2453
2513
|
if (detection) {
|
|
@@ -2736,6 +2796,9 @@ interface PreparedToolCall {
|
|
|
2736
2796
|
prepareError?: unknown;
|
|
2737
2797
|
}
|
|
2738
2798
|
|
|
2799
|
+
/** Committed assistant messages whose unhealed DSML tool-call markup was stripped; the loop answers them with a failure nudge. */
|
|
2800
|
+
const dsmlLeakMessages = new WeakSet<AssistantMessage>();
|
|
2801
|
+
|
|
2739
2802
|
/**
|
|
2740
2803
|
* Prepare results computed in the stream-done branch (before `message_start`/
|
|
2741
2804
|
* `message_end`) so a `beforeToolCall` args revision is baked into the message
|
|
@@ -2839,15 +2902,110 @@ function formatToolNotFoundMessage(
|
|
|
2839
2902
|
return `Tool ${name} not found. Closest available: ${suggestions.slice(0, MAX_TOOL_NAME_SUGGESTIONS).join(", ")}`;
|
|
2840
2903
|
}
|
|
2841
2904
|
|
|
2905
|
+
/**
|
|
2906
|
+
* Enforce the invariant every downstream matcher keys on: `toolCall.id` is
|
|
2907
|
+
* unique and non-empty across the whole run — not just within one assistant
|
|
2908
|
+
* message. Providers can deliver a reused id (wire length truncation, Responses
|
|
2909
|
+
* `callId|itemId` composite collapse) or an id that never materialized (`""`) —
|
|
2910
|
+
* the same occurrences the replay pipeline repairs or drops
|
|
2911
|
+
* (`deduplicateToolCallIds` / `sanitizeMalformedToolCalls`). Id-keyed per-call
|
|
2912
|
+
* state — the prepare map below, every UI and persistence matcher — would
|
|
2913
|
+
* otherwise merge sibling calls: the later call's prepared entry overwrites the
|
|
2914
|
+
* earlier's, both calls execute the sibling's payload, and colliding result ids
|
|
2915
|
+
* merge or drop one sibling's output in every consumer. Message-scope
|
|
2916
|
+
* uniqueness is not enough for the session-wide consumers
|
|
2917
|
+
* (`formatSessionHistoryMarkdown`'s last-entry-wins map, the advisor delta
|
|
2918
|
+
* split, `EventController.#toolTimelineComponents`, persistence keys): a second
|
|
2919
|
+
* assistant turn whose ids never materialized used to re-mint `call_1`,
|
|
2920
|
+
* `call_2`, … and re-derive the same `_dup` suffixes, so those consumers merged
|
|
2921
|
+
* the cross-turn collisions. Every id kept or minted here is therefore claimed
|
|
2922
|
+
* in the run-scoped registry (`toolCallIdsDispatchedUnder`) and every candidate
|
|
2923
|
+
* is generated until unused at run scope. Never-materialized is the sanitizer's
|
|
2924
|
+
* trim test (`isMalformedToolCallId`): a whitespace-only id is minted like an
|
|
2925
|
+
* empty one, never kept — it would be dropped from provider replay together
|
|
2926
|
+
* with its result, the live/replay divergence this repair exists to close.
|
|
2927
|
+
* Cursor server-resolved blocks are never re-keyed (the provider correlates
|
|
2928
|
+
* their results out-of-band under its own ids) but their ids still join the
|
|
2929
|
+
* registry so no minted candidate collides with one. Idempotent: the message is
|
|
2930
|
+
* marked repaired and re-entry is a no-op, so the Harmony path can repair
|
|
2931
|
+
* before its snapshots while the dispatch-time prepare sees the already-minted
|
|
2932
|
+
* ids and leaves them alone.
|
|
2933
|
+
*/
|
|
2934
|
+
const kToolCallIdsRepaired = Symbol("agent-loop.toolCallIdsRepaired");
|
|
2935
|
+
|
|
2936
|
+
/** Assistant message optionally carrying the {@link kToolCallIdsRepaired} mark. */
|
|
2937
|
+
type ToolCallIdsRepairedCarrier = AssistantMessage & { [kToolCallIdsRepaired]?: true };
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* Run-scoped registry of every tool-call id {@link ensureUniqueToolCallIds} has
|
|
2941
|
+
* kept, minted, or seen on a server-resolved block under one
|
|
2942
|
+
* {@link AgentContext}. Keyed by the dispatch context (the same object every
|
|
2943
|
+
* `prepareToolCallDispatch` receives) so the uniqueness window spans every
|
|
2944
|
+
* assistant turn of the run rather than resetting per message. Each run gets a
|
|
2945
|
+
* fresh loop context, so minted ids use the process-unique `mintToolCallId`.
|
|
2946
|
+
*/
|
|
2947
|
+
const dispatchedToolCallIdsByContext = new WeakMap<AgentContext, Set<string>>();
|
|
2948
|
+
|
|
2949
|
+
function toolCallIdsDispatchedUnder(context: AgentContext): Set<string> {
|
|
2950
|
+
let dispatched = dispatchedToolCallIdsByContext.get(context);
|
|
2951
|
+
if (!dispatched) {
|
|
2952
|
+
dispatched = new Set();
|
|
2953
|
+
dispatchedToolCallIdsByContext.set(context, dispatched);
|
|
2954
|
+
}
|
|
2955
|
+
return dispatched;
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
function ensureUniqueToolCallIds(message: ToolCallIdsRepairedCarrier, dispatchedIds: Set<string>): void {
|
|
2959
|
+
if (message[kToolCallIdsRepaired] === true) return;
|
|
2960
|
+
const content = message.content;
|
|
2961
|
+
const reserved = new Set<string>();
|
|
2962
|
+
for (const block of content) {
|
|
2963
|
+
if (block.type === "toolCall" && block.id) reserved.add(block.id);
|
|
2964
|
+
}
|
|
2965
|
+
const seen = new Set<string>();
|
|
2966
|
+
for (const block of content) {
|
|
2967
|
+
if (block.type !== "toolCall") continue;
|
|
2968
|
+
if ((block as CursorExecResolvedCarrier)[kCursorExecResolved] === true) {
|
|
2969
|
+
if (block.id?.trim()) dispatchedIds.add(block.id);
|
|
2970
|
+
continue;
|
|
2971
|
+
}
|
|
2972
|
+
if (block.id?.trim() && !seen.has(block.id) && !dispatchedIds.has(block.id)) {
|
|
2973
|
+
seen.add(block.id);
|
|
2974
|
+
dispatchedIds.add(block.id);
|
|
2975
|
+
continue;
|
|
2976
|
+
}
|
|
2977
|
+
let candidate: string;
|
|
2978
|
+
if (block.id?.trim()) {
|
|
2979
|
+
let suffix = 1;
|
|
2980
|
+
candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
|
|
2981
|
+
while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate)) {
|
|
2982
|
+
suffix += 1;
|
|
2983
|
+
candidate = appendDuplicateSuffix(block.id, `_dup${suffix}`, MAX_TOOL_CALL_ID_LENGTH);
|
|
2984
|
+
}
|
|
2985
|
+
} else {
|
|
2986
|
+
// Process-unique minter: a per-run counter would re-mint `call_1` in
|
|
2987
|
+
// every run and collide with earlier runs' calls in session history.
|
|
2988
|
+
do {
|
|
2989
|
+
candidate = mintToolCallId();
|
|
2990
|
+
} while (seen.has(candidate) || reserved.has(candidate) || dispatchedIds.has(candidate));
|
|
2991
|
+
}
|
|
2992
|
+
block.id = candidate;
|
|
2993
|
+
seen.add(candidate);
|
|
2994
|
+
dispatchedIds.add(candidate);
|
|
2995
|
+
}
|
|
2996
|
+
message[kToolCallIdsRepaired] = true;
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2842
2999
|
/**
|
|
2843
3000
|
* Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
|
|
2844
|
-
* call order: intent extraction, argument validation, and
|
|
2845
|
-
* hook. A hook `args` revision is revalidated against the
|
|
2846
|
-
* written back to `toolCall.arguments`; run before
|
|
2847
|
-
* (the streamed path) that makes the revision the
|
|
2848
|
-
* history, execution events, persistence, provider
|
|
2849
|
-
* scheduling, and `tool.execute` all agree.
|
|
2850
|
-
*
|
|
3001
|
+
* call order: tool-call id repair, intent extraction, argument validation, and
|
|
3002
|
+
* the `beforeToolCall` hook. A hook `args` revision is revalidated against the
|
|
3003
|
+
* tool schema and written back to `toolCall.arguments`; run before
|
|
3004
|
+
* `message_start`/`message_end` (the streamed path) that makes the revision the
|
|
3005
|
+
* single source of truth — history, execution events, persistence, provider
|
|
3006
|
+
* replay, concurrency scheduling, and `tool.execute` all agree. Id repair in
|
|
3007
|
+
* {@link ensureUniqueToolCallIds} follows the same rule. Failures are recorded
|
|
3008
|
+
* per call and surfaced by `executeToolCalls` at the record's scheduled slot.
|
|
2851
3009
|
*/
|
|
2852
3010
|
async function prepareToolCallDispatch(
|
|
2853
3011
|
assistantMessage: AssistantMessage,
|
|
@@ -2856,6 +3014,9 @@ async function prepareToolCallDispatch(
|
|
|
2856
3014
|
signal: AbortSignal | undefined,
|
|
2857
3015
|
): Promise<Map<string, PreparedToolCall>> {
|
|
2858
3016
|
const { resolveFallbackTool, suggestFallbackToolNames, intentTracing, beforeToolCall } = config;
|
|
3017
|
+
// Unique, non-empty ids first: everything below and every consumer of this
|
|
3018
|
+
// message keys per-call state on `toolCall.id`.
|
|
3019
|
+
ensureUniqueToolCallIds(assistantMessage, toolCallIdsDispatchedUnder(context));
|
|
2859
3020
|
const prepared = new Map<string, PreparedToolCall>();
|
|
2860
3021
|
for (const toolCall of assistantMessage.content) {
|
|
2861
3022
|
if (toolCall.type !== "toolCall") continue;
|
|
@@ -2872,17 +3033,13 @@ async function prepareToolCallDispatch(
|
|
|
2872
3033
|
prepared.set(toolCall.id, entry);
|
|
2873
3034
|
let argsForExecution = toolCall.arguments as Record<string, unknown>;
|
|
2874
3035
|
if (intentTracing) {
|
|
2875
|
-
|
|
3036
|
+
// A schema-owned `i` is a tool argument, never a harness intent.
|
|
3037
|
+
const ownsIntent = tool !== undefined && schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD);
|
|
3038
|
+
const { intent, strippedArgs } = ownsIntent
|
|
3039
|
+
? { intent: undefined, strippedArgs: argsForExecution }
|
|
3040
|
+
: extractIntent(toolCall.arguments);
|
|
2876
3041
|
argsForExecution = strippedArgs;
|
|
2877
|
-
|
|
2878
|
-
// args. Unknown tools fall through to the not-found error; a tool that
|
|
2879
|
-
// owns `i` as a real parameter has nowhere else to put the value.
|
|
2880
|
-
if (
|
|
2881
|
-
intent !== undefined &&
|
|
2882
|
-
intent.length > MAX_INTENT_LENGTH &&
|
|
2883
|
-
tool &&
|
|
2884
|
-
!schemaDefinesProperty(toolWireSchema(tool), INTENT_FIELD)
|
|
2885
|
-
) {
|
|
3042
|
+
if (intent !== undefined && intent.length > MAX_INTENT_LENGTH && tool) {
|
|
2886
3043
|
entry.args = strippedArgs;
|
|
2887
3044
|
entry.validationErrorMessage = `\`${INTENT_FIELD}\` is a short intent label (at most ${MAX_INTENT_LENGTH} chars); the value you sent is ${intent.length} chars. The tool was not run. Put that content in the tool's own parameters and retry with a brief \`${INTENT_FIELD}\`.`;
|
|
2888
3045
|
continue;
|
package/src/agent.ts
CHANGED
|
@@ -26,6 +26,7 @@ import type { Dialect } from "@linxiraos/pi-ai/dialect";
|
|
|
26
26
|
import type { HarmonyAuditEvent } from "@linxiraos/pi-ai/utils/harmony-leak";
|
|
27
27
|
import { getBundledModel } from "@linxiraos/pi-catalog/models";
|
|
28
28
|
import { logger } from "@linxiraos/pi-utils";
|
|
29
|
+
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
29
30
|
import {
|
|
30
31
|
abortReasonText,
|
|
31
32
|
agentLoop,
|
|
@@ -386,6 +387,13 @@ interface CursorToolResultEntry {
|
|
|
386
387
|
|
|
387
388
|
type QueuedMessageQueue = "steering" | "followUp";
|
|
388
389
|
|
|
390
|
+
interface LiveSteeredEntry {
|
|
391
|
+
message: AgentMessage;
|
|
392
|
+
controller: AbortController | undefined;
|
|
393
|
+
/** Context preparation committed for the batch, carried by its last adopted entry. Never requeued. */
|
|
394
|
+
additional?: readonly AgentMessage[];
|
|
395
|
+
}
|
|
396
|
+
|
|
389
397
|
interface QueuedMessageClaim {
|
|
390
398
|
messages: AgentMessage[];
|
|
391
399
|
controller: AbortController;
|
|
@@ -420,16 +428,20 @@ export class Agent {
|
|
|
420
428
|
* transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
|
|
421
429
|
* {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
|
|
422
430
|
* replacement must not drop it too; the run's end requeues whatever it did not record, and
|
|
423
|
-
* {@link
|
|
431
|
+
* {@link withdrawUndeliveredQueuedMessages} takes it back ahead of an abort.
|
|
424
432
|
*/
|
|
425
|
-
#liveSteered:
|
|
426
|
-
/** Dequeued originals remain recoverable until their transcript events arrive.
|
|
433
|
+
#liveSteered: LiveSteeredEntry[] = [];
|
|
434
|
+
/** Dequeued originals remain recoverable until their transcript events arrive. `additional` is
|
|
435
|
+
* the context preparation appended after them, which the transcript records with them. */
|
|
427
436
|
#queuedMessageDeliveries = new Set<{
|
|
428
437
|
queue: QueuedMessageQueue;
|
|
429
438
|
controller: AbortController | undefined;
|
|
430
439
|
messages: AgentMessage[];
|
|
431
440
|
next: number;
|
|
441
|
+
additional: readonly AgentMessage[];
|
|
432
442
|
}>();
|
|
443
|
+
/** Messages {@link withdrawUndeliveredQueuedMessages} took back, keyed to the run that must not record them. */
|
|
444
|
+
#withdrawnMessages = new WeakMap<AgentMessage, AbortController>();
|
|
433
445
|
#steeringWaiters = new Set<() => void>();
|
|
434
446
|
#queuedMessageGrouping?: (previous: AgentMessage, next: AgentMessage) => boolean;
|
|
435
447
|
|
|
@@ -484,6 +496,7 @@ export class Agent {
|
|
|
484
496
|
#onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
|
|
485
497
|
#onBeforeYield?: () => Promise<void> | void;
|
|
486
498
|
#onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
|
|
499
|
+
#onModelCallSystemPrompt?: (systemPrompt: string[]) => void;
|
|
487
500
|
#beforeModelCall?: AgentBeforeModelCall;
|
|
488
501
|
#additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
|
|
489
502
|
#asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
|
|
@@ -831,19 +844,22 @@ export class Agent {
|
|
|
831
844
|
|
|
832
845
|
/**
|
|
833
846
|
* Tokenizer for the active model. The instance is replaced whenever the
|
|
834
|
-
* active model's encoding
|
|
835
|
-
* not cache it across model switches.
|
|
847
|
+
* active model's encoding or snapcompact frame pricing changes (see
|
|
848
|
+
* {@link setModel}), so callers must not cache it across model switches.
|
|
836
849
|
*/
|
|
837
850
|
get tokenizer(): Tokenizer {
|
|
838
851
|
return this.#tokenizer;
|
|
839
852
|
}
|
|
840
853
|
|
|
841
854
|
/**
|
|
842
|
-
* Swap the tokenizer only when the encoding
|
|
843
|
-
* per-message memo survives same-
|
|
855
|
+
* Swap the tokenizer only when the encoding or frame pricing actually
|
|
856
|
+
* changes, so the warm per-message memo survives same-pricing model switches.
|
|
844
857
|
*/
|
|
845
858
|
#syncTokenizer(model: Model | null | undefined): void {
|
|
846
|
-
if (
|
|
859
|
+
if (
|
|
860
|
+
tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
|
|
861
|
+
snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
|
|
862
|
+
) {
|
|
847
863
|
this.#tokenizer = new Tokenizer(model);
|
|
848
864
|
}
|
|
849
865
|
}
|
|
@@ -967,7 +983,7 @@ export class Agent {
|
|
|
967
983
|
if (messages.length === 0) return messages;
|
|
968
984
|
const runController = this.#abortController;
|
|
969
985
|
if (!prepare) {
|
|
970
|
-
this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0 });
|
|
986
|
+
this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
|
|
971
987
|
return messages;
|
|
972
988
|
}
|
|
973
989
|
|
|
@@ -986,7 +1002,13 @@ export class Agent {
|
|
|
986
1002
|
throw new DOMException("Queued message preparation cancelled", "AbortError");
|
|
987
1003
|
}
|
|
988
1004
|
delete this.#queuedMessageClaims[queue];
|
|
989
|
-
this.#queuedMessageDeliveries.add({
|
|
1005
|
+
this.#queuedMessageDeliveries.add({
|
|
1006
|
+
queue,
|
|
1007
|
+
controller: runController,
|
|
1008
|
+
messages,
|
|
1009
|
+
next: 0,
|
|
1010
|
+
additional: additional ?? [],
|
|
1011
|
+
});
|
|
990
1012
|
return additional?.length ? [...messages, ...additional] : messages;
|
|
991
1013
|
} catch (error) {
|
|
992
1014
|
if (signal.aborted) throw error;
|
|
@@ -1048,10 +1070,15 @@ export class Agent {
|
|
|
1048
1070
|
const pending = delivery.messages.slice(delivery.next);
|
|
1049
1071
|
const kept = pending.filter(message => !taken.includes(message));
|
|
1050
1072
|
if (kept.length === pending.length) continue;
|
|
1073
|
+
let last: LiveSteeredEntry | undefined;
|
|
1051
1074
|
for (const message of pending) {
|
|
1052
|
-
if (taken.includes(message))
|
|
1075
|
+
if (!taken.includes(message)) continue;
|
|
1076
|
+
last = { message, controller: delivery.controller };
|
|
1077
|
+
this.#liveSteered.push(last);
|
|
1053
1078
|
}
|
|
1054
1079
|
if (kept.length === 0) {
|
|
1080
|
+
// The delivery record goes away; keep its context withdrawable with the adopted batch.
|
|
1081
|
+
if (last && delivery.additional.length > 0) last.additional = delivery.additional;
|
|
1055
1082
|
this.#queuedMessageDeliveries.delete(delivery);
|
|
1056
1083
|
} else {
|
|
1057
1084
|
delivery.messages = kept;
|
|
@@ -1061,13 +1088,29 @@ export class Agent {
|
|
|
1061
1088
|
}
|
|
1062
1089
|
|
|
1063
1090
|
/**
|
|
1064
|
-
* Take back
|
|
1065
|
-
* the
|
|
1091
|
+
* Take back dequeued input the transcript has not recorded yet, ahead of an abort (Esc restores
|
|
1092
|
+
* it to the editor): steering live steering took for the in-flight response, then each batch
|
|
1093
|
+
* dequeued for the next model call, oldest first. The aborted run then neither records it (or
|
|
1094
|
+
* the context prepared for it) nor requeues it.
|
|
1066
1095
|
*/
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1096
|
+
withdrawUndeliveredQueuedMessages(): { steering: AgentMessage[]; followUp: AgentMessage[] } {
|
|
1097
|
+
const withdrawn: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
|
|
1098
|
+
const suppress = (message: AgentMessage, controller: AbortController | undefined) => {
|
|
1099
|
+
if (controller) this.#withdrawnMessages.set(message, controller);
|
|
1100
|
+
};
|
|
1101
|
+
for (const { message, controller, additional } of this.#liveSteered) {
|
|
1102
|
+
withdrawn.steering.push(message);
|
|
1103
|
+
suppress(message, controller);
|
|
1104
|
+
for (const context of additional ?? []) suppress(context, controller);
|
|
1105
|
+
}
|
|
1069
1106
|
this.#liveSteered = [];
|
|
1070
|
-
|
|
1107
|
+
for (const delivery of this.#queuedMessageDeliveries) {
|
|
1108
|
+
const pending = delivery.messages.slice(delivery.next);
|
|
1109
|
+
withdrawn[delivery.queue].push(...pending);
|
|
1110
|
+
for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
|
|
1111
|
+
}
|
|
1112
|
+
this.#queuedMessageDeliveries.clear();
|
|
1113
|
+
return withdrawn;
|
|
1071
1114
|
}
|
|
1072
1115
|
|
|
1073
1116
|
/** Steering live steering took for the streaming response; the transcript records it once
|
|
@@ -1101,6 +1144,11 @@ export class Agent {
|
|
|
1101
1144
|
this.#onTurnEnd = fn;
|
|
1102
1145
|
}
|
|
1103
1146
|
|
|
1147
|
+
/** Called with the exact system prompt each model call is built from, after before-model-call hooks. */
|
|
1148
|
+
setOnModelCallSystemPrompt(fn: ((systemPrompt: string[]) => void) | undefined): void {
|
|
1149
|
+
this.#onModelCallSystemPrompt = fn;
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1104
1152
|
/**
|
|
1105
1153
|
* Install or replace the host pre-model-call gate; pass `undefined` to
|
|
1106
1154
|
* remove it. Gates are sampled when a run starts: installing the first
|
|
@@ -1800,6 +1848,7 @@ export class Agent {
|
|
|
1800
1848
|
await Bun.sleep(0);
|
|
1801
1849
|
}
|
|
1802
1850
|
context.systemPrompt = this.#state.systemPrompt;
|
|
1851
|
+
this.#onModelCallSystemPrompt?.(context.systemPrompt);
|
|
1803
1852
|
context.tools = this.#toolsForModel(this.#state.model ?? model);
|
|
1804
1853
|
},
|
|
1805
1854
|
beforeModelCall:
|
|
@@ -1879,8 +1928,19 @@ export class Agent {
|
|
|
1879
1928
|
? agentLoop(messages, context, config, loopSignal, this.streamFn)
|
|
1880
1929
|
: agentLoopContinue(context, config, loopSignal, this.streamFn);
|
|
1881
1930
|
|
|
1882
|
-
for await (
|
|
1931
|
+
for await (let event of stream) {
|
|
1883
1932
|
if (this.#abortController !== loopAbortController) return;
|
|
1933
|
+
// Withdrawn input the loop already holds for this run never reaches the transcript.
|
|
1934
|
+
if (
|
|
1935
|
+
(event.type === "message_start" || event.type === "message_end") &&
|
|
1936
|
+
this.#withdrawnMessages.get(event.message) === loopAbortController
|
|
1937
|
+
) {
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
if (event.type === "agent_end") {
|
|
1941
|
+
const messages = event.messages.filter(m => this.#withdrawnMessages.get(m) !== loopAbortController);
|
|
1942
|
+
if (messages.length < event.messages.length) event = { ...event, messages };
|
|
1943
|
+
}
|
|
1884
1944
|
if (event.type === "turn_start") turnOpen = true;
|
|
1885
1945
|
if (event.type === "turn_end") turnOpen = false;
|
|
1886
1946
|
// Update internal state based on events
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Anthropic on-demand compaction (`compact-2026-09-04` beta).
|
|
3
3
|
*
|
|
4
|
-
* The request sends only the prefix to summarize,
|
|
5
|
-
* system prompt, tools and thinking settings.
|
|
6
|
-
* replaces that prefix; the retained tail is
|
|
4
|
+
* The request sends only the prefix to summarize, as the live conversation
|
|
5
|
+
* sent it: same system prompt, tools, history bytes and thinking settings.
|
|
6
|
+
* The returned signed block replaces that prefix; the retained tail is
|
|
7
|
+
* replayed from session entries.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type {
|
|
11
|
+
AnthropicCompactionFiles,
|
|
10
12
|
AnthropicCompactionPayload,
|
|
11
13
|
ApiKey,
|
|
14
|
+
Context,
|
|
12
15
|
Effort,
|
|
13
|
-
Message,
|
|
14
16
|
Model,
|
|
15
17
|
SimpleStreamOptions,
|
|
16
|
-
Tool,
|
|
17
18
|
Usage,
|
|
18
19
|
} from "@linxiraos/pi-ai";
|
|
19
20
|
import * as AIError from "@linxiraos/pi-ai/error";
|
|
21
|
+
import { resolveAnthropicCompactionEffort } from "@linxiraos/pi-ai/providers/anthropic";
|
|
20
22
|
import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic-compaction";
|
|
21
23
|
import { isRecord, prompt } from "@linxiraos/pi-utils";
|
|
22
24
|
import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
|
|
@@ -33,8 +35,12 @@ export interface AnthropicCompactionPreserveData {
|
|
|
33
35
|
signature?: string;
|
|
34
36
|
/** Legacy threshold block state; replay-only. */
|
|
35
37
|
encryptedContent?: string;
|
|
36
|
-
/** Harness file metadata (`<files>` section) replayed after the
|
|
38
|
+
/** Harness file metadata (`<files>` section) replayed after the retained tail. */
|
|
37
39
|
filesText?: string;
|
|
40
|
+
/** Earlier summaries' file metadata replayed inside the retained tail. */
|
|
41
|
+
retainedFiles?: AnthropicCompactionFiles[];
|
|
42
|
+
/** The retained tail replays unchanged (see `AnthropicCompactionPayload.exactTail`). */
|
|
43
|
+
exactTail?: true;
|
|
38
44
|
/** Model that wrote the summary. */
|
|
39
45
|
model?: string;
|
|
40
46
|
/** Prompt tokens the compaction request processed, for display. */
|
|
@@ -76,6 +82,15 @@ export function getPreservedAnthropicCompactionData(
|
|
|
76
82
|
...(typeof candidate.filesText === "string" && candidate.filesText.length > 0
|
|
77
83
|
? { filesText: candidate.filesText }
|
|
78
84
|
: {}),
|
|
85
|
+
...(Array.isArray(candidate.retainedFiles) && candidate.retainedFiles.length > 0
|
|
86
|
+
? {
|
|
87
|
+
retainedFiles: candidate.retainedFiles.filter(
|
|
88
|
+
(files): files is AnthropicCompactionFiles =>
|
|
89
|
+
isRecord(files) && typeof files.text === "string" && typeof files.after === "number",
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
...(candidate.exactTail === true ? { exactTail: true as const } : {}),
|
|
79
94
|
...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
|
|
80
95
|
...(typeof candidate.usedTokens === "number" ? { usedTokens: candidate.usedTokens } : {}),
|
|
81
96
|
};
|
|
@@ -109,6 +124,8 @@ export function getAnthropicCompactionPayload(
|
|
|
109
124
|
...(preserved.signature ? { signature: preserved.signature } : {}),
|
|
110
125
|
...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
|
|
111
126
|
...(preserved.filesText ? { filesText: preserved.filesText } : {}),
|
|
127
|
+
...(preserved.retainedFiles?.length ? { retainedFiles: preserved.retainedFiles } : {}),
|
|
128
|
+
...(preserved.exactTail ? { exactTail: true as const } : {}),
|
|
112
129
|
};
|
|
113
130
|
}
|
|
114
131
|
|
|
@@ -167,10 +184,11 @@ export function buildAnthropicCompactionInstructions(
|
|
|
167
184
|
}
|
|
168
185
|
|
|
169
186
|
export interface AnthropicNativeCompactionRequest {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
tools?: Tool[];
|
|
187
|
+
/** The summarized prefix as the live turn would send it. */
|
|
188
|
+
context: Context;
|
|
173
189
|
instructions: string;
|
|
190
|
+
/** Replayed file metadata due before this time ends the request (see `AnthropicCompactionRequest.filesDueBefore`). */
|
|
191
|
+
filesDueBefore?: number;
|
|
174
192
|
maxTokens: number;
|
|
175
193
|
reasoning?: Effort;
|
|
176
194
|
}
|
|
@@ -214,12 +232,12 @@ export async function requestAnthropicNativeCompaction(
|
|
|
214
232
|
): Promise<AnthropicNativeCompactionResponse> {
|
|
215
233
|
const response = await instrumentedCompleteSimple(
|
|
216
234
|
model,
|
|
217
|
-
|
|
235
|
+
request.context,
|
|
218
236
|
{
|
|
219
237
|
apiKey,
|
|
220
238
|
signal,
|
|
221
239
|
maxTokens: request.maxTokens,
|
|
222
|
-
reasoning: request.reasoning,
|
|
240
|
+
reasoning: resolveAnthropicCompactionEffort(model, request.context.messages, request.reasoning),
|
|
223
241
|
initiatorOverride: options.initiatorOverride,
|
|
224
242
|
metadata: options.metadata,
|
|
225
243
|
fetch: options.fetch,
|
|
@@ -227,7 +245,7 @@ export async function requestAnthropicNativeCompaction(
|
|
|
227
245
|
promptCacheKey: options.promptCacheKey,
|
|
228
246
|
providerSessionState: options.providerSessionState,
|
|
229
247
|
maxInFlightRequests: options.maxInFlightRequests,
|
|
230
|
-
anthropicCompaction: { instructions: request.instructions },
|
|
248
|
+
anthropicCompaction: { instructions: request.instructions, filesDueBefore: request.filesDueBefore },
|
|
231
249
|
},
|
|
232
250
|
{
|
|
233
251
|
telemetry: options.telemetry,
|