@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/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, estimateImageContentTokens } from "./image-tokens";
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 — but
47
- * their *local* byte size can diverge wildly from what the provider
48
- * charges, so the compaction floor (which only needs the reliably-countable,
49
- * on-wire-compressible content) excludes them to avoid false triggers on
50
- * thinking-heavy turns.
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 snapcompact frame pricing changes); one-shot
153
- * flows construct their own for the model that will be billed. Known
154
- * tokenizer families use exact native counts; unknown models keep the fast
155
- * byte estimate (or o200k when `PI_TOKENIZER_ACCURATE=1`).
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
- constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
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 estimateImageContentTokens}, the
222
- * same dimension-based estimate the native remote-compaction probe applies.
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
- #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
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 += estimateImageContentTokens(block);
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
- for (const block of message.content) {
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, Anthropic signed thinking blocks, etc.). Without
291
- // counting it, this estimator can read ~half of the provider-reported
292
- // usage on thinking-heavy turns — see #2275 for the resulting
293
- // compaction-trigger / post-check metric divergence. The compaction
294
- // floor excludes it (its local byte size diverges from provider billing).
295
- if (block.thinkingSignature && !excludeEncryptedReasoning) {
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
- if (!excludeEncryptedReasoning) fragments.push(block.data);
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 += estimateImageContentTokens(block);
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
- | { type: "agent_start" }
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[];