@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.
- package/CHANGELOG.md +2 -1283
- package/LICENSE +23 -0
- package/package.json +77 -78
- package/src/agent-loop.ts +17 -2
- package/src/agent.ts +25 -4
- package/src/append-only-context.ts +28 -2
- package/src/compaction/branch-summarization.ts +15 -8
- package/src/compaction/compaction-v2-streaming.ts +2 -0
- package/src/compaction/compaction.ts +234 -162
- package/src/compaction/entries.ts +11 -0
- package/src/compaction/errors.ts +2 -2
- package/src/compaction/index.ts +1 -0
- package/src/compaction/message-cache.ts +33 -34
- package/src/compaction/messages.ts +37 -6
- package/src/compaction/openai.ts +54 -20
- package/src/compaction/prompts/handoff-document.md +2 -0
- package/src/compaction/prompts/handoff-summary-context.md +9 -0
- package/src/compaction/prompts/summarization-system.md +2 -0
- package/src/compaction/pruning.ts +25 -13
- package/src/compaction/shake.ts +18 -16
- package/src/compaction/transcript-tokens.ts +111 -0
- package/src/compaction/utils.ts +9 -2
- package/src/tokenizer.ts +273 -18
- package/src/types.ts +1 -1
- package/dist/types/agent-loop.d.ts +0 -137
- package/dist/types/agent.d.ts +0 -465
- package/dist/types/append-only-context.d.ts +0 -131
- package/dist/types/compaction/branch-summarization.d.ts +0 -101
- package/dist/types/compaction/compaction-v2-streaming.d.ts +0 -85
- package/dist/types/compaction/compaction.d.ts +0 -321
- package/dist/types/compaction/entries.d.ts +0 -111
- package/dist/types/compaction/errors.d.ts +0 -38
- package/dist/types/compaction/index.d.ts +0 -13
- package/dist/types/compaction/message-cache.d.ts +0 -23
- package/dist/types/compaction/messages.d.ts +0 -79
- package/dist/types/compaction/openai.d.ts +0 -117
- package/dist/types/compaction/pruning.d.ts +0 -105
- package/dist/types/compaction/shake.d.ts +0 -99
- package/dist/types/compaction/tool-protection.d.ts +0 -19
- package/dist/types/compaction/utils.d.ts +0 -61
- package/dist/types/compaction.d.ts +0 -1
- package/dist/types/index.d.ts +0 -13
- package/dist/types/pause.d.ts +0 -46
- package/dist/types/proxy.d.ts +0 -91
- package/dist/types/replay-policy.d.ts +0 -5
- package/dist/types/run-collector.d.ts +0 -196
- package/dist/types/telemetry.d.ts +0 -625
- package/dist/types/thinking.d.ts +0 -18
- package/dist/types/tokenizer.d.ts +0 -2
- package/dist/types/types.d.ts +0 -786
- package/dist/types/utils/yield.d.ts +0 -71
package/src/compaction/utils.ts
CHANGED
|
@@ -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 => `<${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
|
-
|
|
217
|
-
return
|
|
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 {
|
|
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
|
|
9
|
+
const testEnv = Bun.env.NODE_ENV === "test";
|
|
10
|
+
const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && !testEnv;
|
|
4
11
|
|
|
5
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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 (
|
|
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>;
|