@linxiraos/pi-agent-core 1.1.20 → 1.1.23

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +243 -2
  2. package/README.md +4 -3
  3. package/THIRD-PARTY-NOTICES.txt +3 -40
  4. package/dist/types/agent-loop.d.ts +14 -1
  5. package/dist/types/agent.d.ts +36 -0
  6. package/dist/types/compaction/anthropic.d.ts +22 -52
  7. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  8. package/dist/types/compaction/compaction.d.ts +4 -2
  9. package/dist/types/compaction/messages.d.ts +9 -0
  10. package/dist/types/compaction/openai.d.ts +11 -0
  11. package/dist/types/compaction/pruning.d.ts +9 -0
  12. package/dist/types/compaction/transcript-tokens.d.ts +25 -2
  13. package/dist/types/index.d.ts +3 -0
  14. package/dist/types/live-steering.d.ts +34 -0
  15. package/dist/types/output-budget.d.ts +54 -0
  16. package/dist/types/sent-tool-definitions.d.ts +17 -0
  17. package/dist/types/speculative-execution.d.ts +2 -1
  18. package/dist/types/telemetry.d.ts +103 -60
  19. package/dist/types/tool-context.d.ts +32 -0
  20. package/dist/types/types.d.ts +105 -7
  21. package/package.json +10 -10
  22. package/src/agent-loop.ts +386 -90
  23. package/src/agent.ts +175 -26
  24. package/src/compaction/anthropic.ts +62 -105
  25. package/src/compaction/branch-summarization.ts +1 -1
  26. package/src/compaction/compaction-v2-streaming.ts +13 -4
  27. package/src/compaction/compaction.ts +63 -51
  28. package/src/compaction/messages.ts +12 -2
  29. package/src/compaction/openai.ts +37 -9
  30. package/src/compaction/prompts/anthropic-compaction-instructions.md +2 -6
  31. package/src/compaction/pruning.ts +1 -1
  32. package/src/compaction/transcript-tokens.ts +64 -3
  33. package/src/index.ts +6 -0
  34. package/src/live-steering.ts +89 -0
  35. package/src/output-budget.ts +142 -0
  36. package/src/sent-tool-definitions.ts +40 -0
  37. package/src/speculative-execution.ts +12 -0
  38. package/src/telemetry.ts +240 -117
  39. package/src/tokenizer.ts +45 -6
  40. package/src/tool-context.ts +55 -0
  41. package/src/types.ts +109 -7
package/src/agent.ts CHANGED
@@ -34,11 +34,19 @@ import {
34
34
  normalizeMessagesForProvider,
35
35
  normalizeTools,
36
36
  resolveOwnedDialectFromEnv,
37
+ steeringQueueState,
37
38
  unpairedToolCallTail,
38
39
  } from "./agent-loop";
39
40
  import type { AppendOnlyContextManager } from "./append-only-context";
40
41
  import { isProviderRefusalMessage } from "./replay-policy";
42
+ import { SentToolDefinitions } from "./sent-tool-definitions";
41
43
  import { Tokenizer, tokenizerEncodingForModel } from "./tokenizer";
44
+ import {
45
+ createAdditionalContextMessage,
46
+ joinAdditionalContext,
47
+ TOOL_RESULT_ADDITIONAL_CONTEXT,
48
+ type ToolResultWithAdditionalContext,
49
+ } from "./tool-context";
42
50
  import type {
43
51
  AgentBeforeModelCall,
44
52
  AgentContext,
@@ -65,7 +73,7 @@ import { EventLoopKeepalive } from "./utils/yield";
65
73
  function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
66
74
  return messages.filter((m): m is Message => {
67
75
  if (m.role === "assistant") return !isProviderRefusalMessage(m);
68
- return m.role === "user" || m.role === "toolResult";
76
+ return m.role === "user" || m.role === "developer" || m.role === "toolResult";
69
77
  });
70
78
  }
71
79
 
@@ -271,6 +279,11 @@ export interface AgentOptions {
271
279
  pruneToolDescriptions?: boolean;
272
280
  /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
273
281
  dialect?: Dialect;
282
+ /**
283
+ * Per-request owned-dialect resolver, consulted with the model being requested.
284
+ * Authoritative when set (like {@link serviceTierResolver}): replaces {@link dialect}.
285
+ */
286
+ dialectResolver?: (model: Model) => Dialect | undefined;
274
287
  /**
275
288
  * When owned tool calling is active and the model fabricates a tool result
276
289
  * mid-turn: `true` (default) aborts the provider request immediately; `false`
@@ -333,6 +346,8 @@ export interface AgentOptions {
333
346
  * tool-call arguments). See {@link AgentLoopConfig.transformAssistantMessage}.
334
347
  */
335
348
  transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
349
+ /** See {@link AgentLoopConfig.transformAssistantMessagePreservesToolCalls}. */
350
+ transformAssistantMessagePreservesToolCalls?: boolean;
336
351
 
337
352
  /**
338
353
  * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
@@ -361,6 +376,12 @@ interface CursorToolResultEntry {
361
376
  * `message_end` lands in the same chunk as the tool result.
362
377
  */
363
378
  pending?: Promise<void>;
379
+ /**
380
+ * Passive context the executor attached via
381
+ * {@link TOOL_RESULT_ADDITIONAL_CONTEXT}, captured before any transformer
382
+ * can replace the message. Injected after the buffered results.
383
+ */
384
+ additionalContext?: string;
364
385
  }
365
386
 
366
387
  type QueuedMessageQueue = "steering" | "followUp";
@@ -389,9 +410,18 @@ export class Agent {
389
410
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
390
411
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
391
412
  #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
413
+ #sentToolDefinitions = new SentToolDefinitions();
392
414
  #steeringQueue: AgentMessage[] = [];
393
415
  #followUpQueue: AgentMessage[] = [];
394
416
  #queuedMessageClaims: Partial<Record<QueuedMessageQueue, QueuedMessageClaim>> = {};
417
+ /**
418
+ * Steering live steering took for the in-flight response (`onLiveSteeringTaken`) that the
419
+ * transcript has not recorded yet, whether or not the provider accepted it. Kept apart from
420
+ * {@link #queuedMessageDeliveries}: the loop drops it on abort instead of recording it, so queue
421
+ * replacement must not drop it too; the run's end requeues whatever it did not record, and
422
+ * {@link withdrawLiveSteering} takes it back ahead of an abort.
423
+ */
424
+ #liveSteered: { message: AgentMessage; controller: AbortController | undefined }[] = [];
395
425
  /** Dequeued originals remain recoverable until their transcript events arrive. */
396
426
  #queuedMessageDeliveries = new Set<{
397
427
  queue: QueuedMessageQueue;
@@ -439,6 +469,7 @@ export class Agent {
439
469
  #intentTracing: boolean;
440
470
  #pruneToolDescriptions: boolean;
441
471
  #dialect?: Dialect;
472
+ #dialectResolver?: (model: Model) => Dialect | undefined;
442
473
  #abortOnFabricatedToolResult?: boolean;
443
474
  #getToolChoice?: () => ToolChoiceDirective | undefined;
444
475
  #onToolChoiceUnavailable?: () => void;
@@ -482,10 +513,17 @@ export class Agent {
482
513
  * UI emission, and tool dispatch. Reassign at any time to swap the implementation.
483
514
  */
484
515
  transformAssistantMessage?: AgentLoopConfig["transformAssistantMessage"];
516
+ /** Declares {@link transformAssistantMessage} never rewrites streamed tool calls; reassign alongside it. */
517
+ transformAssistantMessagePreservesToolCalls?: boolean;
485
518
  /**
486
519
  * Hook that peeks whether interrupting IRC asides are queued for the next boundary.
487
520
  */
488
521
  hasIrcInterrupts?: AgentLoopConfig["hasIrcInterrupts"];
522
+ /**
523
+ * Hook that peeks whether background completions (jobs, supervised processes)
524
+ * are queued for the next boundary.
525
+ */
526
+ hasBackgroundCompletions?: AgentLoopConfig["hasBackgroundCompletions"];
489
527
 
490
528
  constructor(opts: AgentOptions = {}) {
491
529
  this.#state = { ...this.#state, ...opts.initialState };
@@ -533,6 +571,7 @@ export class Agent {
533
571
  this.#intentTracing = opts.intentTracing === true;
534
572
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
535
573
  this.#dialect = opts.dialect;
574
+ this.#dialectResolver = opts.dialectResolver;
536
575
  this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
537
576
  this.#getToolChoice = opts.getToolChoice;
538
577
  this.#onToolChoiceUnavailable = opts.onToolChoiceUnavailable;
@@ -541,6 +580,7 @@ export class Agent {
541
580
  this.beforeToolCall = opts.beforeToolCall;
542
581
  this.afterToolCall = opts.afterToolCall;
543
582
  this.transformAssistantMessage = opts.transformAssistantMessage;
583
+ this.transformAssistantMessagePreservesToolCalls = opts.transformAssistantMessagePreservesToolCalls;
544
584
  this.#telemetry = opts.telemetry;
545
585
  this.#appendOnlyContext = opts.appendOnlyContext;
546
586
  this.#transformProviderContext = opts.transformProviderContext;
@@ -742,6 +782,33 @@ export class Agent {
742
782
  this.#hideThinkingSummary = value;
743
783
  }
744
784
 
785
+ /** Strip tool descriptions from provider-bound specs; read per request. */
786
+ get pruneToolDescriptions(): boolean {
787
+ return this.#pruneToolDescriptions;
788
+ }
789
+
790
+ set pruneToolDescriptions(value: boolean) {
791
+ this.#pruneToolDescriptions = value;
792
+ }
793
+
794
+ /** Inject/strip the intent field on tool calls; applies from the next prompt run. */
795
+ get intentTracing(): boolean {
796
+ return this.#intentTracing;
797
+ }
798
+
799
+ set intentTracing(value: boolean) {
800
+ this.#intentTracing = value;
801
+ }
802
+
803
+ /** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
804
+ get abortOnFabricatedToolResult(): boolean | undefined {
805
+ return this.#abortOnFabricatedToolResult;
806
+ }
807
+
808
+ set abortOnFabricatedToolResult(value: boolean | undefined) {
809
+ this.#abortOnFabricatedToolResult = value;
810
+ }
811
+
745
812
  /**
746
813
  * Get the current max retry delay in milliseconds.
747
814
  */
@@ -822,7 +889,9 @@ export class Agent {
822
889
  ): Promise<Context> {
823
890
  const model = this.#state.model;
824
891
  if (!model) throw new Error("No active model on agent");
825
- const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
892
+ const ownedDialect =
893
+ (this.#dialectResolver ? this.#dialectResolver(model) : this.#dialect) ??
894
+ resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
826
895
  const messages = normalizeMessagesForProvider(llmMessages, model);
827
896
  const tools = ownedDialect
828
897
  ? []
@@ -832,6 +901,11 @@ export class Agent {
832
901
  }) ?? []);
833
902
  let context: Context = { systemPrompt, messages, tools };
834
903
  if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
904
+ // Side requests reuse the main loop's sent definitions without recording their own.
905
+ if (context.tools?.length) {
906
+ const inactiveTools = this.#sentToolDefinitions.inactiveFor(context.messages, context.tools);
907
+ if (inactiveTools) context = { ...context, inactiveTools };
908
+ }
835
909
  return context;
836
910
  }
837
911
 
@@ -932,8 +1006,14 @@ export class Agent {
932
1006
  }
933
1007
 
934
1008
  #restoreUndeliveredQueuedMessages(controller: AbortController): void {
935
- if (this.#queuedMessageDeliveries.size === 0) return;
1009
+ if (this.#queuedMessageDeliveries.size === 0 && this.#liveSteered.length === 0) return;
936
1010
  const restored: Record<QueuedMessageQueue, AgentMessage[]> = { steering: [], followUp: [] };
1011
+ // Live steering was taken before anything the run still holds undelivered.
1012
+ this.#liveSteered = this.#liveSteered.filter(entry => {
1013
+ if (entry.controller !== controller) return true;
1014
+ restored.steering.push(entry.message);
1015
+ return false;
1016
+ });
937
1017
  for (const delivery of this.#queuedMessageDeliveries) {
938
1018
  if (delivery.controller !== controller) continue;
939
1019
  this.#queuedMessageDeliveries.delete(delivery);
@@ -948,6 +1028,40 @@ export class Agent {
948
1028
  if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
949
1029
  }
950
1030
 
1031
+ /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
1032
+ #adoptLiveSteering(taken: readonly AgentMessage[]): void {
1033
+ for (const delivery of this.#queuedMessageDeliveries) {
1034
+ const pending = delivery.messages.slice(delivery.next);
1035
+ const kept = pending.filter(message => !taken.includes(message));
1036
+ if (kept.length === pending.length) continue;
1037
+ for (const message of pending) {
1038
+ if (taken.includes(message)) this.#liveSteered.push({ message, controller: delivery.controller });
1039
+ }
1040
+ if (kept.length === 0) {
1041
+ this.#queuedMessageDeliveries.delete(delivery);
1042
+ } else {
1043
+ delivery.messages = kept;
1044
+ delivery.next = 0;
1045
+ }
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Take back live-steered messages ahead of an abort (Esc restores them to the editor):
1051
+ * the aborted run then neither records nor requeues them.
1052
+ */
1053
+ withdrawLiveSteering(): AgentMessage[] {
1054
+ const messages = this.peekLiveSteeredMessages();
1055
+ this.#liveSteered = [];
1056
+ return messages;
1057
+ }
1058
+
1059
+ /** Steering live steering took for the streaming response; the transcript records it once
1060
+ * that response (or its tool batch) ends, which is when the model switches to it. */
1061
+ peekLiveSteeredMessages(): AgentMessage[] {
1062
+ return this.#liveSteered.map(entry => entry.message);
1063
+ }
1064
+
951
1065
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void {
952
1066
  this.#onResponse = fn;
953
1067
  }
@@ -1087,6 +1201,11 @@ export class Agent {
1087
1201
 
1088
1202
  appendMessage(m: AgentMessage) {
1089
1203
  this.#state.messages.push(m);
1204
+ const live = this.#liveSteered.findIndex(entry => entry.message === m);
1205
+ if (live >= 0) {
1206
+ this.#liveSteered.splice(live, 1);
1207
+ return;
1208
+ }
1090
1209
  for (const delivery of this.#queuedMessageDeliveries) {
1091
1210
  if (delivery.messages[delivery.next] !== m) continue;
1092
1211
  if (++delivery.next === delivery.messages.length) this.#queuedMessageDeliveries.delete(delivery);
@@ -1142,6 +1261,7 @@ export class Agent {
1142
1261
  clearAllQueues() {
1143
1262
  this.#steeringQueue = [];
1144
1263
  this.#followUpQueue = [];
1264
+ this.#liveSteered = [];
1145
1265
  this.#cancelQueuedMessagePreparation("steering");
1146
1266
  this.#cancelQueuedMessagePreparation("followUp");
1147
1267
  this.#notifySteeringWaiters();
@@ -1167,6 +1287,18 @@ export class Agent {
1167
1287
  return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1168
1288
  }
1169
1289
 
1290
+ /** Dequeued messages not yet in the transcript, e.g. steering a provider
1291
+ * took into its in-flight response via live steering. Aborting the run
1292
+ * requeues them, so the session's empty-submit interrupt counts them as
1293
+ * pending input even though {@link peekSteeringQueue} no longer does. */
1294
+ peekUndeliveredQueuedMessages(): AgentMessage[] {
1295
+ const messages = this.peekLiveSteeredMessages();
1296
+ for (const delivery of this.#queuedMessageDeliveries) {
1297
+ for (let i = delivery.next; i < delivery.messages.length; i++) messages.push(delivery.messages[i]);
1298
+ }
1299
+ return messages;
1300
+ }
1301
+
1170
1302
  /** Non-consuming view of the pending follow-up queue. See
1171
1303
  * {@link peekSteeringQueue}. */
1172
1304
  peekFollowUpQueue(): readonly AgentMessage[] {
@@ -1506,7 +1638,10 @@ export class Agent {
1506
1638
  // that, a transformer resolving after the swap would patch a detached
1507
1639
  // object while the persisted result kept the original payload — the
1508
1640
  // rewrite silently lost.
1509
- const entry: CursorToolResultEntry = { toolResult: message };
1641
+ const entry: CursorToolResultEntry = {
1642
+ toolResult: message,
1643
+ additionalContext: (message as ToolResultWithAdditionalContext)[TOOL_RESULT_ADDITIONAL_CONTEXT],
1644
+ };
1510
1645
  this.#cursorToolResultBuffer.push(entry);
1511
1646
  const transform = this.#cursorOnToolResult;
1512
1647
  if (transform) {
@@ -1574,6 +1709,7 @@ export class Agent {
1574
1709
  preferWebsockets: this.#preferWebsockets,
1575
1710
  convertToLlm: this.#convertToLlm,
1576
1711
  transformProviderContext: this.#transformProviderContext,
1712
+ sentToolDefinitions: this.#sentToolDefinitions,
1577
1713
  transformContext: this.#transformContext,
1578
1714
  onPayload: this.#onPayload,
1579
1715
  onResponse: this.#onResponse,
@@ -1611,6 +1747,7 @@ export class Agent {
1611
1747
  intentTracing: this.#intentTracing,
1612
1748
  pruneToolDescriptions: this.#pruneToolDescriptions,
1613
1749
  dialect: this.#dialect,
1750
+ getDialect: this.#dialectResolver,
1614
1751
  abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
1615
1752
  appendOnlyContext: this.#appendOnlyContext,
1616
1753
  beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
@@ -1618,6 +1755,7 @@ export class Agent {
1618
1755
  transformAssistantMessage: this.transformAssistantMessage
1619
1756
  ? (message, signal) => this.transformAssistantMessage?.(message, signal)
1620
1757
  : undefined,
1758
+ transformAssistantMessagePreservesToolCalls: this.transformAssistantMessagePreservesToolCalls,
1621
1759
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1622
1760
  onHarmonyLeak: this.#onHarmonyLeak,
1623
1761
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
@@ -1637,29 +1775,17 @@ export class Agent {
1637
1775
  }
1638
1776
  return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
1639
1777
  },
1640
- hasSteeringMessages: () => {
1641
- if (this.#steeringQueue.length === 0) {
1642
- return { queued: false };
1643
- }
1644
- const messageCount = this.#steeringMode === "one-at-a-time" ? 1 : this.#steeringQueue.length;
1645
- let hasAgentSteering = false;
1646
- for (let i = 0; i < messageCount; i++) {
1647
- const message = this.#steeringQueue[i];
1648
- const role = "role" in message ? message.role : undefined;
1649
- const attribution = "attribution" in message ? message.attribution : undefined;
1650
- if (attribution === "user") {
1651
- return { queued: true, source: "user" };
1652
- }
1653
- if (role !== "user") continue;
1654
- if (attribution !== "agent") {
1655
- return { queued: true, source: "user" };
1656
- }
1657
- hasAgentSteering = true;
1658
- }
1659
- return { queued: true, source: hasAgentSteering ? "agent" : "system" };
1660
- },
1778
+ hasSteeringMessages: () =>
1779
+ steeringQueueState(
1780
+ this.#steeringQueue,
1781
+ this.#steeringMode === "one-at-a-time"
1782
+ ? Math.min(1, this.#steeringQueue.length)
1783
+ : this.#steeringQueue.length,
1784
+ ),
1661
1785
  waitForSteeringMessages: signal => this.#waitForSteeringMessages(signal),
1786
+ onLiveSteeringTaken: messages => this.#adoptLiveSteering(messages),
1662
1787
  hasIrcInterrupts: this.hasIrcInterrupts,
1788
+ hasBackgroundCompletions: this.hasBackgroundCompletions,
1663
1789
  getFollowUpMessages: signal => this.#dequeueFollowUpMessagesAfterHooks(signal ?? loopSignal),
1664
1790
  getAsideMessages: async () => (await this.#asideMessageProvider?.()) ?? [],
1665
1791
  onBeforeYield: () => this.#onBeforeYield?.(),
@@ -1767,6 +1893,9 @@ export class Agent {
1767
1893
  .map(entry => entry.pending);
1768
1894
  if (pendingTransforms.length > 0) await Promise.all(pendingTransforms);
1769
1895
  const bufferedCursorResults = this.#cursorToolResultBuffer.map(({ toolResult }) => toolResult);
1896
+ const bufferedCursorContext = joinAdditionalContext(
1897
+ this.#cursorToolResultBuffer.map(({ additionalContext }) => additionalContext),
1898
+ );
1770
1899
  const retainedToolCallIds = new Set(completedToolCallIds);
1771
1900
  for (const { toolCallId } of bufferedCursorResults) retainedToolCallIds.add(toolCallId);
1772
1901
  const errorMsg: AssistantMessage =
@@ -1843,9 +1972,13 @@ export class Agent {
1843
1972
  this.#emit({ type: "message_end", message: toolResult });
1844
1973
  toolResults.push(toolResult);
1845
1974
  }
1975
+ const agentEndMessages: AgentMessage[] = [errorMsg, ...toolResults];
1976
+ if (bufferedCursorContext !== undefined) {
1977
+ agentEndMessages.push(this.#emitCursorAdditionalContext(bufferedCursorContext));
1978
+ }
1846
1979
  this.#emit({ type: "turn_end", message: errorMsg, toolResults });
1847
1980
  turnOpen = false;
1848
- this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
1981
+ this.#emit({ type: "agent_end", messages: agentEndMessages });
1849
1982
  } else {
1850
1983
  this.appendMessage(errorMsg);
1851
1984
  this.#state.error = errorMessage;
@@ -1917,8 +2050,24 @@ export class Agent {
1917
2050
  this.appendMessage(toolResult);
1918
2051
  this.#emit({ type: "message_end", message: toolResult });
1919
2052
  }
2053
+ const additionalContext = joinAdditionalContext(buffer.map(entry => entry.additionalContext));
2054
+ if (additionalContext !== undefined) this.#emitCursorAdditionalContext(additionalContext);
1920
2055
  } finally {
1921
2056
  this.#cursorToolResultDrain = undefined;
1922
2057
  }
1923
2058
  }
2059
+
2060
+ /**
2061
+ * Append passive context reported by Cursor exec-channel tools after their
2062
+ * results, mirroring the loop's post-batch developer message. Cursor runs
2063
+ * those tools server-side mid-stream, so the context reaches the next
2064
+ * provider request instead of the current one.
2065
+ */
2066
+ #emitCursorAdditionalContext(text: string): AgentMessage {
2067
+ const message = createAdditionalContextMessage(text);
2068
+ this.#emit({ type: "message_start", message });
2069
+ this.appendMessage(message);
2070
+ this.#emit({ type: "message_end", message });
2071
+ return message;
2072
+ }
1924
2073
  }
@@ -1,21 +1,14 @@
1
1
  /**
2
- * Anthropic server-side compaction (`compact-2026-01-12` beta).
2
+ * Anthropic on-demand compaction (`compact-2026-09-04` beta).
3
3
  *
4
- * The compaction request is the live turn's own request shape — same system
5
- * prompt, tools, and message history — plus the `compact_20260112` edit with
6
- * `pause_after_compaction`. The API summarizes the prompt from the already
7
- * cached prefix and stops; the summary arrives as a `compaction` block that
8
- * the provider surfaces as an `anthropicCompaction` payload. The summary is
9
- * plain text, so it doubles as the compaction entry's readable summary for
10
- * every other provider, while the Anthropic provider replays it as a native
11
- * block (the API drops everything that precedes it). The retained tail after
12
- * the cut point is replayed from session entries exactly like a local summary.
4
+ * The request sends only the prefix to summarize, with the live conversation's
5
+ * system prompt, tools and thinking settings. The returned signed block
6
+ * replaces that prefix; the retained tail is replayed from session entries.
13
7
  */
14
8
 
15
9
  import type {
16
10
  AnthropicCompactionPayload,
17
11
  ApiKey,
18
- AssistantMessage,
19
12
  Effort,
20
13
  Message,
21
14
  Model,
@@ -24,29 +17,21 @@ import type {
24
17
  Usage,
25
18
  } from "@linxiraos/pi-ai";
26
19
  import * as AIError from "@linxiraos/pi-ai/error";
27
- import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic";
20
+ import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic-compaction";
28
21
  import { isRecord, prompt } from "@linxiraos/pi-utils";
29
22
  import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
23
+ import type { AgentMessage } from "../types";
30
24
  import anthropicCompactionInstructionsPrompt from "./prompts/anthropic-compaction-instructions.md" with { type: "text" };
31
25
 
32
26
  export const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
33
27
 
34
- /** The API rejects a `compact_20260112` trigger below this many input tokens. */
35
- export const ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS = 50_000;
36
-
37
- /**
38
- * Smallest context the native lane accepts. The trigger sits at the API
39
- * floor, so a prompt that lands below it is answered instead of compacted;
40
- * the margin over the floor absorbs the difference between the last reported
41
- * context size and the compaction request's own input.
42
- */
43
- export const ANTHROPIC_COMPACTION_MIN_CONTEXT_TOKENS = 55_000;
44
-
45
28
  /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
46
29
  export interface AnthropicCompactionPreserveData {
47
30
  provider: string;
48
31
  content: string;
49
- /** Opaque provider state the API attached to the block; replayed verbatim. */
32
+ /** Signature attached to an on-demand block; replayed verbatim. */
33
+ signature?: string;
34
+ /** Legacy threshold block state; replay-only. */
50
35
  encryptedContent?: string;
51
36
  /** Harness file metadata (`<files>` section) replayed after the native block. */
52
37
  filesText?: string;
@@ -82,6 +67,9 @@ export function getPreservedAnthropicCompactionData(
82
67
  return {
83
68
  provider: candidate.provider,
84
69
  content: candidate.content,
70
+ ...(typeof candidate.signature === "string" && candidate.signature.length > 0
71
+ ? { signature: candidate.signature }
72
+ : {}),
85
73
  ...(typeof candidate.encryptedContent === "string" && candidate.encryptedContent.length > 0
86
74
  ? { encryptedContent: candidate.encryptedContent }
87
75
  : {}),
@@ -118,93 +106,63 @@ export function getAnthropicCompactionPayload(
118
106
  type: "anthropicCompaction",
119
107
  provider: preserved.provider,
120
108
  content: preserved.content,
109
+ ...(preserved.signature ? { signature: preserved.signature } : {}),
121
110
  ...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
122
111
  ...(preserved.filesText ? { filesText: preserved.filesText } : {}),
123
112
  };
124
113
  }
125
114
 
126
115
  /**
127
- * The retained tail as the model will see it, for the summarization
128
- * instructions: how many of the conversation's final wire messages stay in
129
- * context verbatim, and the role of the first. The compaction request carries
130
- * the whole conversation so the prompt cache the live turn wrote is read, but
131
- * the summary must cover only the history before that tail — the local
132
- * summarizer never sees the tail, and the rebuilt context replays it after the
133
- * summary. Counting mirrors the provider's message conversion (consecutive
134
- * tool results collapse into one user message; developer messages are user
135
- * messages). Structured for the prompt template, which renders the
136
- * singular/plural wording; the description quotes no content: quoting the
137
- * tail would hand the summarizer the very facts it must leave to the tail.
116
+ * Move the existing keep-tail boundary forward until the summary/tail boundary
117
+ * alternates wire roles and no tool call is separated from its result. If no
118
+ * boundary is safe, the request summarizes the entire snapshot (empty tail).
138
119
  */
139
- export interface RetainedTailScope {
140
- count: number;
141
- role: "assistant" | "user";
142
- }
143
-
144
- export function describeRetainedTail(messages: readonly Message[]): RetainedTailScope | undefined {
145
- const first = messages[0];
146
- if (!first) return undefined;
147
- let count = 0;
148
- let previousWasToolResult = false;
149
- for (const message of messages) {
150
- const isToolResult = message.role === "toolResult";
151
- if (!(isToolResult && previousWasToolResult)) count += 1;
152
- previousWasToolResult = isToolResult;
120
+ export function findAnthropicCompactionCut(
121
+ messages: readonly (AgentMessage | { role: "system"; content: string; timestamp: number })[],
122
+ initialCut: number,
123
+ ): number {
124
+ let calls: Map<string, number> | undefined;
125
+ for (let i = 0; i < messages.length; i++) {
126
+ const message = messages[i];
127
+ if (message.role !== "assistant") continue;
128
+ for (const block of message.content) {
129
+ if (block.type === "toolCall") (calls ??= new Map()).set(block.id, i);
130
+ }
153
131
  }
154
- // Mirror the provider's trailing-assistant prefill: a tail ending in a
155
- // live assistant turn gains a synthetic trailing user message on the
156
- // wire, which stays verbatim too. Only blocks the converter emits count —
157
- // blank text never serializes, and images, redacted thinking, and fallback
158
- // markers need target context this scope lacks, so a turn of only those
159
- // emits nothing and draws no pad. Without the pad in
160
- // the scope, the summary could duplicate the tail head.
161
- const last = messages[messages.length - 1];
162
- if (last?.role === "assistant" && last.content.some(emitsWireBlock)) {
163
- count += 1;
132
+ let lastResult: Int32Array | undefined;
133
+ if (calls) {
134
+ lastResult = new Int32Array(messages.length);
135
+ for (let i = 0; i < messages.length; i++) {
136
+ const message = messages[i];
137
+ if (message.role !== "toolResult") continue;
138
+ const callIndex = calls.get(message.toolCallId);
139
+ if (callIndex !== undefined) lastResult[callIndex] = i;
140
+ }
164
141
  }
165
- return { count, role: first.role === "assistant" ? "assistant" : "user" };
166
- }
167
-
168
- /**
169
- * Whether an assistant content block reaches the wire. Server-tool blocks
170
- * serialize unconditionally; blank text never does. Redacted thinking and
171
- * fallback markers replay only for specific deployments, which the scope
172
- * cannot see, so a turn of only those conservatively draws no pad.
173
- */
174
- function emitsWireBlock(block: AssistantMessage["content"][number]): boolean {
175
- switch (block.type) {
176
- case "text":
177
- return block.text.trim().length > 0;
178
- case "toolCall":
179
- case "anthropicServerTool":
180
- return true;
181
- case "thinking":
182
- return block.thinking.trim().length > 0 || (block.thinkingSignature ?? "").trim().length > 0;
183
- default:
184
- return false;
142
+ let protectedThrough = -1;
143
+ for (let i = 0; i < initialCut; i++) protectedThrough = Math.max(protectedThrough, lastResult?.[i] ?? -1);
144
+ for (let cut = initialCut; cut < messages.length; cut++) {
145
+ protectedThrough = Math.max(protectedThrough, lastResult?.[cut - 1] ?? -1);
146
+ if (cut <= protectedThrough) continue;
147
+ const first = messages[cut];
148
+ if (first.role === "system" || first.role === "developer") continue;
149
+ const previous = messages[cut - 1];
150
+ if (!previous) continue;
151
+ if ((previous.role === "assistant") !== (first.role === "assistant")) return cut;
185
152
  }
153
+ return messages.length;
186
154
  }
187
155
 
188
- /**
189
- * Summarization prompt sent as the edit's `instructions`, which replace the
190
- * API default entirely. The template lays out the retained-tail boundary
191
- * first, so the summary covers only the history the rebuilt context drops,
192
- * then the caller's extra context, the same structure prompt as the local
193
- * summarizer, the caller's focus, and the tool-abstention clause the API
194
- * recommends when tools are defined (a summarization pass that calls a tool
195
- * yields no summary).
196
- */
156
+ /** Instructions replace the API default; the request contains only summarized messages. */
197
157
  export function buildAnthropicCompactionInstructions(
198
158
  basePrompt: string,
199
159
  customInstructions: string | undefined,
200
160
  extraContext: string | undefined,
201
- retainedTail: RetainedTailScope | undefined,
202
161
  ): string {
203
162
  return prompt.render(anthropicCompactionInstructionsPrompt, {
204
163
  basePrompt,
205
164
  customInstructions,
206
165
  extraContext,
207
- retainedTail,
208
166
  });
209
167
  }
210
168
 
@@ -219,7 +177,7 @@ export interface AnthropicNativeCompactionRequest {
219
177
 
220
178
  export interface AnthropicNativeCompactionResponse {
221
179
  content: string;
222
- encryptedContent?: string;
180
+ signature: string;
223
181
  usage: Usage;
224
182
  model: string;
225
183
  }
@@ -239,15 +197,13 @@ export interface AnthropicNativeCompactionOptions
239
197
  Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {}
240
198
 
241
199
  /**
242
- * Run one compaction request and return the summary the API wrote, with the
243
- * opaque `encrypted_content` the API attached for the replay. `completeSimple`
200
+ * Run one compaction request and return the summary and signature the API wrote. `completeSimple`
244
201
  * resolves terminal failures as messages, so their classification is restored
245
202
  * here: an aborted response is an `AbortError` (a cancellation, never a native
246
203
  * failure) and an error response keeps its HTTP status, so auth and timeout
247
204
  * handling downstream classify it the same way as the OpenAI lanes. A response
248
- * without a summary is a native failure — the API answers the prompt instead
249
- * when its input never reached the trigger, and returns an empty block when
250
- * the model called a tool during summarization.
205
+ * without a summary is a native failure, including tool use, refusals and
206
+ * output limits; the configured method order can then choose a fallback.
251
207
  */
252
208
  export async function requestAnthropicNativeCompaction(
253
209
  model: Model<"anthropic-messages">,
@@ -271,11 +227,7 @@ export async function requestAnthropicNativeCompaction(
271
227
  promptCacheKey: options.promptCacheKey,
272
228
  providerSessionState: options.providerSessionState,
273
229
  maxInFlightRequests: options.maxInFlightRequests,
274
- anthropicCompaction: {
275
- triggerInputTokens: ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS,
276
- pauseAfterCompaction: true,
277
- instructions: request.instructions,
278
- },
230
+ anthropicCompaction: { instructions: request.instructions },
279
231
  },
280
232
  {
281
233
  telemetry: options.telemetry,
@@ -294,16 +246,21 @@ export async function requestAnthropicNativeCompaction(
294
246
  : new AIError.ProviderHttpError(message, response.errorStatus);
295
247
  }
296
248
  const payload = response.providerPayload;
297
- if (payload?.type !== "anthropicCompaction" || payload.content.length === 0) {
249
+ if (
250
+ response.stopDetails?.type !== "compaction" ||
251
+ payload?.type !== "anthropicCompaction" ||
252
+ !payload.content ||
253
+ !payload.signature
254
+ ) {
298
255
  throw new Error(
299
256
  response.stopDetails?.type === "compaction"
300
- ? "Anthropic compaction returned no summary"
301
- : "Anthropic compaction response carried no compaction block",
257
+ ? "Anthropic compaction returned no signed summary"
258
+ : `Anthropic compaction response carried no compaction block (stop reason: ${response.stopDetails?.type ?? response.stopReason})`,
302
259
  );
303
260
  }
304
261
  return {
305
262
  content: payload.content,
306
- encryptedContent: payload.encryptedContent,
263
+ signature: payload.signature,
307
264
  usage: response.usage,
308
265
  model: response.model,
309
266
  };
@@ -86,7 +86,7 @@ export interface GenerateBranchSummaryOptions {
86
86
  convertToLlm?: ConvertToLlm;
87
87
  /**
88
88
  * Optional telemetry handle. When provided, the branch summary LLM call is
89
- * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "branch_summary"`.
89
+ * wrapped in an OTEL chat span tagged with `omp.gen_ai.oneshot.kind = "branch_summary"`.
90
90
  */
91
91
  telemetry?: AgentTelemetry;
92
92
  /**