@linxiraos/pi-agent-core 1.0.9 → 1.1.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +2 -1283
  2. package/LICENSE +23 -0
  3. package/package.json +77 -78
  4. package/src/agent-loop.ts +17 -2
  5. package/src/agent.ts +25 -4
  6. package/src/append-only-context.ts +28 -2
  7. package/src/compaction/branch-summarization.ts +15 -8
  8. package/src/compaction/compaction-v2-streaming.ts +2 -0
  9. package/src/compaction/compaction.ts +234 -162
  10. package/src/compaction/entries.ts +11 -0
  11. package/src/compaction/errors.ts +2 -2
  12. package/src/compaction/index.ts +1 -0
  13. package/src/compaction/message-cache.ts +33 -34
  14. package/src/compaction/messages.ts +37 -6
  15. package/src/compaction/openai.ts +54 -20
  16. package/src/compaction/prompts/handoff-document.md +2 -0
  17. package/src/compaction/prompts/handoff-summary-context.md +9 -0
  18. package/src/compaction/prompts/summarization-system.md +2 -0
  19. package/src/compaction/pruning.ts +25 -13
  20. package/src/compaction/shake.ts +18 -16
  21. package/src/compaction/transcript-tokens.ts +111 -0
  22. package/src/compaction/utils.ts +9 -2
  23. package/src/tokenizer.ts +273 -18
  24. package/src/types.ts +1 -1
  25. package/dist/types/agent-loop.d.ts +0 -137
  26. package/dist/types/agent.d.ts +0 -465
  27. package/dist/types/append-only-context.d.ts +0 -131
  28. package/dist/types/compaction/branch-summarization.d.ts +0 -101
  29. package/dist/types/compaction/compaction-v2-streaming.d.ts +0 -85
  30. package/dist/types/compaction/compaction.d.ts +0 -321
  31. package/dist/types/compaction/entries.d.ts +0 -111
  32. package/dist/types/compaction/errors.d.ts +0 -38
  33. package/dist/types/compaction/index.d.ts +0 -13
  34. package/dist/types/compaction/message-cache.d.ts +0 -23
  35. package/dist/types/compaction/messages.d.ts +0 -79
  36. package/dist/types/compaction/openai.d.ts +0 -117
  37. package/dist/types/compaction/pruning.d.ts +0 -105
  38. package/dist/types/compaction/shake.d.ts +0 -99
  39. package/dist/types/compaction/tool-protection.d.ts +0 -19
  40. package/dist/types/compaction/utils.d.ts +0 -61
  41. package/dist/types/compaction.d.ts +0 -1
  42. package/dist/types/index.d.ts +0 -13
  43. package/dist/types/pause.d.ts +0 -46
  44. package/dist/types/proxy.d.ts +0 -91
  45. package/dist/types/replay-policy.d.ts +0 -5
  46. package/dist/types/run-collector.d.ts +0 -196
  47. package/dist/types/telemetry.d.ts +0 -625
  48. package/dist/types/thinking.d.ts +0 -18
  49. package/dist/types/tokenizer.d.ts +0 -2
  50. package/dist/types/types.d.ts +0 -786
  51. package/dist/types/utils/yield.d.ts +0 -71
@@ -208,13 +208,20 @@ export function truncateToolResultForSummary(text: string): string {
208
208
  return `${text.slice(0, TOOL_RESULT_MAX_CHARS)}\n\n[... ${truncatedChars} more characters truncated]`;
209
209
  }
210
210
 
211
+ const SUMMARY_BOUNDARY_TAG_RE = /<\s*\/?\s*(?:conversation|previous-summary)\s*>/gi;
212
+
213
+ /** Keep untrusted summary input from closing or impersonating harness-owned boundaries. */
214
+ export function escapeSummaryBoundaryTags(text: string): string {
215
+ return text.replace(SUMMARY_BOUNDARY_TAG_RE, tag => `&lt;${tag.slice(1)}`);
216
+ }
217
+
211
218
  /**
212
219
  * Serialize LLM messages as plain summary input without provider control tokens.
213
220
  */
214
221
  export function serializeConversationForSummary(messages: Message[], dialect?: Dialect): string {
215
222
  const conversation = serializeConversation(messages, dialect);
216
- if (dialect !== "harmony") return conversation;
217
- return escapeHarmonyControlTokens(conversation);
223
+ const escaped = dialect === "harmony" ? escapeHarmonyControlTokens(conversation) : conversation;
224
+ return escapeSummaryBoundaryTags(escaped);
218
225
  }
219
226
 
220
227
  /**
package/src/tokenizer.ts CHANGED
@@ -1,27 +1,282 @@
1
- import { countTokens as countTokensNat } from "@linxiraos/pi-natives";
1
+ import type { Model } from "@linxiraos/pi-ai";
2
+ import type { ModelTokenizer } from "@linxiraos/pi-catalog/types";
3
+ import { countTokens as countTokensNat, Encoding } from "@linxiraos/pi-natives";
4
+ import * as snapcompact from "@linxiraos/pi-snapcompact";
5
+ import { stringifyJson } from "@linxiraos/pi-utils";
6
+ import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
7
+ import type { AgentMessage } from "./types";
2
8
 
3
- const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && Bun.env.NODE_ENV !== "test";
9
+ const testEnv = Bun.env.NODE_ENV === "test";
10
+ const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && !testEnv;
4
11
 
5
- function estimateTokens(text: string) {
12
+ const NATIVE_ENCODING: Record<ModelTokenizer, Encoding> = {
13
+ "claude-v3": Encoding.ClaudeV3,
14
+ "claude-v47": Encoding.ClaudeV47,
15
+ "claude-v5": Encoding.ClaudeV5,
16
+ "claude-v5-sonnet": Encoding.ClaudeV5Sonnet,
17
+ qwen3: Encoding.Qwen3,
18
+ "deepseek-v3": Encoding.DeepSeekV3,
19
+ "kimi-k2": Encoding.KimiK2,
20
+ glm5: Encoding.Glm5,
21
+ };
22
+
23
+ /** Maps the catalog-resolved tokenizer family to its native implementation. */
24
+ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null | undefined): Encoding | null {
25
+ return model?.tokenizer ? NATIVE_ENCODING[model.tokenizer] : null;
26
+ }
27
+
28
+ /**
29
+ * `strict` always pays for an exact native count (the catalog-resolved
30
+ * tokenizer when known, o200k_base otherwise). `approximate` and
31
+ * `upperbound` prefer the same exact count for known tokenizer families or
32
+ * when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
33
+ * `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
34
+ * undercounts).
35
+ */
36
+ export type TokenCountMode = "strict" | "approximate" | "upperbound";
37
+
38
+ /** Options for {@link Tokenizer.countMessage} / {@link Tokenizer.countMessages}. */
39
+ export interface MessageCountOptions {
40
+ /**
41
+ * Drop opaque provider reasoning payloads (`thinkingSignature`,
42
+ * `redactedThinking`, native server-tool blocks) from the estimate. Those
43
+ * are billed by the provider on replay, so the default counts them — but
44
+ * their *local* byte size can diverge wildly from what the provider
45
+ * charges, so the compaction floor (which only needs the reliably-countable,
46
+ * on-wire-compressible content) excludes them to avoid false triggers on
47
+ * thinking-heavy turns.
48
+ */
49
+ excludeEncryptedReasoning?: boolean;
50
+ }
51
+
52
+ function byteEstimate(text: string): number {
6
53
  return (Buffer.byteLength(text, "utf-8") + 3) >> 2;
7
54
  }
8
55
 
9
- export function countTokens(text: string | string[]): number {
10
- if (accurate) {
11
- return countTokensNat(text);
12
- } else if (Array.isArray(text)) {
13
- return text.reduce((sum, t) => sum + estimateTokens(t), 0);
14
- } else {
15
- return estimateTokens(text);
16
- }
56
+ function byteLength(text: string): number {
57
+ return Buffer.byteLength(text, "utf-8");
17
58
  }
18
59
 
19
- export function countTokensConservatively(text: string | string[]): number {
20
- if (accurate) {
21
- return countTokensNat(text);
22
- } else if (Array.isArray(text)) {
23
- return text.reduce((sum, value) => sum + Buffer.byteLength(value, "utf-8"), 0);
24
- } else {
25
- return Buffer.byteLength(text, "utf-8");
60
+ function sumFragments(text: string | string[], perFragment: (t: string) => number): number {
61
+ return Array.isArray(text) ? text.reduce((sum, t) => sum + perFragment(t), 0) : perFragment(text);
62
+ }
63
+
64
+ /** Verdict from {@link Tokenizer.checkTokenBudget}. */
65
+ export interface TokenBudgetCheck {
66
+ /** Whether the text fits the budget. */
67
+ fits: boolean;
68
+ /**
69
+ * Token count behind the verdict: the exact native count when `exact` is
70
+ * set, otherwise the cheap byte upper bound (which already fit, so it is
71
+ * only an over-estimate of a count known to be under budget).
72
+ */
73
+ tokens: number;
74
+ /** Whether the exact tokenizer had to run because the cheap bound busted. */
75
+ exact: boolean;
76
+ }
77
+
78
+ /**
79
+ * Image content has no tokenizer representation; charge a fixed estimate
80
+ * matching what providers typically bill for inline images.
81
+ */
82
+ const IMAGE_TOKEN_ESTIMATE = 1200;
83
+
84
+ /**
85
+ * Memoized estimates for one message under this tokenizer's encoding, split by
86
+ * the {@link MessageCountOptions.excludeEncryptedReasoning} option so the two
87
+ * variants never collide. `version` snapshots {@link messageEstimateVersion} at
88
+ * write time; an owner mutation (prune/shake/strip-images) bumps the version,
89
+ * which invalidates the entry in every live Tokenizer at once.
90
+ */
91
+ interface MessageEstimate {
92
+ version: number;
93
+ default?: number;
94
+ floored?: number;
95
+ }
96
+
97
+ /**
98
+ * Model-aware local token counter. Immutable: the catalog-resolved encoding
99
+ * is fixed at construction, so a cached count can never straddle two
100
+ * encodings. An `Agent` owns one for its active model (swapping the instance
101
+ * when the model's encoding changes); one-shot flows construct their own for
102
+ * the model that will be billed. Known tokenizer families use exact native
103
+ * counts; unknown models keep the fast byte estimate (or o200k when
104
+ * `PI_TOKENIZER_ACCURATE=1`).
105
+ */
106
+ export class Tokenizer {
107
+ readonly #encoding: Encoding | null;
108
+
109
+ /**
110
+ * Per-message estimate memo. Keyed by message identity, deliberately not a
111
+ * symbol-tagged property: callers spread messages to derive throwaway
112
+ * variants for counting (`estimateBranchSummaryTokens` does
113
+ * `countMessage({ ...message, content: truncated })`), and a property-borne
114
+ * cache would ride along the spread. Identity keying gives clones a fresh
115
+ * count.
116
+ */
117
+ #estimates = new WeakMap<AgentMessage, MessageEstimate>();
118
+
119
+ constructor(model?: Pick<Model, "tokenizer"> | null) {
120
+ this.#encoding = tokenizerEncodingForModel(model);
121
+ }
122
+
123
+ get encoding(): Encoding | null {
124
+ return this.#encoding;
125
+ }
126
+
127
+ countTokens(text: string | string[], mode: TokenCountMode = "approximate"): number {
128
+ if (mode === "strict") return countTokensNat(text, this.#encoding);
129
+ if (!testEnv && this.#encoding !== null) return countTokensNat(text, this.#encoding);
130
+ if (accurate) return countTokensNat(text);
131
+ return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
132
+ }
133
+
134
+ /**
135
+ * Cheap-first budget probe — the way to ask "does this fit in `budget`
136
+ * tokens?" without tokenizing the world.
137
+ *
138
+ * Byte length is a hard upper bound on token count (every token consumes at
139
+ * least one input byte), so text whose raw bytes already fit the budget
140
+ * cannot possibly exceed it — that verdict is returned without tokenizing at
141
+ * all. Only text that busts the bound is ambiguous, and only that case pays
142
+ * for the exact count. Since the bound overshoots ~4x on ordinary prose, the
143
+ * common "comfortably under budget" answer is free.
144
+ */
145
+ checkTokenBudget(text: string | string[], budget: number): TokenBudgetCheck {
146
+ const bound = sumFragments(text, byteLength);
147
+ if (bound <= budget) return { fits: true, tokens: bound, exact: false };
148
+ const tokens = this.countTokens(text, "strict");
149
+ return { fits: tokens <= budget, tokens, exact: true };
150
+ }
151
+
152
+ /**
153
+ * Token estimate for one message under this tokenizer's encoding.
154
+ *
155
+ * Settled historical messages are counted once and reused until an owner
156
+ * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
157
+ * assistants bypass the memo entirely (see the message-cache settle-gate
158
+ * invariant). Image blocks charge a fixed per-image estimate.
159
+ */
160
+ countMessage(message: AgentMessage, options?: MessageCountOptions): number {
161
+ const floored = options?.excludeEncryptedReasoning === true;
162
+ if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored);
163
+ const version = messageEstimateVersion(message);
164
+ let entry = this.#estimates.get(message);
165
+ if (entry === undefined || entry.version !== version) {
166
+ entry = { version };
167
+ this.#estimates.set(message, entry);
168
+ }
169
+ const cached = floored ? entry.floored : entry.default;
170
+ if (cached !== undefined) return cached;
171
+ const result = this.#measureMessage(message, floored);
172
+ if (floored) entry.floored = result;
173
+ else entry.default = result;
174
+ return result;
175
+ }
176
+
177
+ /** Sum of {@link countMessage} over `messages`. */
178
+ countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number {
179
+ let total = 0;
180
+ for (const message of messages) total += this.countMessage(message, options);
181
+ return total;
182
+ }
183
+
184
+ #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
185
+ const fragments: string[] = [];
186
+ let extra = 0;
187
+ // Declaration-merged app roles (the coding-agent's bashExecution) are
188
+ // invisible to this package's union, so the discriminant is read as data.
189
+ const role: string = message.role;
190
+ if (role === "bashExecution") {
191
+ if ("command" in message && typeof message.command === "string") fragments.push(message.command);
192
+ if ("output" in message && typeof message.output === "string") fragments.push(message.output);
193
+ return fragments.length === 0 ? 0 : this.countTokens(fragments);
194
+ }
195
+
196
+ switch (message.role) {
197
+ case "user": {
198
+ const content: string | Array<{ type: string; text?: string }> = message.content;
199
+ if (typeof content === "string") {
200
+ fragments.push(content);
201
+ } else if (Array.isArray(content)) {
202
+ for (const block of content) {
203
+ if (block.type === "text" && block.text) {
204
+ fragments.push(block.text);
205
+ }
206
+ }
207
+ }
208
+ break;
209
+ }
210
+ case "assistant": {
211
+ for (const block of message.content) {
212
+ if (block.type === "text") {
213
+ fragments.push(block.text);
214
+ } else if (block.type === "thinking") {
215
+ fragments.push(block.thinking);
216
+ // Providers charge for the opaque signature/reasoning payload that
217
+ // rides alongside the thinking text (OpenAI Responses encrypted
218
+ // reasoning items, Anthropic signed thinking blocks, etc.). Without
219
+ // counting it, this estimator can read ~half of the provider-reported
220
+ // usage on thinking-heavy turns — see #2275 for the resulting
221
+ // compaction-trigger / post-check metric divergence. The compaction
222
+ // floor excludes it (its local byte size diverges from provider billing).
223
+ if (block.thinkingSignature && !excludeEncryptedReasoning) {
224
+ fragments.push(block.thinkingSignature);
225
+ }
226
+ } else if (block.type === "toolCall") {
227
+ fragments.push(block.name);
228
+ fragments.push(stringifyJson(block.arguments) ?? "null");
229
+ } else if (block.type === "redactedThinking") {
230
+ // Encrypted reasoning blob the provider still bills for on replay;
231
+ // excluded from the compaction floor for the same reason as above.
232
+ if (!excludeEncryptedReasoning) fragments.push(block.data);
233
+ } else if (block.type === "anthropicServerTool") {
234
+ // Native Anthropic server-tool call/result replayed verbatim on the
235
+ // wire (server_tool_use input and opaque result content). The provider
236
+ // still bills for it on same-provider replay; excluded from the
237
+ // compaction floor like other encrypted reasoning because its local
238
+ // byte size diverges from provider billing.
239
+ if (!excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
240
+ }
241
+ }
242
+ break;
243
+ }
244
+ case "hookMessage":
245
+ case "toolResult": {
246
+ if (typeof message.content === "string") {
247
+ fragments.push(message.content);
248
+ } else {
249
+ for (const block of message.content) {
250
+ if (block.type === "text" && block.text) {
251
+ fragments.push(block.text);
252
+ } else if (block.type === "image") {
253
+ extra += IMAGE_TOKEN_ESTIMATE;
254
+ }
255
+ }
256
+ }
257
+ break;
258
+ }
259
+ case "branchSummary":
260
+ case "compactionSummary": {
261
+ fragments.push(message.summary);
262
+ if (message.role === "compactionSummary") {
263
+ if (message.blocks) {
264
+ for (const block of message.blocks) {
265
+ if (block.type === "text") fragments.push(block.text);
266
+ else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
267
+ }
268
+ } else if (message.images) {
269
+ // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
270
+ extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
271
+ }
272
+ }
273
+ break;
274
+ }
275
+ default:
276
+ return 0;
277
+ }
278
+
279
+ if (fragments.length === 0) return extra;
280
+ return extra + this.countTokens(fragments);
26
281
  }
27
282
  }
package/src/types.ts CHANGED
@@ -208,7 +208,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
208
208
  * @example
209
209
  * ```typescript
210
210
  * transformContext: async (messages) => {
211
- * if (estimateTokens(messages) > MAX_TOKENS) {
211
+ * if (agent.tokenizer.countMessages(messages) > MAX_TOKENS) {
212
212
  * return pruneOldMessages(messages);
213
213
  * }
214
214
  * return messages;
@@ -1,137 +0,0 @@
1
- /**
2
- * Agent loop that works with AgentMessage throughout.
3
- * Transforms to Message[] only at the LLM call boundary.
4
- */
5
- import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "@linxiraos/pi-ai";
6
- import { type Dialect } from "@linxiraos/pi-ai/dialect";
7
- import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js";
8
- import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
9
- /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
10
- export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
11
- /**
12
- * Cadence (ms) for polling queued steering while an `interruptible` tool is in
13
- * flight, so a steer cuts the wait short instead of sitting idle until the
14
- * tool's own window elapses. A cheap synchronous queue check; latency-bounded
15
- * at one tick.
16
- */
17
- /**
18
- * Abort reason for a turn-wide interruption where only some tool calls caused
19
- * the abort and sibling placeholders need neutral messages.
20
- */
21
- export interface ToolScopedAbortReason {
22
- readonly kind: "tool-scoped-abort";
23
- readonly message: string;
24
- readonly toolCallMessages: Record<string, string>;
25
- readonly defaultToolCallMessage: string;
26
- }
27
- /** Creates an abort reason that labels matching tool calls separately from siblings. */
28
- export declare function createToolScopedAbortReason(message: string, toolCallMessages: Record<string, string>, defaultToolCallMessage: string): ToolScopedAbortReason;
29
- /**
30
- * Marks an abort raised by a completed post-tool hook as terminal for the
31
- * current run. External/user aborts still synthesize an aborted assistant
32
- * boundary; this reason stops after persisting the completed tool batch.
33
- */
34
- export declare const TERMINAL_TOOL_RESULT_ABORT_REASON: unique symbol;
35
- export declare function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined;
36
- /**
37
- * Start an agent loop with a new prompt message.
38
- * The prompt is added to the context and events are emitted for it.
39
- */
40
- export declare function agentLoop(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
41
- /**
42
- * Continue an agent loop from the current context without adding a new message.
43
- * Used for retries - context already has user message or tool results.
44
- *
45
- * **Important:** The last message in context must convert to a `user` or `toolResult` message
46
- * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
47
- * This cannot be validated here since `convertToLlm` is only called once per turn.
48
- */
49
- export declare function agentLoopContinue(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): EventStream<AgentEvent, AgentMessage[]>;
50
- /**
51
- * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
52
- * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
53
- * payload without changing the resolved type of `stream.result()`.
54
- */
55
- export interface AgentLoopDetailedResult {
56
- readonly messages: AgentMessage[];
57
- readonly telemetry: AgentRunSummary | undefined;
58
- readonly coverage: AgentRunCoverage | undefined;
59
- }
60
- /**
61
- * Convenience wrapper over {@link agentLoop} that exposes the run-level
62
- * summary + coverage alongside the messages. The returned `stream` is the
63
- * same `EventStream` callers already consume; `detailed()` awaits the
64
- * stream's `agent_end` event and returns the additive fields.
65
- *
66
- * Existing `stream.result()` semantics are preserved — it still resolves to
67
- * `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
68
- * use {@link agentLoop} when you do not.
69
- */
70
- export declare function agentLoopDetailed(prompts: AgentMessage[], context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
71
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
72
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
73
- };
74
- /**
75
- * Like {@link agentLoopDetailed} but built on top of
76
- * {@link agentLoopContinue}.
77
- */
78
- export declare function agentLoopContinueDetailed(context: AgentContext, config: AgentLoopConfig, signal?: AbortSignal, streamFn?: StreamFn): {
79
- readonly stream: EventStream<AgentEvent, AgentMessage[]>;
80
- readonly detailed: () => Promise<AgentLoopDetailedResult>;
81
- };
82
- export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
83
- export interface NormalizeToolsOptions {
84
- /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
85
- injectIntent: boolean;
86
- /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
87
- pruneDescriptions?: boolean;
88
- }
89
- export declare function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"];
90
- /** Resolve the human-readable reason an abort carried. A caller that aborts via
91
- * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
92
- * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
93
- * synthesized assistant message's `errorMessage`; a bare `abort()` (whose
94
- * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
95
- * generic sentinel that downstream renderers treat as "no specific reason". */
96
- export declare function abortReasonText(signal: AbortSignal | undefined): string;
97
- /**
98
- * Discriminator embedded in {@link AgentToolResult.details} and
99
- * {@link ToolResultMessage.details} for tool calls that were emitted by the
100
- * assistant but never actually invoked locally.
101
- *
102
- * The synthetic result exists only to preserve the tool_use / tool_result
103
- * pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
104
- * and history consumers can key on `__synthetic === true` to render or
105
- * classify these as "call emitted, not executed" instead of a real local
106
- * tool failure — the mislabeling this discriminator was introduced to fix
107
- * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
108
- * websocket close) was surfaced by the CLI as if the local tool had failed.
109
- *
110
- * `source` names the state that prevented execution — either an assistant-side
111
- * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
112
- * still-pending call to service queued steering/peer input (`interrupt_skipped`).
113
- * `upstreamError` is the provider-reported message when the turn ended with
114
- * `stopReason === "error"`.
115
- */
116
- export interface SyntheticToolResultDetails {
117
- __synthetic: true;
118
- source: "assistant_stop_aborted" | "assistant_stop_error" | "assistant_stop_skipped" | "assistant_stop_length" | "interrupt_skipped";
119
- executed: false;
120
- upstreamError?: string;
121
- }
122
- /**
123
- * Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
124
- * a tool_result emitted for a tool call the assistant never invoked (see
125
- * {@link SyntheticToolResultDetails}). Consumers use this to look past the
126
- * placeholder pairing back to the assistant turn that produced it, e.g.
127
- * `AgentSession.retry()` walking back over the synthetic results a
128
- * stalled/aborted mid-tool-call turn leaves behind.
129
- */
130
- export declare function isSyntheticToolResultMessage(message: AgentMessage | undefined): message is ToolResultMessage<SyntheticToolResultDetails>;
131
- /**
132
- * Create the persisted synthetic result for a tool call that was emitted by
133
- * the assistant but never invoked locally.
134
- */
135
- export declare function createSyntheticToolResultMessage(toolCall: Extract<AssistantMessage["content"][number], {
136
- type: "toolCall";
137
- }>, reason: "aborted" | "error" | "skipped" | "length", errorMessage?: string): ToolResultMessage<SyntheticToolResultDetails>;