@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/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, estimateImageContentTokens } from "./image-tokens";
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 — 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.
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 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`).
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
- constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
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 estimateImageContentTokens}, the
222
- * same dimension-based estimate the native remote-compaction probe applies.
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
- #measureMessage(message: AgentMessage, excludeEncryptedReasoning: boolean): number {
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 += estimateImageContentTokens(block);
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
- for (const block of message.content) {
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, 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) {
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
- if (!excludeEncryptedReasoning) fragments.push(block.data);
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 += estimateImageContentTokens(block);
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
- | { 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[];