@linxiraos/pi-agent-core 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/agent.d.ts +15 -5
- package/dist/types/compaction/compaction.d.ts +20 -4
- package/dist/types/compaction/openai.d.ts +13 -2
- package/dist/types/compaction/shake.d.ts +7 -5
- package/dist/types/compaction/utils.d.ts +7 -2
- package/dist/types/image-tokens.d.ts +9 -12
- package/dist/types/output-budget.d.ts +17 -7
- package/dist/types/tokenizer.d.ts +72 -12
- package/dist/types/types.d.ts +22 -1
- package/package.json +9 -9
- package/src/agent-loop.ts +91 -27
- package/src/agent.ts +56 -22
- package/src/compaction/branch-summarization.ts +28 -3
- package/src/compaction/compaction.ts +286 -149
- package/src/compaction/openai.ts +97 -49
- package/src/compaction/shake.ts +19 -8
- package/src/compaction/utils.ts +8 -10
- package/src/image-tokens.ts +13 -15
- package/src/output-budget.ts +22 -9
- package/src/prompts/dsml-tool-call-leak.md +1 -1
- package/src/tokenizer.ts +255 -27
- package/src/types.ts +24 -1
package/src/tokenizer.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
|
-
import type { Model } from "@linxiraos/pi-ai";
|
|
1
|
+
import type { AssistantMessage, Model } from "@linxiraos/pi-ai";
|
|
2
|
+
import { renderDemotedThinking } from "@linxiraos/pi-ai/dialect/demotion";
|
|
3
|
+
import { ollamaChatDropsOwnThinking } from "@linxiraos/pi-ai/providers/ollama";
|
|
4
|
+
import { isColdReplayableResponsesTurn } from "@linxiraos/pi-ai/providers/openai-shared";
|
|
5
|
+
import {
|
|
6
|
+
isSameModelTurn,
|
|
7
|
+
shouldDropTruncatedThinkingOnlyAssistant,
|
|
8
|
+
} from "@linxiraos/pi-ai/providers/transform-messages";
|
|
9
|
+
import {
|
|
10
|
+
getOpenAIResponsesHistoryItems,
|
|
11
|
+
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
|
|
12
|
+
} from "@linxiraos/pi-ai/utils";
|
|
13
|
+
import { resolveThinkingReplayTokens, type ThinkingReplayTokens } from "@linxiraos/pi-catalog/compat/thinking-replay";
|
|
2
14
|
import type { ModelTokenizer } from "@linxiraos/pi-catalog/types";
|
|
3
15
|
import * as natives from "@linxiraos/pi-natives";
|
|
4
16
|
import { materializeString, stringifyJson } from "@linxiraos/pi-utils";
|
|
5
17
|
import { LRUCache } from "@linxiraos/pi-utils/lru";
|
|
6
18
|
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
7
19
|
import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
|
|
8
|
-
import { base64ImageSize,
|
|
20
|
+
import { base64ImageSize, estimateImageTokens, type ImageDetail, type ImageSize } from "./image-tokens";
|
|
9
21
|
import type { AgentMessage } from "./types";
|
|
10
22
|
|
|
11
23
|
const testEnv = Bun.env.NODE_ENV === "test";
|
|
@@ -20,6 +32,7 @@ const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
|
|
|
20
32
|
"deepseek-v3": natives.Encoding.DeepSeekV3,
|
|
21
33
|
"kimi-k2": natives.Encoding.KimiK2,
|
|
22
34
|
glm5: natives.Encoding.Glm5,
|
|
35
|
+
o200k: natives.Encoding.O200kBase,
|
|
23
36
|
};
|
|
24
37
|
|
|
25
38
|
/** Maps the catalog-resolved tokenizer family to its native implementation. */
|
|
@@ -27,6 +40,41 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
|
|
|
27
40
|
return model?.tokenizer ? NATIVE_ENCODING[model.tokenizer] : null;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
type TokenizerModel = Pick<Model, "tokenizer"> &
|
|
44
|
+
snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"] };
|
|
45
|
+
|
|
46
|
+
/** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
|
|
47
|
+
interface ReasoningReplayReader {
|
|
48
|
+
api: string;
|
|
49
|
+
provider: string;
|
|
50
|
+
id: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function reasoningReplayReader(model: TokenizerModel | null | undefined): ReasoningReplayReader | undefined {
|
|
54
|
+
if (!model?.api || !model.provider || !model.id) return undefined;
|
|
55
|
+
// Filtering targets strip native reasoning items before the request is encoded.
|
|
56
|
+
const compat = model.compat;
|
|
57
|
+
if (compat && "filterReasoningHistory" in compat && compat.filterReasoningHistory) return undefined;
|
|
58
|
+
return { api: model.api, provider: model.provider, id: model.id };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
|
|
62
|
+
export function reasoningReplayKey(model: TokenizerModel | null | undefined): string {
|
|
63
|
+
const reader = reasoningReplayReader(model);
|
|
64
|
+
return reader ? `${reader.api}\0${reader.provider}\0${reader.id}` : "";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Request state a {@link Tokenizer} counts for, beyond its model. */
|
|
68
|
+
export interface TokenizerOptions {
|
|
69
|
+
/**
|
|
70
|
+
* Whether the request replays native Responses history (pi-ai's
|
|
71
|
+
* `openAIResponsesReplaysNativeHistory`). A cold session rebuilds a turn
|
|
72
|
+
* holding encrypted reasoning from its text and tool calls, without the
|
|
73
|
+
* reasoning or its signature. Default: true.
|
|
74
|
+
*/
|
|
75
|
+
nativeHistoryReplay?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
30
78
|
/**
|
|
31
79
|
* `strict` always tries an exact native count (the catalog-resolved tokenizer
|
|
32
80
|
* when known, o200k_base otherwise), falling back to the byte upper bound when
|
|
@@ -43,11 +91,15 @@ export interface MessageCountOptions {
|
|
|
43
91
|
/**
|
|
44
92
|
* Drop opaque provider reasoning payloads (`thinkingSignature`,
|
|
45
93
|
* `redactedThinking`, native server-tool blocks) from the estimate. Those
|
|
46
|
-
* are billed by the provider on replay, so the default counts them
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
94
|
+
* are billed by the provider on replay, so the default counts them (a reader
|
|
95
|
+
* billing replayed thinking at `output` size: the reasoning, at its turn's
|
|
96
|
+
* output size) — but their *local* byte size can diverge wildly from what
|
|
97
|
+
* the provider charges, so the compaction floor (which only needs the
|
|
98
|
+
* reliably-countable, on-wire-compressible content) excludes them to avoid
|
|
99
|
+
* false triggers on thinking-heavy turns.
|
|
100
|
+
* Native Responses reasoning that actually replays is not an opaque byte
|
|
101
|
+
* guess: it is charged at the turn's provider-reported reasoning tokens in
|
|
102
|
+
* both arms.
|
|
51
103
|
*/
|
|
52
104
|
excludeEncryptedReasoning?: boolean;
|
|
53
105
|
}
|
|
@@ -149,15 +201,25 @@ interface MessageEstimate {
|
|
|
149
201
|
* Model-aware local token counter. Immutable: the catalog-resolved encoding
|
|
150
202
|
* is fixed at construction, so a cached count can never straddle two
|
|
151
203
|
* encodings. An `Agent` owns one for its active model (swapping the instance
|
|
152
|
-
* when the model's encoding or
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* byte estimate (or o200k
|
|
204
|
+
* when the model's encoding, image or thinking-replay pricing, replay
|
|
205
|
+
* identity or native-history replay state changes); one-shot flows construct
|
|
206
|
+
* their own for the model that will be billed. Known tokenizer families use
|
|
207
|
+
* exact native counts; unknown models keep the fast byte estimate (or o200k
|
|
208
|
+
* when `PI_TOKENIZER_ACCURATE=1`).
|
|
156
209
|
*/
|
|
157
210
|
export class Tokenizer {
|
|
158
211
|
readonly #encoding: natives.Encoding | null;
|
|
159
212
|
readonly #frameBilling: snapcompact.FrameBilling;
|
|
160
213
|
readonly frameBillingKey: string;
|
|
214
|
+
readonly #reasoningReader: ReasoningReplayReader | undefined;
|
|
215
|
+
readonly #dropsOllamaThinking: boolean;
|
|
216
|
+
/** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
|
|
217
|
+
readonly #readsDeploymentSignatures: boolean;
|
|
218
|
+
readonly reasoningReplayKey: string;
|
|
219
|
+
/** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
|
|
220
|
+
readonly thinkingReplayTokens: ThinkingReplayTokens;
|
|
221
|
+
/** Whether the counted request replays native Responses history; see {@link TokenizerOptions}. */
|
|
222
|
+
readonly nativeHistoryReplay: boolean;
|
|
161
223
|
|
|
162
224
|
/** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
|
|
163
225
|
readonly #nativeCounts = new LRUCache<string, number>({
|
|
@@ -176,10 +238,27 @@ export class Tokenizer {
|
|
|
176
238
|
*/
|
|
177
239
|
#estimates = new WeakMap<AgentMessage, MessageEstimate>();
|
|
178
240
|
|
|
179
|
-
|
|
241
|
+
/**
|
|
242
|
+
* Counts what `streamSimple` sends `model`. Its catalog compat decides
|
|
243
|
+
* whether Responses reasoning history is filtered: `streamOpenAIResponses`'
|
|
244
|
+
* per-request `filterReasoningHistory` override is a debugging hook
|
|
245
|
+
* `streamSimple` never forwards, so a caller using it passes `model` with
|
|
246
|
+
* that compat set.
|
|
247
|
+
*/
|
|
248
|
+
constructor(model?: TokenizerModel | null, options?: TokenizerOptions) {
|
|
180
249
|
this.#encoding = tokenizerEncodingForModel(model);
|
|
181
250
|
this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
|
|
182
251
|
this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
|
|
252
|
+
this.#reasoningReader = reasoningReplayReader(model);
|
|
253
|
+
this.#dropsOllamaThinking = ollamaChatDropsOwnThinking(model);
|
|
254
|
+
const compat = model?.compat;
|
|
255
|
+
this.#readsDeploymentSignatures =
|
|
256
|
+
model?.api === "anthropic-messages" &&
|
|
257
|
+
((compat !== undefined && "officialEndpoint" in compat && compat.officialEndpoint) ||
|
|
258
|
+
model.thinking?.prefixBinding === true);
|
|
259
|
+
this.reasoningReplayKey = reasoningReplayKey(model);
|
|
260
|
+
this.thinkingReplayTokens = resolveThinkingReplayTokens(model ?? {});
|
|
261
|
+
this.nativeHistoryReplay = options?.nativeHistoryReplay ?? true;
|
|
183
262
|
}
|
|
184
263
|
|
|
185
264
|
get encoding(): natives.Encoding | null {
|
|
@@ -218,12 +297,12 @@ export class Tokenizer {
|
|
|
218
297
|
* Settled historical messages are counted once and reused until an owner
|
|
219
298
|
* (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
|
|
220
299
|
* assistants bypass the memo entirely (see the message-cache settle-gate
|
|
221
|
-
* invariant). Image blocks charge {@link
|
|
222
|
-
*
|
|
300
|
+
* invariant). Image blocks charge {@link imageTokens}, the same estimate
|
|
301
|
+
* the native remote-compaction probe applies.
|
|
223
302
|
*/
|
|
224
303
|
countMessage(message: AgentMessage, options?: MessageCountOptions): number {
|
|
225
304
|
const floored = options?.excludeEncryptedReasoning === true;
|
|
226
|
-
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored);
|
|
305
|
+
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored, false);
|
|
227
306
|
const version = messageEstimateVersion(message);
|
|
228
307
|
let entry = this.#estimates.get(message);
|
|
229
308
|
if (entry === undefined || entry.version !== version) {
|
|
@@ -232,12 +311,25 @@ export class Tokenizer {
|
|
|
232
311
|
}
|
|
233
312
|
const cached = floored ? entry.floored : entry.default;
|
|
234
313
|
if (cached !== undefined) return cached;
|
|
235
|
-
const result = this.#measureMessage(message, floored);
|
|
314
|
+
const result = this.#measureMessage(message, floored, false);
|
|
236
315
|
if (floored) entry.floored = result;
|
|
237
316
|
else entry.default = result;
|
|
238
317
|
return result;
|
|
239
318
|
}
|
|
240
319
|
|
|
320
|
+
/**
|
|
321
|
+
* {@link countMessage} for the transcript's latest surviving assistant turn
|
|
322
|
+
* (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
|
|
323
|
+
* byte-for-byte (`transformMessages`): a turn whose tool calls ended without
|
|
324
|
+
* a tool-use stop keeps its signatures then, so its reasoning is charged. A
|
|
325
|
+
* count of one message cannot see its position; a caller walking back from
|
|
326
|
+
* the end of the transcript counts that turn here. Not memoized: it differs
|
|
327
|
+
* from {@link countMessage} only for that one turn.
|
|
328
|
+
*/
|
|
329
|
+
countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number {
|
|
330
|
+
return this.#measureMessage(message, options?.excludeEncryptedReasoning === true, true);
|
|
331
|
+
}
|
|
332
|
+
|
|
241
333
|
/** Sum of {@link countMessage} over `messages`. */
|
|
242
334
|
countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number {
|
|
243
335
|
let total = 0;
|
|
@@ -245,7 +337,51 @@ export class Tokenizer {
|
|
|
245
337
|
return total;
|
|
246
338
|
}
|
|
247
339
|
|
|
248
|
-
|
|
340
|
+
/**
|
|
341
|
+
* Input tokens for one image (not a snapcompact frame) under the reader's
|
|
342
|
+
* catalog image rule, or the generic estimate when it has none.
|
|
343
|
+
*/
|
|
344
|
+
imageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
|
|
345
|
+
return estimateImageTokens(size, detail, this.#frameBilling.rule);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Whether `message`'s signed reasoning reaches this reader natively: from
|
|
350
|
+
* the same model, host and wire, or from another Claude model on the same
|
|
351
|
+
* Anthropic deployment, which keeps the signature (`transformMessages`).
|
|
352
|
+
* Claude bills that reasoning when the reader can read it and drops the
|
|
353
|
+
* block otherwise, so such a turn counts high rather than low.
|
|
354
|
+
*/
|
|
355
|
+
#replaysReasoningNatively(message: AssistantMessage): boolean {
|
|
356
|
+
const reader = this.#reasoningReader;
|
|
357
|
+
return (
|
|
358
|
+
reader !== undefined &&
|
|
359
|
+
message.api === reader.api &&
|
|
360
|
+
message.provider === reader.provider &&
|
|
361
|
+
(message.model === reader.id || this.#readsDeploymentSignatures)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The replayed native Responses reasoning {@link countMessage} charges
|
|
367
|
+
* across `messages`: the part of a count that changes with the reading
|
|
368
|
+
* model. A replay whose backend reported no reasoning tokens counts at the
|
|
369
|
+
* signature price `countMessage` gives it.
|
|
370
|
+
*/
|
|
371
|
+
replayedReasoningTokens(messages: readonly AgentMessage[]): number {
|
|
372
|
+
let total = 0;
|
|
373
|
+
for (const message of messages) {
|
|
374
|
+
if (message.role !== "assistant") continue;
|
|
375
|
+
const replayed = this.#replayedReasoningTokens(message);
|
|
376
|
+
total +=
|
|
377
|
+
replayed === null
|
|
378
|
+
? this.countMessage(message) - this.countMessage(message, { excludeEncryptedReasoning: true })
|
|
379
|
+
: (replayed ?? 0);
|
|
380
|
+
}
|
|
381
|
+
return total;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean, latestTurn: boolean): number {
|
|
249
385
|
const fragments: string[] = [];
|
|
250
386
|
let extra = 0;
|
|
251
387
|
// Declaration-merged app roles (the coding-agent's bashExecution) are
|
|
@@ -273,35 +409,78 @@ export class Tokenizer {
|
|
|
273
409
|
if (block.type === "text" && block.text) {
|
|
274
410
|
fragments.push(block.text);
|
|
275
411
|
} else if (block.type === "image") {
|
|
276
|
-
extra +=
|
|
412
|
+
extra += this.imageTokens(base64ImageSize(block.data), block.detail);
|
|
277
413
|
}
|
|
278
414
|
}
|
|
279
415
|
}
|
|
280
416
|
break;
|
|
281
417
|
}
|
|
282
418
|
case "assistant": {
|
|
283
|
-
|
|
419
|
+
// Replayed native Responses reasoning is billed as input at the turn's
|
|
420
|
+
// reported reasoning tokens, not at its ~3x larger ciphertext size, so it
|
|
421
|
+
// is a token price and counts in both arms. Its thinking signature never
|
|
422
|
+
// reaches the wire then (the payload items replay instead); a turn whose
|
|
423
|
+
// backend reported no reasoning tokens keeps the signature as its price.
|
|
424
|
+
const replayedReasoning = this.#replayedReasoningTokens(message);
|
|
425
|
+
const ollamaReader = this.#reasoningReader?.api === "ollama-chat" ? this.#reasoningReader : undefined;
|
|
426
|
+
const ownOllamaTurn = ollamaReader !== undefined && isSameModelTurn(message, ollamaReader);
|
|
427
|
+
if (typeof replayedReasoning === "number") extra += replayedReasoning;
|
|
428
|
+
const signaturesPriced = this.thinkingReplayTokens === "signature";
|
|
429
|
+
// The replay's signature rule (`transformMessages`): an aborted or
|
|
430
|
+
// errored turn's final block can hold a partial signature, and a turn
|
|
431
|
+
// whose tool calls ended without a tool-use stop is abandoned, so all
|
|
432
|
+
// its signatures are stripped. Every other block completed. On the
|
|
433
|
+
// Anthropic Messages API the latest assistant turn keeps an abandoned
|
|
434
|
+
// turn's signatures byte-for-byte (Bedrock's Converse replay strips
|
|
435
|
+
// them too); a per-message count cannot see position, so it follows
|
|
436
|
+
// the rule for older turns unless the caller says it is the latest
|
|
437
|
+
// (`countLatestTurn`).
|
|
438
|
+
const content = message.content;
|
|
439
|
+
const invalidStop = message.stopReason === "aborted" || message.stopReason === "error";
|
|
440
|
+
const keepsLatestSignatures = latestTurn && message.api === "anthropic-messages";
|
|
441
|
+
const abandonedToolUse =
|
|
442
|
+
!keepsLatestSignatures &&
|
|
443
|
+
!invalidStop &&
|
|
444
|
+
message.stopReason !== "toolUse" &&
|
|
445
|
+
content.some(block => block.type === "toolCall");
|
|
446
|
+
const untrustedIndex = invalidStop ? content.length - 1 : -1;
|
|
447
|
+
let signedReasoning = false;
|
|
448
|
+
for (let index = 0; index < content.length; index++) {
|
|
449
|
+
const block = content[index];
|
|
284
450
|
if (block.type === "text") {
|
|
285
451
|
fragments.push(block.text);
|
|
286
452
|
} else if (block.type === "thinking") {
|
|
453
|
+
// `transformMessages` drops another model's empty thinking and demotes the
|
|
454
|
+
// rest to visible text; only the reader's own thinking stays native.
|
|
455
|
+
if (ollamaReader && !ownOllamaTurn) {
|
|
456
|
+
if (block.thinking.trim()) fragments.push(renderDemotedThinking(ollamaReader.id, block.thinking));
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (this.#dropsOllamaThinking && ownOllamaTurn) continue;
|
|
287
460
|
fragments.push(block.thinking);
|
|
288
461
|
// Providers charge for the opaque signature/reasoning payload that
|
|
289
462
|
// rides alongside the thinking text (OpenAI Responses encrypted
|
|
290
|
-
// reasoning items,
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
463
|
+
// reasoning items, Gemini thought signatures). A reader billing at
|
|
464
|
+
// `output` size (Claude) charges the reasoning, priced below, not
|
|
465
|
+
// these bytes. The compaction floor excludes it (its local byte size
|
|
466
|
+
// diverges from provider billing).
|
|
467
|
+
if (
|
|
468
|
+
block.thinkingSignature &&
|
|
469
|
+
!excludeEncryptedReasoning &&
|
|
470
|
+
signaturesPriced &&
|
|
471
|
+
typeof replayedReasoning !== "number"
|
|
472
|
+
) {
|
|
296
473
|
fragments.push(block.thinkingSignature);
|
|
297
474
|
}
|
|
475
|
+
if (block.thinkingSignature && !abandonedToolUse && index !== untrustedIndex) signedReasoning = true;
|
|
298
476
|
} else if (block.type === "toolCall") {
|
|
299
477
|
fragments.push(block.name);
|
|
300
478
|
fragments.push(stringifyJson(block.arguments) ?? "null");
|
|
301
479
|
} else if (block.type === "redactedThinking") {
|
|
302
480
|
// Encrypted reasoning blob the provider still bills for on replay;
|
|
303
481
|
// excluded from the compaction floor for the same reason as above.
|
|
304
|
-
|
|
482
|
+
signedReasoning = true;
|
|
483
|
+
if (!excludeEncryptedReasoning && signaturesPriced) fragments.push(block.data);
|
|
305
484
|
} else if (block.type === "anthropicServerTool") {
|
|
306
485
|
// Native Anthropic server-tool call/result replayed verbatim on the
|
|
307
486
|
// wire (server_tool_use input and opaque result content). The provider
|
|
@@ -311,6 +490,19 @@ export class Tokenizer {
|
|
|
311
490
|
if (!excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
|
|
312
491
|
}
|
|
313
492
|
}
|
|
493
|
+
// The visible text undercounts a native replay: the provider rebuilds
|
|
494
|
+
// the full reasoning from its signature, billed at the turn's output
|
|
495
|
+
// size less the visible text shake has since elided from it. The
|
|
496
|
+
// floor keeps the visible count.
|
|
497
|
+
if (
|
|
498
|
+
signedReasoning &&
|
|
499
|
+
!excludeEncryptedReasoning &&
|
|
500
|
+
!signaturesPriced &&
|
|
501
|
+
this.#replaysReasoningNatively(message)
|
|
502
|
+
) {
|
|
503
|
+
const visible = fragments.length === 0 ? extra : extra + this.countTokens(fragments);
|
|
504
|
+
return Math.max(visible, message.usage.output - (message.elidedOutputTokens ?? 0));
|
|
505
|
+
}
|
|
314
506
|
break;
|
|
315
507
|
}
|
|
316
508
|
case "custom":
|
|
@@ -323,7 +515,7 @@ export class Tokenizer {
|
|
|
323
515
|
if (block.type === "text" && block.text) {
|
|
324
516
|
fragments.push(block.text);
|
|
325
517
|
} else if (block.type === "image") {
|
|
326
|
-
extra +=
|
|
518
|
+
extra += this.imageTokens(base64ImageSize(block.data), block.detail);
|
|
327
519
|
}
|
|
328
520
|
}
|
|
329
521
|
}
|
|
@@ -354,6 +546,42 @@ export class Tokenizer {
|
|
|
354
546
|
return extra + this.countTokens(fragments);
|
|
355
547
|
}
|
|
356
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Input tokens this reader bills for replaying `message`'s native Responses
|
|
551
|
+
* reasoning; `undefined` when the reader re-encodes the turn from its
|
|
552
|
+
* blocks instead, `null` when it replays but the backend reported no
|
|
553
|
+
* reasoning tokens to price it by. The payload goes back on the wire only
|
|
554
|
+
* to the api, model and provider that produced it (`convertMessages` in
|
|
555
|
+
* pi-ai's Responses providers), and then only what survives replay: a
|
|
556
|
+
* truncated thinking-only turn is dropped, a hidden-empty or reasoning-only
|
|
557
|
+
* payload sanitizes to nothing, and a cold session rebuilds the turn without
|
|
558
|
+
* its reasoning, so those send no reasoning and no signature (0).
|
|
559
|
+
*/
|
|
560
|
+
#replayedReasoningTokens(message: AssistantMessage): number | null | undefined {
|
|
561
|
+
const reader = this.#reasoningReader;
|
|
562
|
+
if (!reader || message.api !== reader.api || message.model !== reader.id) return undefined;
|
|
563
|
+
const items = getOpenAIResponsesHistoryItems(message.providerPayload, reader.provider, message.provider);
|
|
564
|
+
if (!items) return undefined;
|
|
565
|
+
if (!items.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) return 0;
|
|
566
|
+
const replay = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items);
|
|
567
|
+
if (!replay?.some(item => item.type === "reasoning")) return 0;
|
|
568
|
+
// Cold, only plaintext reasoning bound to no server state replays natively.
|
|
569
|
+
if (!this.nativeHistoryReplay && !isColdReplayableResponsesTurn(replay)) return 0;
|
|
570
|
+
return message.usage?.reasoningTokens ?? null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Whether a Tokenizer built for `model` with `options` would count every message as this one does. */
|
|
574
|
+
countsAlike(model: TokenizerModel | null | undefined, options?: TokenizerOptions): boolean {
|
|
575
|
+
return (
|
|
576
|
+
tokenizerEncodingForModel(model) === this.#encoding &&
|
|
577
|
+
snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) === this.frameBillingKey &&
|
|
578
|
+
reasoningReplayKey(model) === this.reasoningReplayKey &&
|
|
579
|
+
resolveThinkingReplayTokens(model ?? {}) === this.thinkingReplayTokens &&
|
|
580
|
+
ollamaChatDropsOwnThinking(model) === this.#dropsOllamaThinking &&
|
|
581
|
+
(options?.nativeHistoryReplay ?? true) === this.nativeHistoryReplay
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
357
585
|
/** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
|
|
358
586
|
#frameTokens(data: string): number {
|
|
359
587
|
const size = base64ImageSize(data);
|
package/src/types.ts
CHANGED
|
@@ -38,10 +38,14 @@ export interface QueuedMessagePreparation {
|
|
|
38
38
|
commit(): readonly AgentMessage[] | undefined;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Which agent queue a batch came from: steering amends the running task, follow-ups start the next one. */
|
|
42
|
+
export type QueuedMessageQueue = "steering" | "followUp";
|
|
43
|
+
|
|
41
44
|
/** Prepare an exclusively claimed batch. Undefined delivers unchanged; the signal also aborts when the claim is cancelled. */
|
|
42
45
|
export type PrepareQueuedMessages = (
|
|
43
46
|
messages: readonly AgentMessage[],
|
|
44
47
|
signal: AbortSignal,
|
|
48
|
+
queue: QueuedMessageQueue,
|
|
45
49
|
) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
|
|
46
50
|
|
|
47
51
|
/** Called once an aside has been inserted into the agent's live context. */
|
|
@@ -338,6 +342,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
338
342
|
* tools finish first.
|
|
339
343
|
*/
|
|
340
344
|
hasQueuedAsides?: () => boolean | Promise<boolean>;
|
|
345
|
+
/**
|
|
346
|
+
* Receives a tool's `onUpdate` frames that arrive after this run's event
|
|
347
|
+
* stream ended: a call that returned while its background job keeps
|
|
348
|
+
* reporting (`details.async`), whose run yielded before the job settled.
|
|
349
|
+
* Without it those frames are dropped.
|
|
350
|
+
*/
|
|
351
|
+
onDetachedToolUpdate?: (event: Extract<AgentEvent, { type: "tool_execution_update" }>) => void;
|
|
341
352
|
|
|
342
353
|
/**
|
|
343
354
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
@@ -1135,6 +1146,11 @@ export interface AgentTool<
|
|
|
1135
1146
|
loadMode?: ToolLoadMode;
|
|
1136
1147
|
/** Short one-line summary used for tool discovery indexes. */
|
|
1137
1148
|
summary?: string;
|
|
1149
|
+
/**
|
|
1150
|
+
* Instructions a same-name extension replacement inherits in its description, because results it
|
|
1151
|
+
* obtains by delegating to this native tool through `ctx.invokeTool` follow this tool's contract.
|
|
1152
|
+
*/
|
|
1153
|
+
readonly delegationGuidance?: string;
|
|
1138
1154
|
/**
|
|
1139
1155
|
* On-demand documentation topics (`topic → markdown`), readable as
|
|
1140
1156
|
* `xd://<tool>/<topic>`. Lets a tool keep large sub-surfaces out of its
|
|
@@ -1240,7 +1256,14 @@ export interface AgentContext {
|
|
|
1240
1256
|
*/
|
|
1241
1257
|
export type AgentEvent =
|
|
1242
1258
|
// Agent lifecycle
|
|
1243
|
-
| {
|
|
1259
|
+
| {
|
|
1260
|
+
type: "agent_start";
|
|
1261
|
+
/**
|
|
1262
|
+
* Calls of the unpaired assistant tail a continued run executes before its
|
|
1263
|
+
* first model request. They run without a new assistant `message_end`.
|
|
1264
|
+
*/
|
|
1265
|
+
resumedToolCallIds?: string[];
|
|
1266
|
+
}
|
|
1244
1267
|
| {
|
|
1245
1268
|
type: "agent_end";
|
|
1246
1269
|
messages: AgentMessage[];
|