@linxiraos/pi-agent-core 1.2.0 → 1.3.0
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 +40 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/agent.d.ts +15 -5
- package/dist/types/compaction/compaction.d.ts +20 -4
- package/dist/types/compaction/openai.d.ts +13 -2
- package/dist/types/compaction/shake.d.ts +7 -5
- package/dist/types/compaction/utils.d.ts +7 -2
- package/dist/types/image-tokens.d.ts +9 -12
- package/dist/types/output-budget.d.ts +17 -7
- package/dist/types/tokenizer.d.ts +72 -12
- package/dist/types/types.d.ts +22 -1
- package/package.json +10 -10
- package/src/agent-loop.ts +91 -27
- package/src/agent.ts +56 -22
- package/src/compaction/branch-summarization.ts +28 -3
- package/src/compaction/compaction.ts +286 -149
- package/src/compaction/openai.ts +97 -49
- package/src/compaction/shake.ts +19 -8
- package/src/compaction/utils.ts +8 -10
- package/src/image-tokens.ts +13 -15
- package/src/output-budget.ts +22 -9
- package/src/tokenizer.ts +238 -27
- package/src/types.ts +24 -1
package/src/agent-loop.ts
CHANGED
|
@@ -76,7 +76,13 @@ import {
|
|
|
76
76
|
startInvokeAgentSpan,
|
|
77
77
|
} from "./telemetry";
|
|
78
78
|
import { validateAgentToolArguments } from "./tool-arguments";
|
|
79
|
-
import {
|
|
79
|
+
import {
|
|
80
|
+
createAdditionalContextMessage,
|
|
81
|
+
isNonBlankContext,
|
|
82
|
+
joinAdditionalContext,
|
|
83
|
+
TOOL_RESULT_ADDITIONAL_CONTEXT,
|
|
84
|
+
type ToolResultWithAdditionalContext,
|
|
85
|
+
} from "./tool-context";
|
|
80
86
|
import dsmlToolCallLeakPrompt from "./prompts/dsml-tool-call-leak.md" with { type: "text" };
|
|
81
87
|
import type {
|
|
82
88
|
AgentContext,
|
|
@@ -700,7 +706,17 @@ export function agentLoopContinue(
|
|
|
700
706
|
const newMessages: AgentMessage[] = [];
|
|
701
707
|
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
|
|
702
708
|
|
|
703
|
-
|
|
709
|
+
const resumeTail = unpairedToolCallTail(currentContext.messages);
|
|
710
|
+
stream.push(
|
|
711
|
+
resumeTail
|
|
712
|
+
? {
|
|
713
|
+
type: "agent_start",
|
|
714
|
+
resumedToolCallIds: resumeTail.content.flatMap(block =>
|
|
715
|
+
block.type === "toolCall" ? [block.id] : [],
|
|
716
|
+
),
|
|
717
|
+
}
|
|
718
|
+
: { type: "agent_start" },
|
|
719
|
+
);
|
|
704
720
|
|
|
705
721
|
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
706
722
|
} catch (err) {
|
|
@@ -887,7 +903,11 @@ export function normalizeMessagesForProvider(
|
|
|
887
903
|
messages: Context["messages"],
|
|
888
904
|
model: AgentLoopConfig["model"],
|
|
889
905
|
): Context["messages"] {
|
|
890
|
-
|
|
906
|
+
// The anthropic and ollama transports apply `stripThinkingHistory` with their
|
|
907
|
+
// own turn-shape rules; chat completions has no wire-level handling, so the
|
|
908
|
+
// history is stripped here.
|
|
909
|
+
const compat = model.api === "openai-completions" ? model.compat : undefined;
|
|
910
|
+
if (!compat || !("stripThinkingHistory" in compat) || compat.stripThinkingHistory !== true) {
|
|
891
911
|
return messages;
|
|
892
912
|
}
|
|
893
913
|
|
|
@@ -2072,6 +2092,50 @@ async function streamAssistantResponse(
|
|
|
2072
2092
|
return userOnResponse?.(response, modelInfo);
|
|
2073
2093
|
};
|
|
2074
2094
|
|
|
2095
|
+
// Cursor executes its tools server-side during the stream and hands each
|
|
2096
|
+
// result to `cursorOnToolResult`; the host persists them after the assistant
|
|
2097
|
+
// message, so this loop never runs them and never sees them. A follow-up the
|
|
2098
|
+
// loop picks up after this turn needs them in its own context, or the next
|
|
2099
|
+
// request rebuilds this turn with every call unpaired. The host follows the
|
|
2100
|
+
// results with one message carrying their passive context, so that is
|
|
2101
|
+
// mirrored too. Entries are reserved synchronously so concurrent transforms
|
|
2102
|
+
// keep their arrival order. When the server re-sends a call it already ran
|
|
2103
|
+
// (issue #15749), the newer result replaces the earlier one, matching the
|
|
2104
|
+
// single result the host persists.
|
|
2105
|
+
const cursorToolResults: {
|
|
2106
|
+
result: ToolResultMessage;
|
|
2107
|
+
additionalContext: string | undefined;
|
|
2108
|
+
/** The host transform, which the provider does not always await. */
|
|
2109
|
+
pending: Promise<void>;
|
|
2110
|
+
}[] = [];
|
|
2111
|
+
const userCursorOnToolResult = config.cursorOnToolResult;
|
|
2112
|
+
const collectCursorToolResult: AgentLoopConfig["cursorOnToolResult"] = userCursorOnToolResult
|
|
2113
|
+
? async result => {
|
|
2114
|
+
const superseded = cursorToolResults.findIndex(entry => entry.result.toolCallId === result.toolCallId);
|
|
2115
|
+
if (superseded >= 0) cursorToolResults.splice(superseded, 1);
|
|
2116
|
+
// Read before the transform, which may return a message without it.
|
|
2117
|
+
const additionalContext = (result as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT];
|
|
2118
|
+
const { promise: pending, resolve: settle } = Promise.withResolvers<void>();
|
|
2119
|
+
const entry = { result, additionalContext, pending };
|
|
2120
|
+
cursorToolResults.push(entry);
|
|
2121
|
+
try {
|
|
2122
|
+
const settled = await userCursorOnToolResult(result);
|
|
2123
|
+
if (settled) entry.result = settled;
|
|
2124
|
+
return settled;
|
|
2125
|
+
} finally {
|
|
2126
|
+
settle();
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
: undefined;
|
|
2130
|
+
// Like the host before it persists, wait for transforms still running so the
|
|
2131
|
+
// next request carries the result the transcript stores.
|
|
2132
|
+
const appendCursorToolResults = async (): Promise<void> => {
|
|
2133
|
+
await Promise.all(cursorToolResults.map(entry => entry.pending));
|
|
2134
|
+
for (const { result } of cursorToolResults) context.messages.push(result);
|
|
2135
|
+
const additionalContext = joinAdditionalContext(cursorToolResults.map(entry => entry.additionalContext));
|
|
2136
|
+
if (additionalContext !== undefined) context.messages.push(createAdditionalContextMessage(additionalContext));
|
|
2137
|
+
};
|
|
2138
|
+
|
|
2075
2139
|
const finishChat = async (message: AssistantMessage): Promise<void> => {
|
|
2076
2140
|
await finishChatSpan(telemetry, chatSpan, message, {
|
|
2077
2141
|
stepNumber: chatStepNumber,
|
|
@@ -2096,6 +2160,7 @@ async function streamAssistantResponse(
|
|
|
2096
2160
|
cwd: effectiveCwd,
|
|
2097
2161
|
signal: finalRequestSignal,
|
|
2098
2162
|
onResponse: captureOnResponse,
|
|
2163
|
+
cursorOnToolResult: collectCursorToolResult,
|
|
2099
2164
|
liveSteering: providerCall.liveSteering,
|
|
2100
2165
|
});
|
|
2101
2166
|
if (promptToolWireTools && ownedDialect) {
|
|
@@ -2228,17 +2293,15 @@ async function streamAssistantResponse(
|
|
|
2228
2293
|
}
|
|
2229
2294
|
}
|
|
2230
2295
|
finalMessage = snapshotAssistantMessage(finalMessage);
|
|
2231
|
-
// Unhealed DSML
|
|
2232
|
-
//
|
|
2233
|
-
//
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
finalMessage.stopReason === "stop" &&
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
) {
|
|
2241
|
-
dsmlLeakMessages.add(finalMessage);
|
|
2296
|
+
// Unhealed DSML markup must not reach history, including reasoning
|
|
2297
|
+
// and turns with a valid structured call. Only a failed text-only
|
|
2298
|
+
// call needs the recovery nudge.
|
|
2299
|
+
if (dsmlLeakRecoveryEnabled) {
|
|
2300
|
+
const hasToolCall = finalMessage.content.some(block => block.type === "toolCall");
|
|
2301
|
+
const removed = removeDsmlToolMarkupLeak(finalMessage);
|
|
2302
|
+
if (removed && finalMessage.stopReason === "stop" && !hasToolCall) {
|
|
2303
|
+
dsmlLeakMessages.add(finalMessage);
|
|
2304
|
+
}
|
|
2242
2305
|
}
|
|
2243
2306
|
// Expand inline macros (and any other registered rewrite) on the
|
|
2244
2307
|
// finalized message before it reaches the context, the UI, or tool
|
|
@@ -2286,6 +2349,7 @@ async function streamAssistantResponse(
|
|
|
2286
2349
|
} else {
|
|
2287
2350
|
context.messages.push(finalMessage);
|
|
2288
2351
|
}
|
|
2352
|
+
await appendCursorToolResults();
|
|
2289
2353
|
if (!addedPartial) {
|
|
2290
2354
|
stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
|
|
2291
2355
|
}
|
|
@@ -2566,6 +2630,7 @@ async function streamAssistantResponse(
|
|
|
2566
2630
|
context.messages.push(trailing);
|
|
2567
2631
|
stream.push({ type: "message_start", message: snapshotAssistantMessage(trailing) });
|
|
2568
2632
|
}
|
|
2633
|
+
await appendCursorToolResults();
|
|
2569
2634
|
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
2570
2635
|
await finishChat(trailing);
|
|
2571
2636
|
speculationSettled = true;
|
|
@@ -3570,13 +3635,15 @@ async function executeToolCalls(
|
|
|
3570
3635
|
executionArgs,
|
|
3571
3636
|
record.signal,
|
|
3572
3637
|
partialResult => {
|
|
3573
|
-
|
|
3638
|
+
const event: Extract<AgentEvent, { type: "tool_execution_update" }> = {
|
|
3574
3639
|
type: "tool_execution_update",
|
|
3575
3640
|
toolCallId: toolCall.id,
|
|
3576
3641
|
toolName: toolCall.name,
|
|
3577
3642
|
args: executionArgs,
|
|
3578
3643
|
partialResult: coerceToolResult(partialResult).result,
|
|
3579
|
-
}
|
|
3644
|
+
};
|
|
3645
|
+
if (stream.done) config.onDetachedToolUpdate?.(event);
|
|
3646
|
+
else stream.push(event);
|
|
3580
3647
|
},
|
|
3581
3648
|
toolContext,
|
|
3582
3649
|
);
|
|
@@ -3642,20 +3709,17 @@ async function executeToolCalls(
|
|
|
3642
3709
|
const interrupted = interruptState.triggered;
|
|
3643
3710
|
const perToolAborted = record.signal.aborted;
|
|
3644
3711
|
const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
|
|
3645
|
-
if (interrupted && abortedDuringExecution) {
|
|
3646
|
-
//
|
|
3647
|
-
//
|
|
3648
|
-
//
|
|
3712
|
+
if (record.interruptible && interrupted && abortedDuringExecution) {
|
|
3713
|
+
// Queued steering/asides abort only interruptible tools. An external
|
|
3714
|
+
// user abort can also fail a non-interruptible tool (e.g. cancelled
|
|
3715
|
+
// ask); keep its actual error instead of attributing it to the queue.
|
|
3649
3716
|
record.skipped = true;
|
|
3650
3717
|
emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
|
|
3651
3718
|
} else {
|
|
3652
|
-
//
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
//
|
|
3656
|
-
// false "skipped" that discards work the tool performed (#4752). A peer-IRC interrupt
|
|
3657
|
-
// on the batch leaves non-interruptible tools' signals untouched — their genuine
|
|
3658
|
-
// errors survive here too.
|
|
3719
|
+
// A tool that finished before the interrupt landed (`completedToolExecution`)
|
|
3720
|
+
// keeps its real result even if its signal aborted around completion:
|
|
3721
|
+
// the model must see the work it actually performed (#4752).
|
|
3722
|
+
// Non-interruptible tools also retain errors from external user aborts.
|
|
3659
3723
|
emitToolResult(record, result, isError);
|
|
3660
3724
|
}
|
|
3661
3725
|
|
package/src/agent.ts
CHANGED
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
type ToolResultMessage,
|
|
24
24
|
} from "@linxiraos/pi-ai";
|
|
25
25
|
import type { Dialect } from "@linxiraos/pi-ai/dialect";
|
|
26
|
+
import { openAIResponsesReplaysNativeHistory } from "@linxiraos/pi-ai/providers/openai-responses";
|
|
26
27
|
import type { HarmonyAuditEvent } from "@linxiraos/pi-ai/utils/harmony-leak";
|
|
27
28
|
import { getBundledModel } from "@linxiraos/pi-catalog/models";
|
|
28
29
|
import { logger } from "@linxiraos/pi-utils";
|
|
29
|
-
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
30
30
|
import {
|
|
31
31
|
abortReasonText,
|
|
32
32
|
agentLoop,
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
42
42
|
import { isProviderRefusalMessage } from "./replay-policy";
|
|
43
43
|
import { SentToolDefinitions } from "./sent-tool-definitions";
|
|
44
|
-
import { Tokenizer
|
|
44
|
+
import { Tokenizer } from "./tokenizer";
|
|
45
45
|
import {
|
|
46
46
|
createAdditionalContextMessage,
|
|
47
47
|
joinAdditionalContext,
|
|
@@ -60,6 +60,7 @@ import type {
|
|
|
60
60
|
AgentTurnEndContext,
|
|
61
61
|
AsideMessage,
|
|
62
62
|
PrepareQueuedMessages,
|
|
63
|
+
QueuedMessageQueue,
|
|
63
64
|
SpeculativeToolExecutionConfig,
|
|
64
65
|
StreamFn,
|
|
65
66
|
ToolCallContext,
|
|
@@ -385,8 +386,6 @@ interface CursorToolResultEntry {
|
|
|
385
386
|
additionalContext?: string;
|
|
386
387
|
}
|
|
387
388
|
|
|
388
|
-
type QueuedMessageQueue = "steering" | "followUp";
|
|
389
|
-
|
|
390
389
|
interface LiveSteeredEntry {
|
|
391
390
|
message: AgentMessage;
|
|
392
391
|
controller: AbortController | undefined;
|
|
@@ -504,7 +503,7 @@ export class Agent {
|
|
|
504
503
|
#telemetry?: AgentLoopConfig["telemetry"];
|
|
505
504
|
#appendOnlyContext?: AppendOnlyContextManager;
|
|
506
505
|
#beforeQueuedMessageDequeueHooks = new Set<(signal?: AbortSignal) => Promise<void> | void>();
|
|
507
|
-
#beforeModelCallHooks = new Set<(signal
|
|
506
|
+
#beforeModelCallHooks = new Set<(signal: AbortSignal | undefined, context: AgentContext) => Promise<void> | void>();
|
|
508
507
|
|
|
509
508
|
/** Buffered Cursor tool results with text length at time of call (for correct ordering) */
|
|
510
509
|
#cursorToolResultBuffer: CursorToolResultEntry[] = [];
|
|
@@ -850,24 +849,32 @@ export class Agent {
|
|
|
850
849
|
|
|
851
850
|
/**
|
|
852
851
|
* Tokenizer for the active model. The instance is replaced whenever the
|
|
853
|
-
* active model's encoding or
|
|
854
|
-
* {@link setModel}),
|
|
852
|
+
* active model's encoding, image or thinking-replay pricing, or
|
|
853
|
+
* replayed-reasoning reader changes (see {@link setModel}), or its Responses
|
|
854
|
+
* session turns cold or warm, so callers must not cache it across model
|
|
855
|
+
* switches or requests.
|
|
855
856
|
*/
|
|
856
857
|
get tokenizer(): Tokenizer {
|
|
858
|
+
// The provider warms or closes a session inside a request, so the replay
|
|
859
|
+
// state is read here rather than only on model switches.
|
|
860
|
+
const model = this.#state.model;
|
|
861
|
+
if (
|
|
862
|
+
openAIResponsesReplaysNativeHistory(model, this.#providerSessionState) !== this.#tokenizer.nativeHistoryReplay
|
|
863
|
+
) {
|
|
864
|
+
this.#syncTokenizer(model);
|
|
865
|
+
}
|
|
857
866
|
return this.#tokenizer;
|
|
858
867
|
}
|
|
859
868
|
|
|
860
869
|
/**
|
|
861
|
-
* Swap the tokenizer only when the encoding or
|
|
862
|
-
*
|
|
870
|
+
* Swap the tokenizer only when the encoding, image or thinking-replay
|
|
871
|
+
* pricing, replayed-reasoning reader or native-history replay state
|
|
872
|
+
* actually changes, so the warm
|
|
873
|
+
* per-message memo survives same-pricing model switches.
|
|
863
874
|
*/
|
|
864
875
|
#syncTokenizer(model: Model | null | undefined): void {
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) !== this.#tokenizer.frameBillingKey
|
|
868
|
-
) {
|
|
869
|
-
this.#tokenizer = new Tokenizer(model);
|
|
870
|
-
}
|
|
876
|
+
const options = { nativeHistoryReplay: openAIResponsesReplaysNativeHistory(model, this.#providerSessionState) };
|
|
877
|
+
if (!this.#tokenizer.countsAlike(model, options)) this.#tokenizer = new Tokenizer(model, options);
|
|
871
878
|
}
|
|
872
879
|
|
|
873
880
|
get appendOnlyContext(): AppendOnlyContextManager | undefined {
|
|
@@ -956,15 +963,18 @@ export class Agent {
|
|
|
956
963
|
return () => this.#beforeQueuedMessageDequeueHooks.delete(registration);
|
|
957
964
|
}
|
|
958
965
|
|
|
959
|
-
/** Register an independently removable hook that runs immediately before each model call
|
|
960
|
-
|
|
961
|
-
|
|
966
|
+
/** Register an independently removable hook that runs immediately before each model call, with the
|
|
967
|
+
* loop's live context (mutating `context.messages` changes what this and later calls send). */
|
|
968
|
+
addBeforeModelCallHook(
|
|
969
|
+
hook: (signal: AbortSignal | undefined, context: AgentContext) => Promise<void> | void,
|
|
970
|
+
): () => void {
|
|
971
|
+
const registration = (signal: AbortSignal | undefined, context: AgentContext) => hook(signal, context);
|
|
962
972
|
this.#beforeModelCallHooks.add(registration);
|
|
963
973
|
return () => this.#beforeModelCallHooks.delete(registration);
|
|
964
974
|
}
|
|
965
975
|
|
|
966
|
-
async #runBeforeModelCallHooks(signal
|
|
967
|
-
for (const hook of this.#beforeModelCallHooks) await hook(signal);
|
|
976
|
+
async #runBeforeModelCallHooks(signal: AbortSignal | undefined, context: AgentContext): Promise<void> {
|
|
977
|
+
for (const hook of this.#beforeModelCallHooks) await hook(signal, context);
|
|
968
978
|
}
|
|
969
979
|
|
|
970
980
|
async #runBeforeQueuedMessageDequeueHooks(signal?: AbortSignal): Promise<void> {
|
|
@@ -998,7 +1008,7 @@ export class Agent {
|
|
|
998
1008
|
this.#queuedMessageClaims[queue] = claim;
|
|
999
1009
|
const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
|
|
1000
1010
|
try {
|
|
1001
|
-
const preparation = await prepare(messages, preparationSignal);
|
|
1011
|
+
const preparation = await prepare(messages, preparationSignal, queue);
|
|
1002
1012
|
signal.throwIfAborted();
|
|
1003
1013
|
if (preparationSignal.aborted || this.#queuedMessageClaims[queue] !== claim) return [];
|
|
1004
1014
|
const additional = preparation?.commit();
|
|
@@ -1379,6 +1389,20 @@ export class Agent {
|
|
|
1379
1389
|
this.#emitQueueChanged();
|
|
1380
1390
|
}
|
|
1381
1391
|
|
|
1392
|
+
/**
|
|
1393
|
+
* Move the given pending steering messages (by identity) ahead of the rest,
|
|
1394
|
+
* keeping both groups' relative order, so one-at-a-time delivery takes them
|
|
1395
|
+
* first. Messages no longer pending, including a claimed batch already
|
|
1396
|
+
* being delivered, are ignored.
|
|
1397
|
+
*/
|
|
1398
|
+
prioritizeSteering(messages: readonly AgentMessage[]): void {
|
|
1399
|
+
const wanted = new Set(messages);
|
|
1400
|
+
const front = this.#steeringQueue.filter(message => wanted.has(message));
|
|
1401
|
+
if (front.every((message, index) => this.#steeringQueue[index] === message)) return;
|
|
1402
|
+
this.#steeringQueue = [...front, ...this.#steeringQueue.filter(message => !wanted.has(message))];
|
|
1403
|
+
this.#emitQueueChanged();
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1382
1406
|
/**
|
|
1383
1407
|
* Queue a follow-up message to be processed after the agent finishes.
|
|
1384
1408
|
* Delivered only when agent has no more tool calls or steering messages.
|
|
@@ -1797,10 +1821,19 @@ export class Agent {
|
|
|
1797
1821
|
// that, a transformer resolving after the swap would patch a detached
|
|
1798
1822
|
// object while the persisted result kept the original payload — the
|
|
1799
1823
|
// rewrite silently lost.
|
|
1824
|
+
//
|
|
1825
|
+
// A result for a call already buffered is the server re-dispatching
|
|
1826
|
+
// that call; the provider keeps one block for it, so the re-run's
|
|
1827
|
+
// result replaces the first rather than pairing a second result with
|
|
1828
|
+
// the same id (issue #15749).
|
|
1800
1829
|
const entry: CursorToolResultEntry = {
|
|
1801
1830
|
toolResult: message,
|
|
1802
1831
|
additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
|
|
1803
1832
|
};
|
|
1833
|
+
const superseded = this.#cursorToolResultBuffer.findIndex(
|
|
1834
|
+
({ toolResult }) => toolResult.toolCallId === message.toolCallId,
|
|
1835
|
+
);
|
|
1836
|
+
if (superseded >= 0) this.#cursorToolResultBuffer.splice(superseded, 1);
|
|
1804
1837
|
this.#cursorToolResultBuffer.push(entry);
|
|
1805
1838
|
const transform = this.#cursorOnToolResult;
|
|
1806
1839
|
if (transform) {
|
|
@@ -1876,7 +1909,7 @@ export class Agent {
|
|
|
1876
1909
|
getApiKey: this.getApiKey,
|
|
1877
1910
|
getToolContext: this.#getToolContext,
|
|
1878
1911
|
syncContextBeforeModelCall: async (context, signal) => {
|
|
1879
|
-
await this.#runBeforeModelCallHooks(signal);
|
|
1912
|
+
await this.#runBeforeModelCallHooks(signal, context);
|
|
1880
1913
|
if (this.#listeners.size > 0) {
|
|
1881
1914
|
await Bun.sleep(0);
|
|
1882
1915
|
}
|
|
@@ -1950,6 +1983,7 @@ export class Agent {
|
|
|
1950
1983
|
getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
|
|
1951
1984
|
getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
|
|
1952
1985
|
onBeforeYield: () => this.#onBeforeYield?.(),
|
|
1986
|
+
onDetachedToolUpdate: event => this.emitExternalEvent(event),
|
|
1953
1987
|
telemetry: this.#telemetry,
|
|
1954
1988
|
};
|
|
1955
1989
|
|
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions } from "@linxiraos/pi-ai";
|
|
9
|
+
import { resolveCatalogPolicy } from "@linxiraos/pi-catalog/compat/catalog-policy";
|
|
9
10
|
import { preferredDialect } from "@linxiraos/pi-catalog/identity";
|
|
10
11
|
import { prompt } from "@linxiraos/pi-utils";
|
|
12
|
+
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
11
13
|
import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
|
|
12
14
|
import { Tokenizer } from "../tokenizer";
|
|
13
15
|
import type { AgentMessage } from "../types";
|
|
@@ -208,7 +210,16 @@ function getMessageFromEntry(entry: SessionEntry): AgentMessage | undefined {
|
|
|
208
210
|
}
|
|
209
211
|
}
|
|
210
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Tokens `message` adds to the summarizer's input, which is the branch
|
|
215
|
+
* serialized as plain text: tool results truncated as serialization truncates
|
|
216
|
+
* them, assistants without their native replay items or opaque reasoning
|
|
217
|
+
* payloads, which the text never carries.
|
|
218
|
+
*/
|
|
211
219
|
function estimateBranchSummaryTokens(message: AgentMessage, tokenizer: Tokenizer): number {
|
|
220
|
+
if (message.role === "assistant") {
|
|
221
|
+
return tokenizer.countMessage({ ...message, providerPayload: undefined }, { excludeEncryptedReasoning: true });
|
|
222
|
+
}
|
|
212
223
|
if (message.role !== "toolResult") return tokenizer.countMessage(message);
|
|
213
224
|
const text = message.content
|
|
214
225
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
@@ -317,16 +328,30 @@ export async function generateBranchSummary(
|
|
|
317
328
|
const tokenBudget = contextWindow - reserveTokens;
|
|
318
329
|
const tokenizer = new Tokenizer(model);
|
|
319
330
|
|
|
320
|
-
const { messages, fileOps } = prepareBranchEntries(entries, tokenizer, tokenBudget);
|
|
331
|
+
const { messages: branchMessages, fileOps } = prepareBranchEntries(entries, tokenizer, tokenBudget);
|
|
321
332
|
|
|
322
|
-
if (
|
|
333
|
+
if (branchMessages.length === 0) {
|
|
323
334
|
return { summary: "No content to summarize" };
|
|
324
335
|
}
|
|
325
336
|
|
|
337
|
+
// A snapcompact summary rendered for another model may carry reasoning
|
|
338
|
+
// sections and their legend, which Claude refuses (issue #6093).
|
|
339
|
+
const messages = snapcompact.readerIncludesThinking(model)
|
|
340
|
+
? branchMessages
|
|
341
|
+
: branchMessages.map(message =>
|
|
342
|
+
message.role === "compactionSummary"
|
|
343
|
+
? { ...message, summary: snapcompact.textWithoutThinking(message.summary) }
|
|
344
|
+
: message,
|
|
345
|
+
);
|
|
346
|
+
|
|
326
347
|
// Transform to LLM-compatible messages, then serialize to text
|
|
327
348
|
// Serialization prevents the model from treating it as a conversation to continue
|
|
328
349
|
const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
|
|
329
|
-
const conversationText = serializeConversationForSummary(
|
|
350
|
+
const conversationText = serializeConversationForSummary(
|
|
351
|
+
llmMessages,
|
|
352
|
+
preferredDialect(model.id),
|
|
353
|
+
resolveCatalogPolicy(model).classifierCotExtraction === true,
|
|
354
|
+
);
|
|
330
355
|
|
|
331
356
|
// Build prompt
|
|
332
357
|
const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
|