@oh-my-pi/pi-agent-core 16.1.7 → 16.1.9

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.8] - 2026-06-20
6
+
7
+ ### Breaking Changes
8
+
9
+ - Changed `transformProviderContext` and `buildSideRequestContext` to return a Promise
10
+
11
+ ### Added
12
+
13
+ - Added `buildSideRequestContext` to the `Agent` class to build prompt-cache-friendly provider Contexts for side-channels or ephemeral requests.
14
+ - Added `compactionContextTokens(providerContextTokens, storedConversationEstimate)`: floors the provider-reported context tokens by a local estimate of the stored conversation for the compaction decision, so a `before_provider_request` payload transform (a compression extension, obfuscator, or inline snapcompact) that shrinks the request can no longer deflate provider usage below the true history size and suppress auto-compaction.
15
+
16
+ ### Changed
17
+
18
+ - Exported helper functions `normalizeMessagesForProvider` and `resolveOwnedDialectFromEnv` from `packages/agent/src/agent-loop.ts`.
19
+
5
20
  ## [16.1.5] - 2026-06-19
6
21
 
7
22
  ### Fixed
@@ -8,6 +8,7 @@ import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
8
8
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
9
9
  /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
10
10
  export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
11
+ export declare function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined;
11
12
  /**
12
13
  * Start an agent loop with a new prompt message.
13
14
  * The prompt is added to the context and events are emitted for it.
@@ -54,6 +55,7 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
54
55
  readonly stream: EventStream<AgentEvent, AgentMessage[]>;
55
56
  readonly detailed: () => Promise<AgentLoopDetailedResult>;
56
57
  };
58
+ export declare function normalizeMessagesForProvider(messages: Context["messages"], model: AgentLoopConfig["model"]): Context["messages"];
57
59
  export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect, pruneDescriptions?: boolean): Context["tools"];
58
60
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
59
61
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
@@ -22,7 +22,7 @@ export interface AgentOptions {
22
22
  * Optional transform applied after provider context assembly and before
23
23
  * telemetry capture/provider send.
24
24
  */
25
- transformProviderContext?: (context: Context, model: Model) => Context;
25
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
26
26
  /**
27
27
  * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
28
28
  */
@@ -104,6 +104,13 @@ export interface AgentOptions {
104
104
  presencePenalty?: number;
105
105
  repetitionPenalty?: number;
106
106
  serviceTier?: ServiceTier;
107
+ /**
108
+ * Per-call effective service-tier resolver. When set, it authoritatively
109
+ * supplies the request's tier (replacing the static `serviceTier` and its
110
+ * telemetry) per model — used to scope a provider/model into a priority
111
+ * serving path without mutating the shared session `serviceTier`.
112
+ */
113
+ serviceTierResolver?: (model: Model) => ServiceTier | undefined;
107
114
  /**
108
115
  * If true, request that the underlying provider omit reasoning/thinking summaries
109
116
  * from the response. The model still reasons internally; only the human-readable
@@ -299,6 +306,8 @@ export declare class Agent {
299
306
  set repetitionPenalty(value: number | undefined);
300
307
  get serviceTier(): ServiceTier | undefined;
301
308
  set serviceTier(value: ServiceTier | undefined);
309
+ get serviceTierResolver(): ((model: Model) => ServiceTier | undefined) | undefined;
310
+ set serviceTierResolver(value: ((model: Model) => ServiceTier | undefined) | undefined);
302
311
  get hideThinkingSummary(): boolean | undefined;
303
312
  set hideThinkingSummary(value: boolean | undefined);
304
313
  /**
@@ -313,6 +322,15 @@ export declare class Agent {
313
322
  get state(): AgentState;
314
323
  get appendOnlyContext(): AppendOnlyContextManager | undefined;
315
324
  setAppendOnlyContext(manager?: AppendOnlyContextManager): void;
325
+ /**
326
+ * Assemble the provider Context for a side-channel (no-loop) request, mirroring
327
+ * the main loop's prefix (system + normalized tools) so it shares the prompt
328
+ * cache. Never touches the append-only log or the tool-choice queue. Owned/
329
+ * in-band dialect sessions stay tools-less (matching their no-native-tools wire
330
+ * shape and avoiding tool-markup leakage). `llmMessages` is already converted
331
+ * (and, in production, obfuscated) by the caller.
332
+ */
333
+ buildSideRequestContext(llmMessages: Message[]): Promise<Context>;
316
334
  subscribe(fn: (e: AgentEvent) => void): () => void;
317
335
  setProviderResponseInterceptor(fn: SimpleStreamOptions["onResponse"] | undefined): void;
318
336
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
@@ -58,13 +58,38 @@ export declare function effectiveReserveTokens(contextWindow: number, settings:
58
58
  * Check if compaction should trigger based on context usage.
59
59
  */
60
60
  export declare function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean;
61
+ /**
62
+ * Context tokens to feed the compaction decision, floored by a local estimate of
63
+ * the stored conversation.
64
+ *
65
+ * The provider-reported usage is normally ground truth, but a
66
+ * `before_provider_request` payload transform — a compression extension (e.g.
67
+ * Headroom), an obfuscator, or inline snapcompact — can shrink the request below
68
+ * the real stored conversation. The provider then reports deflated prompt
69
+ * tokens, so anchoring compaction purely on that usage lets the real history
70
+ * grow unbounded until it overflows and native compaction can no longer run.
71
+ * Flooring by the agent's own estimate of the stored conversation keeps the
72
+ * compaction trigger honest regardless of on-wire compression. (Display/cost
73
+ * accounting still uses the exact provider usage; only the compaction decision
74
+ * takes the floor.)
75
+ */
76
+ export declare function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number;
61
77
  export declare function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number;
62
78
  /**
63
79
  * Estimate token count for a message using cl100k_base via the native
64
80
  * tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
65
81
  * publish one) but is within ~5–10% across English/code text.
82
+ *
83
+ * `excludeEncryptedReasoning` drops opaque provider reasoning payloads
84
+ * (`thinkingSignature`, `redactedThinking`) from the estimate. Those are billed
85
+ * by the provider on replay, so the default counts them — but their *local*
86
+ * byte size can diverge wildly from what the provider charges, so the
87
+ * compaction floor (which only needs the reliably-countable, on-wire-compressible
88
+ * content) excludes them to avoid false triggers on thinking-heavy turns.
66
89
  */
67
- export declare function estimateTokens(message: AgentMessage): number;
90
+ export declare function estimateTokens(message: AgentMessage, options?: {
91
+ excludeEncryptedReasoning?: boolean;
92
+ }): number;
68
93
  /**
69
94
  * Find the user message (or bashExecution) that starts the turn containing the given entry index.
70
95
  * Returns -1 if no turn start found before the index.
@@ -1,4 +1,4 @@
1
- import type { ApiKey, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
1
+ import type { ApiKey, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, ServiceTier, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
2
2
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
@@ -119,7 +119,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
119
119
  * normalization, and append-only context handling, but before telemetry capture
120
120
  * and provider send.
121
121
  */
122
- transformProviderContext?: (context: Context, model: Model) => Context;
122
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
123
123
  /**
124
124
  * Resolves the API key or resolver for the current model before each LLM call.
125
125
  *
@@ -266,6 +266,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
266
266
  * to the next provider call.
267
267
  */
268
268
  getDisableReasoning?: () => boolean | undefined;
269
+ /**
270
+ * Per-call effective service-tier resolver. Unlike {@link getReasoning},
271
+ * this is *authoritative*: when set, its return value (including
272
+ * `undefined`) fully replaces the static `serviceTier` for the request and
273
+ * its telemetry. The resolver receives the model being requested so the
274
+ * caller can scope the tier per provider/model without mutating the shared
275
+ * session `serviceTier` (e.g. opting a Fireworks model into the Priority
276
+ * serving path while leaving the OpenAI/Anthropic tier untouched).
277
+ */
278
+ getServiceTier?: (model: Model) => ServiceTier | undefined;
269
279
  /**
270
280
  * Called after a tool call has been validated and is about to execute.
271
281
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "16.1.7",
4
+ "version": "16.1.9",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.1.7",
39
- "@oh-my-pi/pi-catalog": "16.1.7",
40
- "@oh-my-pi/pi-natives": "16.1.7",
41
- "@oh-my-pi/pi-utils": "16.1.7",
42
- "@oh-my-pi/pi-wire": "16.1.7",
43
- "@oh-my-pi/snapcompact": "16.1.7",
38
+ "@oh-my-pi/pi-ai": "16.1.9",
39
+ "@oh-my-pi/pi-catalog": "16.1.9",
40
+ "@oh-my-pi/pi-natives": "16.1.9",
41
+ "@oh-my-pi/pi-utils": "16.1.9",
42
+ "@oh-my-pi/pi-wire": "16.1.9",
43
+ "@oh-my-pi/snapcompact": "16.1.9",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -121,7 +121,7 @@ class HarmonyLeakInterruption extends Error {
121
121
  this.name = "HarmonyLeakInterruption";
122
122
  }
123
123
  }
124
- function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
124
+ export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
125
125
  switch (value) {
126
126
  case "1":
127
127
  case "true":
@@ -502,7 +502,7 @@ function createDetailedCapture(config: AgentLoopConfig): {
502
502
  };
503
503
  }
504
504
 
505
- function normalizeMessagesForProvider(
505
+ export function normalizeMessagesForProvider(
506
506
  messages: Context["messages"],
507
507
  model: AgentLoopConfig["model"],
508
508
  ): Context["messages"] {
@@ -1161,7 +1161,7 @@ async function streamAssistantResponse(
1161
1161
  };
1162
1162
  }
1163
1163
  if (config.transformProviderContext) {
1164
- llmContext = config.transformProviderContext(llmContext, config.model);
1164
+ llmContext = await config.transformProviderContext(llmContext, config.model);
1165
1165
  }
1166
1166
 
1167
1167
  // Owned tool calling: take tool calls away from the provider and run them
@@ -1182,6 +1182,10 @@ async function streamAssistantResponse(
1182
1182
 
1183
1183
  const dynamicReasoning = config.getReasoning?.();
1184
1184
  const dynamicDisableReasoning = config.getDisableReasoning?.();
1185
+ // `getServiceTier` is authoritative when present (replaces the static tier
1186
+ // for both the wire request and telemetry), so callers can scope priority
1187
+ // per model without touching the shared session `serviceTier`.
1188
+ const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(config.model) : config.serviceTier;
1185
1189
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1186
1190
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1187
1191
  const requestSignal = harmonyAbortController
@@ -1229,7 +1233,7 @@ async function streamAssistantResponse(
1229
1233
  topP: config.topP,
1230
1234
  topK: config.topK,
1231
1235
  presencePenalty: config.presencePenalty,
1232
- serviceTier: config.serviceTier,
1236
+ serviceTier: effectiveServiceTier,
1233
1237
  reasoningEffort: typeof effectiveReasoning === "string" ? effectiveReasoning : undefined,
1234
1238
  toolChoice: effectiveToolChoice,
1235
1239
  tools: llmContext.tools,
@@ -1251,7 +1255,7 @@ async function streamAssistantResponse(
1251
1255
  const finishChat = async (message: AssistantMessage): Promise<void> => {
1252
1256
  await finishChatSpan(telemetry, chatSpan, message, {
1253
1257
  stepNumber: chatStepNumber,
1254
- serviceTier: config.serviceTier,
1258
+ serviceTier: effectiveServiceTier,
1255
1259
  responseHeaders: capturedHeaders,
1256
1260
  baseUrl: config.model.baseUrl,
1257
1261
  });
@@ -1267,6 +1271,7 @@ async function streamAssistantResponse(
1267
1271
  reasoning: effectiveReasoning,
1268
1272
  disableReasoning: effectiveDisableReasoning,
1269
1273
  temperature: effectiveTemperature,
1274
+ serviceTier: effectiveServiceTier,
1270
1275
  signal: finalRequestSignal,
1271
1276
  onResponse: captureOnResponse,
1272
1277
  });
package/src/agent.ts CHANGED
@@ -24,9 +24,17 @@ import {
24
24
  } from "@oh-my-pi/pi-ai";
25
25
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
27
28
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
28
29
  import { logger } from "@oh-my-pi/pi-utils";
29
- import { abortReasonText, agentLoop, agentLoopContinue } from "./agent-loop";
30
+ import {
31
+ abortReasonText,
32
+ agentLoop,
33
+ agentLoopContinue,
34
+ normalizeMessagesForProvider,
35
+ normalizeTools,
36
+ resolveOwnedDialectFromEnv,
37
+ } from "./agent-loop";
30
38
  import type { AppendOnlyContextManager } from "./append-only-context";
31
39
  import type {
32
40
  AgentContext,
@@ -102,7 +110,7 @@ export interface AgentOptions {
102
110
  * Optional transform applied after provider context assembly and before
103
111
  * telemetry capture/provider send.
104
112
  */
105
- transformProviderContext?: (context: Context, model: Model) => Context;
113
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
106
114
 
107
115
  /**
108
116
  * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
@@ -196,6 +204,13 @@ export interface AgentOptions {
196
204
  presencePenalty?: number;
197
205
  repetitionPenalty?: number;
198
206
  serviceTier?: ServiceTier;
207
+ /**
208
+ * Per-call effective service-tier resolver. When set, it authoritatively
209
+ * supplies the request's tier (replacing the static `serviceTier` and its
210
+ * telemetry) per model — used to scope a provider/model into a priority
211
+ * serving path without mutating the shared session `serviceTier`.
212
+ */
213
+ serviceTierResolver?: (model: Model) => ServiceTier | undefined;
199
214
  /**
200
215
  * If true, request that the underlying provider omit reasoning/thinking summaries
201
216
  * from the response. The model still reasons internally; only the human-readable
@@ -313,7 +328,7 @@ export class Agent {
313
328
  #abortController?: AbortController;
314
329
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
315
330
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
316
- #transformProviderContext?: (context: Context, model: Model) => Context;
331
+ #transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
317
332
  #steeringQueue: AgentMessage[] = [];
318
333
  #followUpQueue: AgentMessage[] = [];
319
334
  #steeringMode: "all" | "one-at-a-time";
@@ -333,6 +348,7 @@ export class Agent {
333
348
  #presencePenalty?: number;
334
349
  #repetitionPenalty?: number;
335
350
  #serviceTier?: ServiceTier;
351
+ #serviceTierResolver?: (model: Model) => ServiceTier | undefined;
336
352
  #hideThinkingSummary?: boolean;
337
353
  #maxRetryDelayMs?: number;
338
354
  #getToolContext?: (toolCall?: ToolCallContext) => AgentToolContext | undefined;
@@ -403,6 +419,7 @@ export class Agent {
403
419
  this.#presencePenalty = opts.presencePenalty;
404
420
  this.#repetitionPenalty = opts.repetitionPenalty;
405
421
  this.#serviceTier = opts.serviceTier;
422
+ this.#serviceTierResolver = opts.serviceTierResolver;
406
423
  this.#hideThinkingSummary = opts.hideThinkingSummary;
407
424
  this.#maxRetryDelayMs = opts.maxRetryDelayMs;
408
425
  this.getApiKey = opts.getApiKey;
@@ -610,6 +627,14 @@ export class Agent {
610
627
  this.#serviceTier = value;
611
628
  }
612
629
 
630
+ get serviceTierResolver(): ((model: Model) => ServiceTier | undefined) | undefined {
631
+ return this.#serviceTierResolver;
632
+ }
633
+
634
+ set serviceTierResolver(value: ((model: Model) => ServiceTier | undefined) | undefined) {
635
+ this.#serviceTierResolver = value;
636
+ }
637
+
613
638
  get hideThinkingSummary(): boolean | undefined {
614
639
  return this.#hideThinkingSummary;
615
640
  }
@@ -645,6 +670,32 @@ export class Agent {
645
670
  this.#appendOnlyContext = manager;
646
671
  }
647
672
 
673
+ /**
674
+ * Assemble the provider Context for a side-channel (no-loop) request, mirroring
675
+ * the main loop's prefix (system + normalized tools) so it shares the prompt
676
+ * cache. Never touches the append-only log or the tool-choice queue. Owned/
677
+ * in-band dialect sessions stay tools-less (matching their no-native-tools wire
678
+ * shape and avoiding tool-markup leakage). `llmMessages` is already converted
679
+ * (and, in production, obfuscated) by the caller.
680
+ */
681
+ async buildSideRequestContext(llmMessages: Message[]): Promise<Context> {
682
+ const model = this.#state.model;
683
+ if (!model) throw new Error("No active model on agent");
684
+ const ownedDialect = this.#dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
685
+ const messages = normalizeMessagesForProvider(llmMessages, model);
686
+ const tools = ownedDialect
687
+ ? []
688
+ : (normalizeTools(
689
+ this.#state.tools,
690
+ this.#intentTracing,
691
+ preferredDialect(model.id),
692
+ this.#pruneToolDescriptions,
693
+ ) ?? []);
694
+ let context: Context = { systemPrompt: this.#state.systemPrompt, messages, tools };
695
+ if (this.#transformProviderContext) context = await this.#transformProviderContext(context, model);
696
+ return context;
697
+ }
698
+
648
699
  subscribe(fn: (e: AgentEvent) => void): () => void {
649
700
  this.#listeners.add(fn);
650
701
  return () => this.#listeners.delete(fn);
@@ -1087,6 +1138,7 @@ export class Agent {
1087
1138
  getToolChoice,
1088
1139
  getReasoning: () => this.#state.thinkingLevel,
1089
1140
  getDisableReasoning: () => this.#state.disableReasoning,
1141
+ getServiceTier: this.#serviceTierResolver,
1090
1142
  getSteeringMessages: async () => {
1091
1143
  if (skipInitialSteeringPoll) {
1092
1144
  skipInitialSteeringPoll = false;
@@ -228,6 +228,25 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
228
228
  return contextTokens > thresholdTokens;
229
229
  }
230
230
 
231
+ /**
232
+ * Context tokens to feed the compaction decision, floored by a local estimate of
233
+ * the stored conversation.
234
+ *
235
+ * The provider-reported usage is normally ground truth, but a
236
+ * `before_provider_request` payload transform — a compression extension (e.g.
237
+ * Headroom), an obfuscator, or inline snapcompact — can shrink the request below
238
+ * the real stored conversation. The provider then reports deflated prompt
239
+ * tokens, so anchoring compaction purely on that usage lets the real history
240
+ * grow unbounded until it overflows and native compaction can no longer run.
241
+ * Flooring by the agent's own estimate of the stored conversation keeps the
242
+ * compaction trigger honest regardless of on-wire compression. (Display/cost
243
+ * accounting still uses the exact provider usage; only the compaction decision
244
+ * takes the floor.)
245
+ */
246
+ export function compactionContextTokens(providerContextTokens: number, storedConversationEstimate: number): number {
247
+ return Math.max(Math.max(0, providerContextTokens), Math.max(0, storedConversationEstimate));
248
+ }
249
+
231
250
  export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
232
251
  // Fixed token limit takes priority over percentage
233
252
  const thresholdTokens = settings.thresholdTokens;
@@ -259,8 +278,15 @@ const IMAGE_TOKEN_ESTIMATE = 1200;
259
278
  * Estimate token count for a message using cl100k_base via the native
260
279
  * tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
261
280
  * publish one) but is within ~5–10% across English/code text.
281
+ *
282
+ * `excludeEncryptedReasoning` drops opaque provider reasoning payloads
283
+ * (`thinkingSignature`, `redactedThinking`) from the estimate. Those are billed
284
+ * by the provider on replay, so the default counts them — but their *local*
285
+ * byte size can diverge wildly from what the provider charges, so the
286
+ * compaction floor (which only needs the reliably-countable, on-wire-compressible
287
+ * content) excludes them to avoid false triggers on thinking-heavy turns.
262
288
  */
263
- export function estimateTokens(message: AgentMessage): number {
289
+ export function estimateTokens(message: AgentMessage, options?: { excludeEncryptedReasoning?: boolean }): number {
264
290
  const fragments: string[] = [];
265
291
  let extra = 0;
266
292
  if ((message as { role?: string }).role === "bashExecution") {
@@ -296,14 +322,18 @@ export function estimateTokens(message: AgentMessage): number {
296
322
  // reasoning items, Anthropic signed thinking blocks, etc.). Without
297
323
  // counting it, this estimator can read ~half of the provider-reported
298
324
  // usage on thinking-heavy turns — see #2275 for the resulting
299
- // compaction-trigger / post-check metric divergence.
300
- if (block.thinkingSignature) fragments.push(block.thinkingSignature);
325
+ // compaction-trigger / post-check metric divergence. The compaction
326
+ // floor excludes it (its local byte size diverges from provider billing).
327
+ if (block.thinkingSignature && !options?.excludeEncryptedReasoning) {
328
+ fragments.push(block.thinkingSignature);
329
+ }
301
330
  } else if (block.type === "toolCall") {
302
331
  fragments.push(block.name);
303
332
  fragments.push(JSON.stringify(block.arguments));
304
333
  } else if (block.type === "redactedThinking") {
305
- // Encrypted reasoning blob the provider still bills for on replay.
306
- fragments.push(block.data);
334
+ // Encrypted reasoning blob the provider still bills for on replay;
335
+ // excluded from the compaction floor for the same reason as above.
336
+ if (!options?.excludeEncryptedReasoning) fragments.push(block.data);
307
337
  }
308
338
  }
309
339
  break;
package/src/types.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  ImageContent,
9
9
  Message,
10
10
  Model,
11
+ ServiceTier,
11
12
  SimpleStreamOptions,
12
13
  Static,
13
14
  streamSimple,
@@ -154,7 +155,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
154
155
  * normalization, and append-only context handling, but before telemetry capture
155
156
  * and provider send.
156
157
  */
157
- transformProviderContext?: (context: Context, model: Model) => Context;
158
+ transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
158
159
 
159
160
  /**
160
161
  * Resolves the API key or resolver for the current model before each LLM call.
@@ -315,6 +316,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
315
316
  */
316
317
  getDisableReasoning?: () => boolean | undefined;
317
318
 
319
+ /**
320
+ * Per-call effective service-tier resolver. Unlike {@link getReasoning},
321
+ * this is *authoritative*: when set, its return value (including
322
+ * `undefined`) fully replaces the static `serviceTier` for the request and
323
+ * its telemetry. The resolver receives the model being requested so the
324
+ * caller can scope the tier per provider/model without mutating the shared
325
+ * session `serviceTier` (e.g. opting a Fireworks model into the Priority
326
+ * serving path while leaving the OpenAI/Anthropic tier untouched).
327
+ */
328
+ getServiceTier?: (model: Model) => ServiceTier | undefined;
329
+
318
330
  /**
319
331
  * Called after a tool call has been validated and is about to execute.
320
332
  *