@linxiraos/pi-agent-core 1.1.13 → 1.1.15

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/src/agent.ts CHANGED
@@ -50,6 +50,7 @@ import type {
50
50
  AgentToolContext,
51
51
  AgentTurnEndContext,
52
52
  AsideMessage,
53
+ SpeculativeToolExecutionConfig,
53
54
  StreamFn,
54
55
  ToolCallContext,
55
56
  ToolChoiceDirective,
@@ -242,13 +243,22 @@ export interface AgentOptions {
242
243
  * Use for deobfuscating secrets or rewriting arguments.
243
244
  */
244
245
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
246
+ /** Host authorization and telemetry for opt-in speculative tool execution. */
247
+ speculativeToolExecution?: SpeculativeToolExecutionConfig;
245
248
 
246
249
  /**
247
250
  * Resolve a tool call whose name matched no advertised tool. Lets hosts
248
251
  * route calls to tools exposed through side transports (e.g. `xd://`
249
252
  * device mounts) instead of failing with "Tool not found".
250
253
  */
251
- resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
254
+ resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
255
+
256
+ /**
257
+ * Names routable by {@link resolveFallbackTool} that the advertised set
258
+ * omits (e.g. `xd://` device mounts), used only to suggest a target when a
259
+ * call misses.
260
+ */
261
+ suggestFallbackToolNames?: () => Iterable<string>;
252
262
 
253
263
  /** Enable intent tracing schema injection/stripping in the harness. */
254
264
  intentTracing?: boolean;
@@ -407,7 +417,9 @@ export class Agent {
407
417
  #kimiApiFormat?: "openai" | "anthropic";
408
418
  #preferWebsockets?: boolean;
409
419
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
410
- #resolveFallbackTool?: (name: string) => AgentTool<any> | undefined;
420
+ #speculativeToolExecution?: SpeculativeToolExecutionConfig;
421
+ #resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
422
+ #suggestFallbackToolNames?: () => Iterable<string>;
411
423
  #intentTracing: boolean;
412
424
  #pruneToolDescriptions: boolean;
413
425
  #dialect?: Dialect;
@@ -433,6 +445,7 @@ export class Agent {
433
445
 
434
446
  /** Buffered Cursor tool results with text length at time of call (for correct ordering) */
435
447
  #cursorToolResultBuffer: CursorToolResultEntry[] = [];
448
+ #cursorToolResultDrain: CursorToolResultEntry[] | undefined;
436
449
 
437
450
  streamFn: StreamFn;
438
451
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
@@ -496,7 +509,9 @@ export class Agent {
496
509
  this.#kimiApiFormat = opts.kimiApiFormat;
497
510
  this.#preferWebsockets = opts.preferWebsockets;
498
511
  this.#transformToolCallArguments = opts.transformToolCallArguments;
512
+ this.#speculativeToolExecution = opts.speculativeToolExecution;
499
513
  this.#resolveFallbackTool = opts.resolveFallbackTool;
514
+ this.#suggestFallbackToolNames = opts.suggestFallbackToolNames;
500
515
  this.#intentTracing = opts.intentTracing === true;
501
516
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
502
517
  this.#dialect = opts.dialect;
@@ -1048,6 +1063,13 @@ export class Agent {
1048
1063
  return this.#followUpQueue;
1049
1064
  }
1050
1065
 
1066
+ /** Nonblocking snapshot of the latest results, including provisional payloads while transforms are pending. */
1067
+ getPendingToolResults(): readonly ToolResultMessage[] {
1068
+ const results = this.#cursorToolResultDrain?.map(({ toolResult }) => toolResult) ?? [];
1069
+ for (const { toolResult } of this.#cursorToolResultBuffer) results.push(toolResult);
1070
+ return results;
1071
+ }
1072
+
1051
1073
  get isAborting(): boolean {
1052
1074
  return this.#abortController?.signal.aborted === true && this.#state.isStreaming;
1053
1075
  }
@@ -1461,7 +1483,9 @@ export class Agent {
1461
1483
  cwd: this.#cwd,
1462
1484
  getCwd: this.#cwdResolver,
1463
1485
  transformToolCallArguments: this.#transformToolCallArguments,
1486
+ speculativeToolExecution: this.#speculativeToolExecution,
1464
1487
  resolveFallbackTool: this.#resolveFallbackTool,
1488
+ suggestFallbackToolNames: this.#suggestFallbackToolNames,
1465
1489
  intentTracing: this.#intentTracing,
1466
1490
  pruneToolDescriptions: this.#pruneToolDescriptions,
1467
1491
  dialect: this.#dialect,
@@ -1751,29 +1775,25 @@ export class Agent {
1751
1775
  * multi-text turns, producing duplicated text on replay.
1752
1776
  */
1753
1777
  async #emitCursorSplitAssistantMessage(assistantMessage: AssistantMessage): Promise<void> {
1754
- // Snapshot and detach immediately so a still-pending `cursorOnToolResult`
1755
- // cannot push into a drained buffer. Entries already reserved stay paired
1756
- // with their toolCall.
1757
1778
  const buffer = this.#cursorToolResultBuffer;
1758
1779
  this.#cursorToolResultBuffer = [];
1780
+ this.#cursorToolResultDrain = buffer;
1781
+ try {
1782
+ // Keep the detached batch observable while its transforms finish.
1783
+ const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
1784
+ if (pending.length > 0) await Promise.all(pending);
1759
1785
 
1760
- // Await any transformer still running for a reserved entry before reading
1761
- // its payload. The provider dispatches with `void handleServerMessage(…)`,
1762
- // so a `message_end` from the same chunk can reach this point while a
1763
- // transformer is mid-flight; without the await its rewrite would land on
1764
- // the detached entry after the original was already appended and emitted.
1765
- // Each `pending` swallows its own rejection, so this cannot throw.
1766
- const pending = buffer.filter(entry => entry.pending !== undefined).map(entry => entry.pending);
1767
- if (pending.length > 0) await Promise.all(pending);
1768
-
1769
- this.#state.streamMessage = null;
1770
- this.appendMessage(assistantMessage);
1771
- this.#emit({ type: "message_end", message: assistantMessage });
1786
+ this.#state.streamMessage = null;
1787
+ this.appendMessage(assistantMessage);
1788
+ this.#emit({ type: "message_end", message: assistantMessage });
1772
1789
 
1773
- for (const { toolResult } of buffer) {
1774
- this.#emit({ type: "message_start", message: toolResult });
1775
- this.appendMessage(toolResult);
1776
- this.#emit({ type: "message_end", message: toolResult });
1790
+ for (const { toolResult } of buffer) {
1791
+ this.#emit({ type: "message_start", message: toolResult });
1792
+ this.appendMessage(toolResult);
1793
+ this.#emit({ type: "message_end", message: toolResult });
1794
+ }
1795
+ } finally {
1796
+ this.#cursorToolResultDrain = undefined;
1777
1797
  }
1778
1798
  }
1779
1799
  }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Anthropic server-side compaction (`compact-2026-01-12` beta).
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.
13
+ */
14
+
15
+ import type {
16
+ AnthropicCompactionPayload,
17
+ ApiKey,
18
+ AssistantMessage,
19
+ Effort,
20
+ Message,
21
+ Model,
22
+ SimpleStreamOptions,
23
+ Tool,
24
+ Usage,
25
+ } from "@linxiraos/pi-ai";
26
+ import * as AIError from "@linxiraos/pi-ai/error";
27
+ import { supportsAnthropicCompaction } from "@linxiraos/pi-ai/providers/anthropic";
28
+ import { isRecord, prompt } from "@linxiraos/pi-utils";
29
+ import { type InstrumentedChatSpanOptions, instrumentedCompleteSimple } from "../telemetry";
30
+ import anthropicCompactionInstructionsPrompt from "./prompts/anthropic-compaction-instructions.md" with { type: "text" };
31
+
32
+ export const ANTHROPIC_COMPACTION_PRESERVE_KEY = "anthropicCompaction";
33
+
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
+ /** Summary persisted under {@link ANTHROPIC_COMPACTION_PRESERVE_KEY}. */
46
+ export interface AnthropicCompactionPreserveData {
47
+ provider: string;
48
+ content: string;
49
+ /** Opaque provider state the API attached to the block; replayed verbatim. */
50
+ encryptedContent?: string;
51
+ /** Harness file metadata (`<files>` section) replayed after the native block. */
52
+ filesText?: string;
53
+ /** Model that wrote the summary. */
54
+ model?: string;
55
+ /** Prompt tokens the compaction request processed, for display. */
56
+ usedTokens?: number;
57
+ }
58
+
59
+ function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
60
+ return model.api === "anthropic-messages";
61
+ }
62
+
63
+ /**
64
+ * Whether a model compacts through the Anthropic compaction beta. Model
65
+ * eligibility is catalog policy (`compat.supportsServerCompaction`, the
66
+ * lineage the beta documents); endpoint eligibility is resolved the way the
67
+ * provider routes requests, so a Foundry or `ANTHROPIC_BASE_URL` reroute of a
68
+ * first-party model is excluded unless the route opted in with
69
+ * `remoteCompaction.enabled`.
70
+ */
71
+ export function shouldUseAnthropicNativeCompaction(model: Model): model is Model<"anthropic-messages"> {
72
+ return isAnthropicMessagesModel(model) && supportsAnthropicCompaction(model);
73
+ }
74
+
75
+ export function getPreservedAnthropicCompactionData(
76
+ preserveData: Record<string, unknown> | undefined,
77
+ ): AnthropicCompactionPreserveData | undefined {
78
+ const candidate = preserveData?.[ANTHROPIC_COMPACTION_PRESERVE_KEY];
79
+ if (!isRecord(candidate)) return undefined;
80
+ if (typeof candidate.provider !== "string" || candidate.provider.length === 0) return undefined;
81
+ if (typeof candidate.content !== "string" || candidate.content.length === 0) return undefined;
82
+ return {
83
+ provider: candidate.provider,
84
+ content: candidate.content,
85
+ ...(typeof candidate.encryptedContent === "string" && candidate.encryptedContent.length > 0
86
+ ? { encryptedContent: candidate.encryptedContent }
87
+ : {}),
88
+ ...(typeof candidate.filesText === "string" && candidate.filesText.length > 0
89
+ ? { filesText: candidate.filesText }
90
+ : {}),
91
+ ...(typeof candidate.model === "string" ? { model: candidate.model } : {}),
92
+ ...(typeof candidate.usedTokens === "number" ? { usedTokens: candidate.usedTokens } : {}),
93
+ };
94
+ }
95
+
96
+ /** Set or strip the Anthropic compaction slot; a new compaction never inherits a stale summary. */
97
+ export function withAnthropicCompactionPreserveData(
98
+ preserveData: Record<string, unknown> | undefined,
99
+ compaction: AnthropicCompactionPreserveData | undefined,
100
+ ): Record<string, unknown> | undefined {
101
+ if (compaction) {
102
+ return { ...preserveData, [ANTHROPIC_COMPACTION_PRESERVE_KEY]: compaction };
103
+ }
104
+ if (!preserveData || !(ANTHROPIC_COMPACTION_PRESERVE_KEY in preserveData)) {
105
+ return preserveData;
106
+ }
107
+ const { [ANTHROPIC_COMPACTION_PRESERVE_KEY]: _removed, ...rest } = preserveData;
108
+ return Object.keys(rest).length > 0 ? rest : undefined;
109
+ }
110
+
111
+ /** Replay payload for a compaction summary the active model produced natively. */
112
+ export function getAnthropicCompactionPayload(
113
+ preserveData: Record<string, unknown> | undefined,
114
+ ): AnthropicCompactionPayload | undefined {
115
+ const preserved = getPreservedAnthropicCompactionData(preserveData);
116
+ if (!preserved) return undefined;
117
+ return {
118
+ type: "anthropicCompaction",
119
+ provider: preserved.provider,
120
+ content: preserved.content,
121
+ ...(preserved.encryptedContent ? { encryptedContent: preserved.encryptedContent } : {}),
122
+ ...(preserved.filesText ? { filesText: preserved.filesText } : {}),
123
+ };
124
+ }
125
+
126
+ /**
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.
138
+ */
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;
153
+ }
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;
164
+ }
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;
185
+ }
186
+ }
187
+
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
+ */
197
+ export function buildAnthropicCompactionInstructions(
198
+ basePrompt: string,
199
+ customInstructions: string | undefined,
200
+ extraContext: string | undefined,
201
+ retainedTail: RetainedTailScope | undefined,
202
+ ): string {
203
+ return prompt.render(anthropicCompactionInstructionsPrompt, {
204
+ basePrompt,
205
+ customInstructions,
206
+ extraContext,
207
+ retainedTail,
208
+ });
209
+ }
210
+
211
+ export interface AnthropicNativeCompactionRequest {
212
+ systemPrompt: string[];
213
+ messages: Message[];
214
+ tools?: Tool[];
215
+ instructions: string;
216
+ maxTokens: number;
217
+ reasoning?: Effort;
218
+ }
219
+
220
+ export interface AnthropicNativeCompactionResponse {
221
+ content: string;
222
+ encryptedContent?: string;
223
+ usage: Usage;
224
+ model: string;
225
+ }
226
+
227
+ export interface AnthropicNativeCompactionOptions
228
+ extends
229
+ Pick<
230
+ SimpleStreamOptions,
231
+ | "initiatorOverride"
232
+ | "metadata"
233
+ | "fetch"
234
+ | "sessionId"
235
+ | "promptCacheKey"
236
+ | "providerSessionState"
237
+ | "maxInFlightRequests"
238
+ >,
239
+ Pick<InstrumentedChatSpanOptions, "completeImpl" | "telemetry" | "retry"> {}
240
+
241
+ /**
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`
244
+ * resolves terminal failures as messages, so their classification is restored
245
+ * here: an aborted response is an `AbortError` (a cancellation, never a native
246
+ * failure) and an error response keeps its HTTP status, so auth and timeout
247
+ * 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.
251
+ */
252
+ export async function requestAnthropicNativeCompaction(
253
+ model: Model<"anthropic-messages">,
254
+ apiKey: ApiKey,
255
+ request: AnthropicNativeCompactionRequest,
256
+ signal: AbortSignal | undefined,
257
+ options: AnthropicNativeCompactionOptions,
258
+ ): Promise<AnthropicNativeCompactionResponse> {
259
+ const response = await instrumentedCompleteSimple(
260
+ model,
261
+ { systemPrompt: request.systemPrompt, messages: request.messages, tools: request.tools },
262
+ {
263
+ apiKey,
264
+ signal,
265
+ maxTokens: request.maxTokens,
266
+ reasoning: request.reasoning,
267
+ initiatorOverride: options.initiatorOverride,
268
+ metadata: options.metadata,
269
+ fetch: options.fetch,
270
+ sessionId: options.sessionId,
271
+ promptCacheKey: options.promptCacheKey,
272
+ providerSessionState: options.providerSessionState,
273
+ maxInFlightRequests: options.maxInFlightRequests,
274
+ anthropicCompaction: {
275
+ triggerInputTokens: ANTHROPIC_COMPACTION_MIN_TRIGGER_TOKENS,
276
+ pauseAfterCompaction: true,
277
+ instructions: request.instructions,
278
+ },
279
+ },
280
+ {
281
+ telemetry: options.telemetry,
282
+ oneshotKind: "compaction_native",
283
+ completeImpl: options.completeImpl,
284
+ retry: options.retry,
285
+ },
286
+ );
287
+ if (response.stopReason === "aborted") {
288
+ throw new AIError.AbortError("Anthropic compaction aborted", { cause: signal?.reason });
289
+ }
290
+ if (response.stopReason === "error") {
291
+ const message = `Anthropic compaction failed: ${response.errorMessage ?? "unknown error"}`;
292
+ throw response.errorStatus === undefined
293
+ ? new Error(message)
294
+ : new AIError.ProviderHttpError(message, response.errorStatus);
295
+ }
296
+ const payload = response.providerPayload;
297
+ if (payload?.type !== "anthropicCompaction" || payload.content.length === 0) {
298
+ throw new Error(
299
+ response.stopDetails?.type === "compaction"
300
+ ? "Anthropic compaction returned no summary"
301
+ : "Anthropic compaction response carried no compaction block",
302
+ );
303
+ }
304
+ return {
305
+ content: payload.content,
306
+ encryptedContent: payload.encryptedContent,
307
+ usage: response.usage,
308
+ model: response.model,
309
+ };
310
+ }
@@ -44,8 +44,8 @@ export const V2_RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
44
44
  /** Max retries for V2 streaming compaction on transient stream errors. */
45
45
  export const V2_COMPACTION_MAX_RETRIES = 2;
46
46
 
47
- /** Timeout for V2 streaming compaction (3 minutes, same as V1). */
48
- export const V2_COMPACTION_TIMEOUT_MS = 180_000;
47
+ /** Timeout for V2 streaming compaction (5 minutes, same as V1). */
48
+ export const V2_COMPACTION_TIMEOUT_MS = 300_000;
49
49
 
50
50
  const DEFAULT_AZURE_API_VERSION = "v1";
51
51
  const OPENAI_REMOTE_COMPACTION_PRESERVE_KEY = "openaiRemoteCompaction";