@linxiraos/pi-agent-core 1.2.1 → 1.3.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-agent-core",
4
- "version": "1.2.1",
4
+ "version": "1.3.1",
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": "Linxira-OS",
@@ -28,23 +28,23 @@
28
28
  "types": "./dist/types/index.d.ts",
29
29
  "scripts": {
30
30
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
31
- "check:types": "tsgo -p tsconfig.json --noEmit",
31
+ "check:types": "tsc -p tsconfig.json --noEmit",
32
32
  "lint": "oxlint .",
33
33
  "test": "bun test --parallel",
34
34
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
35
35
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
36
  },
37
37
  "dependencies": {
38
- "@linxiraos/pi-ai": "1.2.1",
39
- "@linxiraos/pi-catalog": "1.2.1",
40
- "@linxiraos/pi-natives": "1.2.1",
41
- "@linxiraos/pi-utils": "1.2.1",
42
- "@linxiraos/pi-wire": "1.2.1",
43
- "@linxiraos/pi-snapcompact": "1.2.1",
38
+ "@linxiraos/pi-ai": "1.3.1",
39
+ "@linxiraos/pi-catalog": "1.3.1",
40
+ "@linxiraos/pi-natives": "1.3.1",
41
+ "@linxiraos/pi-utils": "1.3.1",
42
+ "@linxiraos/pi-wire": "1.3.1",
43
+ "@linxiraos/pi-snapcompact": "1.3.1",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@linxiraos/pi-omptype": "1.2.1",
47
+ "@linxiraos/pi-omptype": "1.3.1",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "1.4.3"
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 { createAdditionalContextMessage, isNonBlankContext, joinAdditionalContext } from "./tool-context";
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
- stream.push({ type: "agent_start" });
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
- if (model.provider !== "cerebras") {
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 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);
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
- stream.push({
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
- // This tool's own signal fired AND it failed to produce a result. The
3647
- // execution may already have performed partial work before throwing on
3648
- // abort, so preserve that distinction in the placeholder metadata.
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
- // No interrupt on this signal, or the tool finished before the interrupt landed
3653
- // (`completedToolExecution`) — even if the signal aborted around completion. Keep
3654
- // its real result: a completed tool already ran its side effects, so the model must
3655
- // see what actually happened (a genuine non-zero exit / error result) rather than a
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, tokenizerEncodingForModel } from "./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?: AbortSignal) => Promise<void> | void>();
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 snapcompact frame pricing changes (see
854
- * {@link setModel}), so callers must not cache it across model switches.
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 frame pricing actually
862
- * changes, so the warm per-message memo survives same-pricing model switches.
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
- if (
866
- tokenizerEncodingForModel(model) !== this.#tokenizer.encoding ||
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
- addBeforeModelCallHook(hook: (signal?: AbortSignal) => Promise<void> | void): () => void {
961
- const registration = (signal?: AbortSignal) => hook(signal);
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?: AbortSignal): Promise<void> {
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 (messages.length === 0) {
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(llmMessages, preferredDialect(model.id));
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;