@linxiraos/pi-agent-core 1.2.0 → 1.3.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 +40 -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 +10 -10
- 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/tokenizer.ts +238 -27
- package/src/types.ts +24 -1
package/src/tokenizer.ts
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type { Model } from "@linxiraos/pi-ai";
|
|
1
|
+
import type { AssistantMessage, Model } from "@linxiraos/pi-ai";
|
|
2
|
+
import { isColdReplayableResponsesTurn } from "@linxiraos/pi-ai/providers/openai-shared";
|
|
3
|
+
import { shouldDropTruncatedThinkingOnlyAssistant } from "@linxiraos/pi-ai/providers/transform-messages";
|
|
4
|
+
import {
|
|
5
|
+
getOpenAIResponsesHistoryItems,
|
|
6
|
+
sanitizeOpenAIResponsesAssistantHistoryItemsForReplay,
|
|
7
|
+
} from "@linxiraos/pi-ai/utils";
|
|
8
|
+
import { resolveThinkingReplayTokens, type ThinkingReplayTokens } from "@linxiraos/pi-catalog/compat/thinking-replay";
|
|
2
9
|
import type { ModelTokenizer } from "@linxiraos/pi-catalog/types";
|
|
3
10
|
import * as natives from "@linxiraos/pi-natives";
|
|
4
11
|
import { materializeString, stringifyJson } from "@linxiraos/pi-utils";
|
|
5
12
|
import { LRUCache } from "@linxiraos/pi-utils/lru";
|
|
6
13
|
import * as snapcompact from "@linxiraos/pi-snapcompact";
|
|
7
14
|
import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
|
|
8
|
-
import { base64ImageSize,
|
|
15
|
+
import { base64ImageSize, estimateImageTokens, type ImageDetail, type ImageSize } from "./image-tokens";
|
|
9
16
|
import type { AgentMessage } from "./types";
|
|
10
17
|
|
|
11
18
|
const testEnv = Bun.env.NODE_ENV === "test";
|
|
@@ -20,6 +27,7 @@ const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
|
|
|
20
27
|
"deepseek-v3": natives.Encoding.DeepSeekV3,
|
|
21
28
|
"kimi-k2": natives.Encoding.KimiK2,
|
|
22
29
|
glm5: natives.Encoding.Glm5,
|
|
30
|
+
o200k: natives.Encoding.O200kBase,
|
|
23
31
|
};
|
|
24
32
|
|
|
25
33
|
/** Maps the catalog-resolved tokenizer family to its native implementation. */
|
|
@@ -27,6 +35,41 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
|
|
|
27
35
|
return model?.tokenizer ? NATIVE_ENCODING[model.tokenizer] : null;
|
|
28
36
|
}
|
|
29
37
|
|
|
38
|
+
type TokenizerModel = Pick<Model, "tokenizer"> &
|
|
39
|
+
snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"] };
|
|
40
|
+
|
|
41
|
+
/** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
|
|
42
|
+
interface ReasoningReplayReader {
|
|
43
|
+
api: string;
|
|
44
|
+
provider: string;
|
|
45
|
+
id: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function reasoningReplayReader(model: TokenizerModel | null | undefined): ReasoningReplayReader | undefined {
|
|
49
|
+
if (!model?.api || !model.provider || !model.id) return undefined;
|
|
50
|
+
// Filtering targets strip native reasoning items before the request is encoded.
|
|
51
|
+
const compat = model.compat;
|
|
52
|
+
if (compat && "filterReasoningHistory" in compat && compat.filterReasoningHistory) return undefined;
|
|
53
|
+
return { api: model.api, provider: model.provider, id: model.id };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
|
|
57
|
+
export function reasoningReplayKey(model: TokenizerModel | null | undefined): string {
|
|
58
|
+
const reader = reasoningReplayReader(model);
|
|
59
|
+
return reader ? `${reader.api}\0${reader.provider}\0${reader.id}` : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Request state a {@link Tokenizer} counts for, beyond its model. */
|
|
63
|
+
export interface TokenizerOptions {
|
|
64
|
+
/**
|
|
65
|
+
* Whether the request replays native Responses history (pi-ai's
|
|
66
|
+
* `openAIResponsesReplaysNativeHistory`). A cold session rebuilds a turn
|
|
67
|
+
* holding encrypted reasoning from its text and tool calls, without the
|
|
68
|
+
* reasoning or its signature. Default: true.
|
|
69
|
+
*/
|
|
70
|
+
nativeHistoryReplay?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
30
73
|
/**
|
|
31
74
|
* `strict` always tries an exact native count (the catalog-resolved tokenizer
|
|
32
75
|
* when known, o200k_base otherwise), falling back to the byte upper bound when
|
|
@@ -43,11 +86,15 @@ export interface MessageCountOptions {
|
|
|
43
86
|
/**
|
|
44
87
|
* Drop opaque provider reasoning payloads (`thinkingSignature`,
|
|
45
88
|
* `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
|
-
*
|
|
89
|
+
* are billed by the provider on replay, so the default counts them (a reader
|
|
90
|
+
* billing replayed thinking at `output` size: the reasoning, at its turn's
|
|
91
|
+
* output size) — but their *local* byte size can diverge wildly from what
|
|
92
|
+
* the provider charges, so the compaction floor (which only needs the
|
|
93
|
+
* reliably-countable, on-wire-compressible content) excludes them to avoid
|
|
94
|
+
* false triggers on thinking-heavy turns.
|
|
95
|
+
* Native Responses reasoning that actually replays is not an opaque byte
|
|
96
|
+
* guess: it is charged at the turn's provider-reported reasoning tokens in
|
|
97
|
+
* both arms.
|
|
51
98
|
*/
|
|
52
99
|
excludeEncryptedReasoning?: boolean;
|
|
53
100
|
}
|
|
@@ -149,15 +196,24 @@ interface MessageEstimate {
|
|
|
149
196
|
* Model-aware local token counter. Immutable: the catalog-resolved encoding
|
|
150
197
|
* is fixed at construction, so a cached count can never straddle two
|
|
151
198
|
* 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
|
|
199
|
+
* when the model's encoding, image or thinking-replay pricing, replay
|
|
200
|
+
* identity or native-history replay state changes); one-shot flows construct
|
|
201
|
+
* their own for the model that will be billed. Known tokenizer families use
|
|
202
|
+
* exact native counts; unknown models keep the fast byte estimate (or o200k
|
|
203
|
+
* when `PI_TOKENIZER_ACCURATE=1`).
|
|
156
204
|
*/
|
|
157
205
|
export class Tokenizer {
|
|
158
206
|
readonly #encoding: natives.Encoding | null;
|
|
159
207
|
readonly #frameBilling: snapcompact.FrameBilling;
|
|
160
208
|
readonly frameBillingKey: string;
|
|
209
|
+
readonly #reasoningReader: ReasoningReplayReader | undefined;
|
|
210
|
+
/** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
|
|
211
|
+
readonly #readsDeploymentSignatures: boolean;
|
|
212
|
+
readonly reasoningReplayKey: string;
|
|
213
|
+
/** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
|
|
214
|
+
readonly thinkingReplayTokens: ThinkingReplayTokens;
|
|
215
|
+
/** Whether the counted request replays native Responses history; see {@link TokenizerOptions}. */
|
|
216
|
+
readonly nativeHistoryReplay: boolean;
|
|
161
217
|
|
|
162
218
|
/** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
|
|
163
219
|
readonly #nativeCounts = new LRUCache<string, number>({
|
|
@@ -176,10 +232,26 @@ export class Tokenizer {
|
|
|
176
232
|
*/
|
|
177
233
|
#estimates = new WeakMap<AgentMessage, MessageEstimate>();
|
|
178
234
|
|
|
179
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Counts what `streamSimple` sends `model`. Its catalog compat decides
|
|
237
|
+
* whether Responses reasoning history is filtered: `streamOpenAIResponses`'
|
|
238
|
+
* per-request `filterReasoningHistory` override is a debugging hook
|
|
239
|
+
* `streamSimple` never forwards, so a caller using it passes `model` with
|
|
240
|
+
* that compat set.
|
|
241
|
+
*/
|
|
242
|
+
constructor(model?: TokenizerModel | null, options?: TokenizerOptions) {
|
|
180
243
|
this.#encoding = tokenizerEncodingForModel(model);
|
|
181
244
|
this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
|
|
182
245
|
this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
|
|
246
|
+
this.#reasoningReader = reasoningReplayReader(model);
|
|
247
|
+
const compat = model?.compat;
|
|
248
|
+
this.#readsDeploymentSignatures =
|
|
249
|
+
model?.api === "anthropic-messages" &&
|
|
250
|
+
((compat !== undefined && "officialEndpoint" in compat && compat.officialEndpoint) ||
|
|
251
|
+
model.thinking?.prefixBinding === true);
|
|
252
|
+
this.reasoningReplayKey = reasoningReplayKey(model);
|
|
253
|
+
this.thinkingReplayTokens = resolveThinkingReplayTokens(model ?? {});
|
|
254
|
+
this.nativeHistoryReplay = options?.nativeHistoryReplay ?? true;
|
|
183
255
|
}
|
|
184
256
|
|
|
185
257
|
get encoding(): natives.Encoding | null {
|
|
@@ -218,12 +290,12 @@ export class Tokenizer {
|
|
|
218
290
|
* Settled historical messages are counted once and reused until an owner
|
|
219
291
|
* (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
|
|
220
292
|
* assistants bypass the memo entirely (see the message-cache settle-gate
|
|
221
|
-
* invariant). Image blocks charge {@link
|
|
222
|
-
*
|
|
293
|
+
* invariant). Image blocks charge {@link imageTokens}, the same estimate
|
|
294
|
+
* the native remote-compaction probe applies.
|
|
223
295
|
*/
|
|
224
296
|
countMessage(message: AgentMessage, options?: MessageCountOptions): number {
|
|
225
297
|
const floored = options?.excludeEncryptedReasoning === true;
|
|
226
|
-
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored);
|
|
298
|
+
if (!isEstimateCacheable(message)) return this.#measureMessage(message, floored, false);
|
|
227
299
|
const version = messageEstimateVersion(message);
|
|
228
300
|
let entry = this.#estimates.get(message);
|
|
229
301
|
if (entry === undefined || entry.version !== version) {
|
|
@@ -232,12 +304,25 @@ export class Tokenizer {
|
|
|
232
304
|
}
|
|
233
305
|
const cached = floored ? entry.floored : entry.default;
|
|
234
306
|
if (cached !== undefined) return cached;
|
|
235
|
-
const result = this.#measureMessage(message, floored);
|
|
307
|
+
const result = this.#measureMessage(message, floored, false);
|
|
236
308
|
if (floored) entry.floored = result;
|
|
237
309
|
else entry.default = result;
|
|
238
310
|
return result;
|
|
239
311
|
}
|
|
240
312
|
|
|
313
|
+
/**
|
|
314
|
+
* {@link countMessage} for the transcript's latest surviving assistant turn
|
|
315
|
+
* (`isSurvivingAssistant`), which an Anthropic Messages replay keeps
|
|
316
|
+
* byte-for-byte (`transformMessages`): a turn whose tool calls ended without
|
|
317
|
+
* a tool-use stop keeps its signatures then, so its reasoning is charged. A
|
|
318
|
+
* count of one message cannot see its position; a caller walking back from
|
|
319
|
+
* the end of the transcript counts that turn here. Not memoized: it differs
|
|
320
|
+
* from {@link countMessage} only for that one turn.
|
|
321
|
+
*/
|
|
322
|
+
countLatestTurn(message: AgentMessage, options?: MessageCountOptions): number {
|
|
323
|
+
return this.#measureMessage(message, options?.excludeEncryptedReasoning === true, true);
|
|
324
|
+
}
|
|
325
|
+
|
|
241
326
|
/** Sum of {@link countMessage} over `messages`. */
|
|
242
327
|
countMessages(messages: readonly AgentMessage[], options?: MessageCountOptions): number {
|
|
243
328
|
let total = 0;
|
|
@@ -245,7 +330,51 @@ export class Tokenizer {
|
|
|
245
330
|
return total;
|
|
246
331
|
}
|
|
247
332
|
|
|
248
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Input tokens for one image (not a snapcompact frame) under the reader's
|
|
335
|
+
* catalog image rule, or the generic estimate when it has none.
|
|
336
|
+
*/
|
|
337
|
+
imageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
|
|
338
|
+
return estimateImageTokens(size, detail, this.#frameBilling.rule);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Whether `message`'s signed reasoning reaches this reader natively: from
|
|
343
|
+
* the same model, host and wire, or from another Claude model on the same
|
|
344
|
+
* Anthropic deployment, which keeps the signature (`transformMessages`).
|
|
345
|
+
* Claude bills that reasoning when the reader can read it and drops the
|
|
346
|
+
* block otherwise, so such a turn counts high rather than low.
|
|
347
|
+
*/
|
|
348
|
+
#replaysReasoningNatively(message: AssistantMessage): boolean {
|
|
349
|
+
const reader = this.#reasoningReader;
|
|
350
|
+
return (
|
|
351
|
+
reader !== undefined &&
|
|
352
|
+
message.api === reader.api &&
|
|
353
|
+
message.provider === reader.provider &&
|
|
354
|
+
(message.model === reader.id || this.#readsDeploymentSignatures)
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The replayed native Responses reasoning {@link countMessage} charges
|
|
360
|
+
* across `messages`: the part of a count that changes with the reading
|
|
361
|
+
* model. A replay whose backend reported no reasoning tokens counts at the
|
|
362
|
+
* signature price `countMessage` gives it.
|
|
363
|
+
*/
|
|
364
|
+
replayedReasoningTokens(messages: readonly AgentMessage[]): number {
|
|
365
|
+
let total = 0;
|
|
366
|
+
for (const message of messages) {
|
|
367
|
+
if (message.role !== "assistant") continue;
|
|
368
|
+
const replayed = this.#replayedReasoningTokens(message);
|
|
369
|
+
total +=
|
|
370
|
+
replayed === null
|
|
371
|
+
? this.countMessage(message) - this.countMessage(message, { excludeEncryptedReasoning: true })
|
|
372
|
+
: (replayed ?? 0);
|
|
373
|
+
}
|
|
374
|
+
return total;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean, latestTurn: boolean): number {
|
|
249
378
|
const fragments: string[] = [];
|
|
250
379
|
let extra = 0;
|
|
251
380
|
// Declaration-merged app roles (the coding-agent's bashExecution) are
|
|
@@ -273,35 +402,69 @@ export class Tokenizer {
|
|
|
273
402
|
if (block.type === "text" && block.text) {
|
|
274
403
|
fragments.push(block.text);
|
|
275
404
|
} else if (block.type === "image") {
|
|
276
|
-
extra +=
|
|
405
|
+
extra += this.imageTokens(base64ImageSize(block.data), block.detail);
|
|
277
406
|
}
|
|
278
407
|
}
|
|
279
408
|
}
|
|
280
409
|
break;
|
|
281
410
|
}
|
|
282
411
|
case "assistant": {
|
|
283
|
-
|
|
412
|
+
// Replayed native Responses reasoning is billed as input at the turn's
|
|
413
|
+
// reported reasoning tokens, not at its ~3x larger ciphertext size, so it
|
|
414
|
+
// is a token price and counts in both arms. Its thinking signature never
|
|
415
|
+
// reaches the wire then (the payload items replay instead); a turn whose
|
|
416
|
+
// backend reported no reasoning tokens keeps the signature as its price.
|
|
417
|
+
const replayedReasoning = this.#replayedReasoningTokens(message);
|
|
418
|
+
if (typeof replayedReasoning === "number") extra += replayedReasoning;
|
|
419
|
+
const signaturesPriced = this.thinkingReplayTokens === "signature";
|
|
420
|
+
// The replay's signature rule (`transformMessages`): an aborted or
|
|
421
|
+
// errored turn's final block can hold a partial signature, and a turn
|
|
422
|
+
// whose tool calls ended without a tool-use stop is abandoned, so all
|
|
423
|
+
// its signatures are stripped. Every other block completed. On the
|
|
424
|
+
// Anthropic Messages API the latest assistant turn keeps an abandoned
|
|
425
|
+
// turn's signatures byte-for-byte (Bedrock's Converse replay strips
|
|
426
|
+
// them too); a per-message count cannot see position, so it follows
|
|
427
|
+
// the rule for older turns unless the caller says it is the latest
|
|
428
|
+
// (`countLatestTurn`).
|
|
429
|
+
const content = message.content;
|
|
430
|
+
const invalidStop = message.stopReason === "aborted" || message.stopReason === "error";
|
|
431
|
+
const keepsLatestSignatures = latestTurn && message.api === "anthropic-messages";
|
|
432
|
+
const abandonedToolUse =
|
|
433
|
+
!keepsLatestSignatures &&
|
|
434
|
+
!invalidStop &&
|
|
435
|
+
message.stopReason !== "toolUse" &&
|
|
436
|
+
content.some(block => block.type === "toolCall");
|
|
437
|
+
const untrustedIndex = invalidStop ? content.length - 1 : -1;
|
|
438
|
+
let signedReasoning = false;
|
|
439
|
+
for (let index = 0; index < content.length; index++) {
|
|
440
|
+
const block = content[index];
|
|
284
441
|
if (block.type === "text") {
|
|
285
442
|
fragments.push(block.text);
|
|
286
443
|
} else if (block.type === "thinking") {
|
|
287
444
|
fragments.push(block.thinking);
|
|
288
445
|
// Providers charge for the opaque signature/reasoning payload that
|
|
289
446
|
// rides alongside the thinking text (OpenAI Responses encrypted
|
|
290
|
-
// reasoning items,
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
447
|
+
// reasoning items, Gemini thought signatures). A reader billing at
|
|
448
|
+
// `output` size (Claude) charges the reasoning, priced below, not
|
|
449
|
+
// these bytes. The compaction floor excludes it (its local byte size
|
|
450
|
+
// diverges from provider billing).
|
|
451
|
+
if (
|
|
452
|
+
block.thinkingSignature &&
|
|
453
|
+
!excludeEncryptedReasoning &&
|
|
454
|
+
signaturesPriced &&
|
|
455
|
+
typeof replayedReasoning !== "number"
|
|
456
|
+
) {
|
|
296
457
|
fragments.push(block.thinkingSignature);
|
|
297
458
|
}
|
|
459
|
+
if (block.thinkingSignature && !abandonedToolUse && index !== untrustedIndex) signedReasoning = true;
|
|
298
460
|
} else if (block.type === "toolCall") {
|
|
299
461
|
fragments.push(block.name);
|
|
300
462
|
fragments.push(stringifyJson(block.arguments) ?? "null");
|
|
301
463
|
} else if (block.type === "redactedThinking") {
|
|
302
464
|
// Encrypted reasoning blob the provider still bills for on replay;
|
|
303
465
|
// excluded from the compaction floor for the same reason as above.
|
|
304
|
-
|
|
466
|
+
signedReasoning = true;
|
|
467
|
+
if (!excludeEncryptedReasoning && signaturesPriced) fragments.push(block.data);
|
|
305
468
|
} else if (block.type === "anthropicServerTool") {
|
|
306
469
|
// Native Anthropic server-tool call/result replayed verbatim on the
|
|
307
470
|
// wire (server_tool_use input and opaque result content). The provider
|
|
@@ -311,6 +474,19 @@ export class Tokenizer {
|
|
|
311
474
|
if (!excludeEncryptedReasoning) fragments.push(stringifyJson(block.block) ?? "null");
|
|
312
475
|
}
|
|
313
476
|
}
|
|
477
|
+
// The visible text undercounts a native replay: the provider rebuilds
|
|
478
|
+
// the full reasoning from its signature, billed at the turn's output
|
|
479
|
+
// size less the visible text shake has since elided from it. The
|
|
480
|
+
// floor keeps the visible count.
|
|
481
|
+
if (
|
|
482
|
+
signedReasoning &&
|
|
483
|
+
!excludeEncryptedReasoning &&
|
|
484
|
+
!signaturesPriced &&
|
|
485
|
+
this.#replaysReasoningNatively(message)
|
|
486
|
+
) {
|
|
487
|
+
const visible = fragments.length === 0 ? extra : extra + this.countTokens(fragments);
|
|
488
|
+
return Math.max(visible, message.usage.output - (message.elidedOutputTokens ?? 0));
|
|
489
|
+
}
|
|
314
490
|
break;
|
|
315
491
|
}
|
|
316
492
|
case "custom":
|
|
@@ -323,7 +499,7 @@ export class Tokenizer {
|
|
|
323
499
|
if (block.type === "text" && block.text) {
|
|
324
500
|
fragments.push(block.text);
|
|
325
501
|
} else if (block.type === "image") {
|
|
326
|
-
extra +=
|
|
502
|
+
extra += this.imageTokens(base64ImageSize(block.data), block.detail);
|
|
327
503
|
}
|
|
328
504
|
}
|
|
329
505
|
}
|
|
@@ -354,6 +530,41 @@ export class Tokenizer {
|
|
|
354
530
|
return extra + this.countTokens(fragments);
|
|
355
531
|
}
|
|
356
532
|
|
|
533
|
+
/**
|
|
534
|
+
* Input tokens this reader bills for replaying `message`'s native Responses
|
|
535
|
+
* reasoning; `undefined` when the reader re-encodes the turn from its
|
|
536
|
+
* blocks instead, `null` when it replays but the backend reported no
|
|
537
|
+
* reasoning tokens to price it by. The payload goes back on the wire only
|
|
538
|
+
* to the api, model and provider that produced it (`convertMessages` in
|
|
539
|
+
* pi-ai's Responses providers), and then only what survives replay: a
|
|
540
|
+
* truncated thinking-only turn is dropped, a hidden-empty or reasoning-only
|
|
541
|
+
* payload sanitizes to nothing, and a cold session rebuilds the turn without
|
|
542
|
+
* its reasoning, so those send no reasoning and no signature (0).
|
|
543
|
+
*/
|
|
544
|
+
#replayedReasoningTokens(message: AssistantMessage): number | null | undefined {
|
|
545
|
+
const reader = this.#reasoningReader;
|
|
546
|
+
if (!reader || message.api !== reader.api || message.model !== reader.id) return undefined;
|
|
547
|
+
const items = getOpenAIResponsesHistoryItems(message.providerPayload, reader.provider, message.provider);
|
|
548
|
+
if (!items) return undefined;
|
|
549
|
+
if (!items.some(item => item.type === "reasoning") || shouldDropTruncatedThinkingOnlyAssistant(message)) return 0;
|
|
550
|
+
const replay = sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(items);
|
|
551
|
+
if (!replay?.some(item => item.type === "reasoning")) return 0;
|
|
552
|
+
// Cold, only plaintext reasoning bound to no server state replays natively.
|
|
553
|
+
if (!this.nativeHistoryReplay && !isColdReplayableResponsesTurn(replay)) return 0;
|
|
554
|
+
return message.usage?.reasoningTokens ?? null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Whether a Tokenizer built for `model` with `options` would count every message as this one does. */
|
|
558
|
+
countsAlike(model: TokenizerModel | null | undefined, options?: TokenizerOptions): boolean {
|
|
559
|
+
return (
|
|
560
|
+
tokenizerEncodingForModel(model) === this.#encoding &&
|
|
561
|
+
snapcompact.frameBillingKey(snapcompact.frameBilling(model ?? undefined)) === this.frameBillingKey &&
|
|
562
|
+
reasoningReplayKey(model) === this.reasoningReplayKey &&
|
|
563
|
+
resolveThinkingReplayTokens(model ?? {}) === this.thinkingReplayTokens &&
|
|
564
|
+
(options?.nativeHistoryReplay ?? true) === this.nativeHistoryReplay
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
357
568
|
/** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
|
|
358
569
|
#frameTokens(data: string): number {
|
|
359
570
|
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[];
|