@oh-my-pi/pi-agent-core 18.15.0 → 18.17.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,9 +1,11 @@
1
1
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { renderDemotedThinking } from "@oh-my-pi/pi-ai/dialect/demotion";
3
+ import { isFactoryDroidGeminiWire, keepsFactoryGeminiReasoning } from "@oh-my-pi/pi-ai/providers/factory-droid/gemini";
3
4
  import { ollamaChatDropsOwnThinking } from "@oh-my-pi/pi-ai/providers/ollama";
4
5
  import { completionsDropsOwnThinking, isLiteLLMThinkingSignature } from "@oh-my-pi/pi-ai/providers/openai-completions";
5
6
  import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
6
7
  import {
8
+ dropsForeignReasoningPayload,
7
9
  isSameModelTurn,
8
10
  shouldDropTruncatedThinkingOnlyAssistant,
9
11
  } from "@oh-my-pi/pi-ai/providers/transform-messages";
@@ -22,7 +24,6 @@ import { base64ImageSize, estimateImageTokens, type ImageDetail, type ImageSize
22
24
  import type { AgentMessage } from "./types";
23
25
 
24
26
  const testEnv = Bun.env.NODE_ENV === "test";
25
- const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && !testEnv;
26
27
 
27
28
  const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
28
29
  "claude-v3": natives.Encoding.ClaudeV3,
@@ -33,6 +34,7 @@ const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
33
34
  "deepseek-v3": natives.Encoding.DeepSeekV3,
34
35
  "kimi-k2": natives.Encoding.KimiK2,
35
36
  glm5: natives.Encoding.Glm5,
37
+ cl100k: natives.Encoding.Cl100kBase,
36
38
  o200k: natives.Encoding.O200kBase,
37
39
  };
38
40
 
@@ -42,7 +44,12 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
42
44
  }
43
45
 
44
46
  type TokenizerModel = Pick<Model, "tokenizer"> &
45
- snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"]; reasoning?: boolean };
47
+ snapcompact.ShapeTarget & {
48
+ compat?: Model["compat"];
49
+ thinking?: Model["thinking"];
50
+ reasoning?: boolean;
51
+ requestModelId?: string;
52
+ };
46
53
 
47
54
  /** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
48
55
  interface ReasoningReplayReader {
@@ -77,13 +84,12 @@ export interface TokenizerOptions {
77
84
  }
78
85
 
79
86
  /**
80
- * `strict` always tries an exact native count (the catalog-resolved tokenizer
81
- * when known, o200k_base otherwise), falling back to the byte upper bound when
82
- * the loaded addon does not recognize that encoding. `approximate` and
83
- * `upperbound` prefer the same exact count for known tokenizer families or
84
- * when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
85
- * `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
86
- * undercounts).
87
+ * Every mode counts with the native tokenizer: the catalog-resolved family
88
+ * when known, o200k_base otherwise. They differ only in the fallback when the
89
+ * loaded addon does not recognize that encoding: `strict` and `upperbound`
90
+ * the raw byte length (never undercounts), `approximate` a bytes/4 guess.
91
+ * Tests (`NODE_ENV=test`) skip the native count outside `strict` and use that
92
+ * fallback.
87
93
  */
88
94
  export type TokenCountMode = "strict" | "approximate" | "upperbound";
89
95
 
@@ -205,8 +211,8 @@ interface MessageEstimate {
205
211
  * when the model's encoding, image or thinking-replay pricing, replay
206
212
  * identity or native-history replay state changes); one-shot flows construct
207
213
  * their own for the model that will be billed. Known tokenizer families use
208
- * exact native counts; unknown models keep the fast byte estimate (or o200k
209
- * when `PI_TOKENIZER_ACCURATE=1`).
214
+ * exact native counts; other models are counted in o200k_base, which bills
215
+ * unknown readers closer than a bytes/4 guess.
210
216
  */
211
217
  export class Tokenizer {
212
218
  readonly #encoding: natives.Encoding | null;
@@ -217,6 +223,8 @@ export class Tokenizer {
217
223
  readonly #dropsCompletionsThinking: boolean;
218
224
  /** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
219
225
  readonly #readsDeploymentSignatures: boolean;
226
+ /** Factory's Gemini generate wire, whose replay pass keeps only Google-origin reasoning. */
227
+ readonly #factoryGeminiWire: boolean;
220
228
  readonly reasoningReplayKey: string;
221
229
  /** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
222
230
  readonly thinkingReplayTokens: ThinkingReplayTokens;
@@ -259,6 +267,10 @@ export class Tokenizer {
259
267
  model?.api === "anthropic-messages" &&
260
268
  ((compat !== undefined && "officialEndpoint" in compat && compat.officialEndpoint) ||
261
269
  model.thinking?.prefixBinding === true);
270
+ this.#factoryGeminiWire =
271
+ model?.api === "factory-droid-agent" &&
272
+ model.id !== undefined &&
273
+ isFactoryDroidGeminiWire({ id: model.id, requestModelId: model.requestModelId });
262
274
  this.reasoningReplayKey = reasoningReplayKey(model);
263
275
  this.thinkingReplayTokens = resolveThinkingReplayTokens(model ?? {});
264
276
  this.nativeHistoryReplay = options?.nativeHistoryReplay ?? true;
@@ -269,11 +281,8 @@ export class Tokenizer {
269
281
  }
270
282
 
271
283
  countTokens(text: string | string[], mode: TokenCountMode = "approximate"): number {
272
- if (mode === "strict") return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
273
- if (!testEnv && this.#encoding !== null)
274
- return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
275
- if (accurate) return countTokensNat(text, undefined, mode, this.#nativeCounts).tokens;
276
- return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
284
+ if (testEnv && mode !== "strict") return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
285
+ return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
277
286
  }
278
287
 
279
288
  /**
@@ -365,6 +374,21 @@ export class Tokenizer {
365
374
  );
366
375
  }
367
376
 
377
+ /**
378
+ * Whether `message`'s opaque reasoning payload (thinking signatures, redacted
379
+ * thinking) may reach this reader: everything except what `transformMessages`
380
+ * certainly strips from another model's turn, or what Factory's Gemini wire
381
+ * drops from a turn not of Google origin. Without a replay reader (no
382
+ * model, or a `filterReasoningHistory` target) every payload is charged,
383
+ * which overcounts a filtering reader's foreign turns.
384
+ */
385
+ #receivesReasoningPayload(message: AssistantMessage): boolean {
386
+ const reader = this.#reasoningReader;
387
+ if (reader === undefined) return true;
388
+ if (this.#factoryGeminiWire) return keepsFactoryGeminiReasoning(message);
389
+ return !dropsForeignReasoningPayload(message, reader);
390
+ }
391
+
368
392
  /**
369
393
  * The replayed native Responses reasoning {@link countMessage} charges
370
394
  * across `messages`: the part of a count that changes with the reading
@@ -468,15 +492,17 @@ export class Tokenizer {
468
492
  fragments.push(block.thinking);
469
493
  // Providers charge for the opaque signature/reasoning payload that
470
494
  // rides alongside the thinking text (OpenAI Responses encrypted
471
- // reasoning items, Gemini thought signatures). A reader billing at
472
- // `output` size (Claude) charges the reasoning, priced below, not
473
- // these bytes. The compaction floor excludes it (its local byte size
474
- // diverges from provider billing).
495
+ // reasoning items, Gemini thought signatures) wherever it survives
496
+ // replay; `transformMessages` strips another model's on most wires.
497
+ // A reader billing at `output` size (Claude) charges the reasoning,
498
+ // priced below, not these bytes. The compaction floor excludes it
499
+ // (its local byte size diverges from provider billing).
475
500
  if (
476
501
  block.thinkingSignature &&
477
502
  !excludeEncryptedReasoning &&
478
503
  signaturesPriced &&
479
- typeof replayedReasoning !== "number"
504
+ typeof replayedReasoning !== "number" &&
505
+ this.#receivesReasoningPayload(message)
480
506
  ) {
481
507
  fragments.push(block.thinkingSignature);
482
508
  }
@@ -485,10 +511,13 @@ export class Tokenizer {
485
511
  fragments.push(block.name);
486
512
  fragments.push(stringifyJson(block.arguments) ?? "null");
487
513
  } else if (block.type === "redactedThinking") {
488
- // Encrypted reasoning blob the provider still bills for on replay;
489
- // excluded from the compaction floor for the same reason as above.
514
+ // Encrypted reasoning blob the provider still bills for wherever it
515
+ // survives replay, as above; excluded from the compaction floor for
516
+ // the same reason.
490
517
  signedReasoning = true;
491
- if (!excludeEncryptedReasoning && signaturesPriced) fragments.push(block.data);
518
+ if (!excludeEncryptedReasoning && signaturesPriced && this.#receivesReasoningPayload(message)) {
519
+ fragments.push(block.data);
520
+ }
492
521
  } else if (block.type === "anthropicServerTool") {
493
522
  // Native Anthropic server-tool call/result replayed verbatim on the
494
523
  // wire (server_tool_use input and opaque result content). The provider
package/src/types.ts CHANGED
@@ -48,6 +48,22 @@ export type PrepareQueuedMessages = (
48
48
  queue: QueuedMessageQueue,
49
49
  ) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
50
50
 
51
+ /**
52
+ * Synchronous arbiter over queued messages leaving a queue for delivery, for a
53
+ * queue whose messages someone else may withdraw concurrently (a session homed
54
+ * on another thread whose view pops them).
55
+ */
56
+ export interface QueuedMessageGate {
57
+ /**
58
+ * Called with the batch about to be removed from `queue` for delivery; returns
59
+ * the candidates that must be dropped from the queue instead. The others are
60
+ * claimed for delivery.
61
+ */
62
+ claim(queue: QueuedMessageQueue, candidates: readonly AgentMessage[]): readonly AgentMessage[];
63
+ /** Called with claimed messages that left delivery undelivered (requeued, withdrawn, or cancelled). */
64
+ release(messages: readonly AgentMessage[]): void;
65
+ }
66
+
51
67
  /** Called once an aside has been inserted into the agent's live context. */
52
68
  export const ASIDE_MESSAGE_COMMIT = Symbol("aside-message-commit");
53
69
  /** Symbol-keyed handoff for one finalized, tool-owned stream speculation session. */
@@ -278,6 +294,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
278
294
  */
279
295
  getSteeringMessages?: (signal?: AbortSignal) => Promise<AgentMessage[]>;
280
296
 
297
+ /** Whether input is still owned by a queue and must be restored if a model call is aborted before it starts. */
298
+ isUndeliveredQueuedMessage?: (message: AgentMessage) => boolean;
299
+
281
300
  /**
282
301
  * Peeks whether steering messages are queued, without consuming them.
283
302
  *
@@ -457,6 +476,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
457
476
  * then strips from arguments before executing tools.
458
477
  */
459
478
  intentTracing?: boolean;
479
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
480
+ intentDescription?: string;
460
481
  /**
461
482
  * Strip tool descriptions (top-level + nested schema annotations) from the
462
483
  * provider-bound tool specs. Use when the full catalog is rendered into the
@@ -1,5 +0,0 @@
1
- {{#if files}}
2
- {{#xml "files"}}
3
- {{files}}
4
- {{/xml}}
5
- {{/if}}