@linxiraos/pi-agent-core 1.1.26 → 1.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +6 -243
  2. package/README.md +1 -1
  3. package/dist/types/agent.d.ts +27 -7
  4. package/dist/types/compaction/anthropic.d.ts +14 -8
  5. package/dist/types/compaction/branch-summarization.d.ts +1 -1
  6. package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
  7. package/dist/types/compaction/compaction.d.ts +24 -2
  8. package/dist/types/compaction/entries.d.ts +2 -2
  9. package/dist/types/compaction/messages.d.ts +6 -0
  10. package/dist/types/compaction/openai.d.ts +14 -2
  11. package/dist/types/compaction/pruning.d.ts +64 -16
  12. package/dist/types/compaction/tool-protection.d.ts +2 -1
  13. package/dist/types/compaction/utils.d.ts +5 -0
  14. package/dist/types/image-tokens.d.ts +29 -0
  15. package/dist/types/index.d.ts +1 -0
  16. package/dist/types/speculative-execution.d.ts +1 -1
  17. package/dist/types/telemetry.d.ts +70 -60
  18. package/dist/types/tokenizer.d.ts +9 -6
  19. package/dist/types/tool-arguments.d.ts +19 -0
  20. package/dist/types/types.d.ts +12 -1
  21. package/package.json +8 -8
  22. package/src/agent-loop.ts +191 -34
  23. package/src/agent.ts +126 -21
  24. package/src/append-only-context.ts +38 -29
  25. package/src/compaction/anthropic.ts +30 -12
  26. package/src/compaction/branch-summarization.ts +1 -1
  27. package/src/compaction/compaction-v2-streaming.ts +23 -16
  28. package/src/compaction/compaction.ts +163 -73
  29. package/src/compaction/entries.ts +2 -2
  30. package/src/compaction/message-cache.ts +1 -1
  31. package/src/compaction/messages.ts +25 -0
  32. package/src/compaction/openai.ts +65 -28
  33. package/src/compaction/pruning.ts +328 -96
  34. package/src/compaction/tool-protection.ts +8 -2
  35. package/src/compaction/utils.ts +9 -0
  36. package/src/image-tokens.ts +77 -0
  37. package/src/index.ts +2 -0
  38. package/src/prompts/dsml-tool-call-leak.md +19 -0
  39. package/src/speculative-execution.ts +5 -9
  40. package/src/telemetry.ts +100 -81
  41. package/src/tokenizer.ts +24 -17
  42. package/src/tool-arguments.ts +38 -0
  43. package/src/types.ts +12 -1
@@ -0,0 +1,77 @@
1
+ import type { ImageContent } from "@linxiraos/pi-ai";
2
+ import {
3
+ type ImageSize,
4
+ type ImageTokenization,
5
+ imageTokens,
6
+ resolveImageTokenization,
7
+ } from "@linxiraos/pi-catalog/compat/image-tokenization";
8
+ import { parseImageMetadata } from "@linxiraos/pi-utils";
9
+
10
+ /**
11
+ * Dimension-based image token estimates shared by the local context counter
12
+ * ({@link Tokenizer.countMessage}) and the native remote-compaction fit probe.
13
+ * Both sides must price the same image the same way: when they disagreed
14
+ * (1.2k flat locally vs a 12k flat worst case remotely) an image-heavy session
15
+ * stayed under the local compaction trigger yet was refused as over-window by
16
+ * the remote probe.
17
+ *
18
+ * Follows the catalog's image rule for the OpenAI Responses wire (GPT-5.5's
19
+ * 32px patches, a per-detail pixel limit and patch budget, x1.2 multiplier),
20
+ * which is also a close upper estimate for other providers once omp has
21
+ * downscaled the image (≤1568px by default).
22
+ */
23
+
24
+ /** OpenAI image `detail` values; `undefined` means the provider default (`auto`). */
25
+ export type ImageDetail = ImageContent["detail"];
26
+
27
+ export type { ImageSize };
28
+
29
+ // Larger than every detail level's pixel limit, so an unknown size charges
30
+ // the level's full patch budget.
31
+ const UNKNOWN_SIZE: ImageSize = { width: 65_535, height: 65_535 };
32
+
33
+ let wireRule: ImageTokenization | undefined;
34
+
35
+ /** The OpenAI Responses wire's image rule, resolved from the catalog once. */
36
+ function openAiWireRule(): ImageTokenization {
37
+ if (wireRule) return wireRule;
38
+ const rule = resolveImageTokenization({ api: "openai-responses" });
39
+ if (!rule) throw new Error("The catalog has no image-tokenization rule for the openai-responses wire");
40
+ wireRule = rule;
41
+ return rule;
42
+ }
43
+
44
+ /**
45
+ * Estimated input tokens for one image. Unknown dimensions (undecodable data,
46
+ * remote URLs, provider file ids) charge the detail level's full patch budget.
47
+ */
48
+ export function estimateImageTokens(size: ImageSize | null | undefined, detail?: ImageDetail): number {
49
+ return imageTokens(openAiWireRule(), size && size.width > 0 && size.height > 0 ? size : UNKNOWN_SIZE, detail);
50
+ }
51
+
52
+ // Enough decoded bytes to reach a JPEG SOF marker behind typical EXIF/ICC
53
+ // segments; PNG/GIF/WebP need only the first 30 bytes. Multiple of 4 so the
54
+ // base64 slice decodes cleanly.
55
+ const HEADER_BASE64_CHARS = 4 * Math.ceil((64 * 1024) / 3);
56
+
57
+ /** Pixel size read from the header of base64-encoded image data, or null. */
58
+ export function base64ImageSize(base64: string): ImageSize | null {
59
+ if (base64.length === 0) return null;
60
+ const header = Buffer.from(base64.slice(0, HEADER_BASE64_CHARS), "base64");
61
+ const metadata = parseImageMetadata(header);
62
+ if (!metadata?.width || !metadata.height) return null;
63
+ return { width: metadata.width, height: metadata.height };
64
+ }
65
+
66
+ /** Pixel size of a `data:<mime>;base64,` URL, or null for any other URL. */
67
+ export function dataUrlImageSize(url: string): ImageSize | null {
68
+ if (!url.startsWith("data:")) return null;
69
+ const comma = url.indexOf(",");
70
+ if (comma < 0 || !url.slice(0, comma).endsWith(";base64")) return null;
71
+ return base64ImageSize(url.slice(comma + 1));
72
+ }
73
+
74
+ /** {@link estimateImageTokens} for an in-memory image block. */
75
+ export function estimateImageContentTokens(image: Pick<ImageContent, "data" | "detail">): number {
76
+ return estimateImageTokens(base64ImageSize(image.data), image.detail);
77
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,8 @@ export * from "./thinking";
28
28
  export * from "./tool-context";
29
29
  // Tokenizer choice
30
30
  export * from "./tokenizer";
31
+ // Lenient-aware tool argument validation
32
+ export * from "./tool-arguments";
31
33
  // Types
32
34
  export * from "./types";
33
35
  // Yield utilities for Bun event-loop busy-wait prevention
@@ -0,0 +1,19 @@
1
+ Tool call failed: your previous response tried to invoke a tool, but the DSML markup was malformed, so it arrived as plain text, no tool was executed, and the broken markup was removed from your response. Usually the opening `<|DSML|tool_calls>` and/or `<|DSML|invoke name="…">` tags are missing, or the tool name was written as a plain line.
2
+
3
+ A tool call must be one complete envelope:
4
+
5
+ ```
6
+ <|DSML|tool_calls>
7
+ <|DSML|invoke name="TOOL_NAME">
8
+ <|DSML|parameter name="ARG_NAME" string="true">text value</|DSML|parameter>
9
+ <|DSML|parameter name="OTHER_ARG" string="false">{"json": "value"}</|DSML|parameter>
10
+ </|DSML|invoke>
11
+ </|DSML|tool_calls>
12
+ ```
13
+
14
+ - `<|DSML|tool_calls>` wraps every call; put one `<|DSML|invoke>` per call inside it.
15
+ - The tool name goes in the `name` attribute of `<|DSML|invoke>`, never on its own line.
16
+ - Use `string="true"` for raw text values and `string="false"` for numbers, booleans, arrays, and objects (JSON).
17
+ - Close every tag.
18
+
19
+ Re-issue the intended call now in this shape, or reply in plain prose if no tool is needed.
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
- import { type AssistantMessage, validateToolArguments } from "@linxiraos/pi-ai";
2
+ import type { AssistantMessage } from "@linxiraos/pi-ai";
3
+ import { validateAgentToolArguments } from "./tool-arguments";
3
4
  import type {
4
5
  AgentContext,
5
6
  AgentLoopConfig,
@@ -712,15 +713,10 @@ export class SpeculativeOperationCoordinator {
712
713
  }
713
714
  let validatedArgs: Record<string, unknown>;
714
715
  try {
715
- validatedArgs = validateToolArguments(tool, toolCall);
716
+ validatedArgs = validateAgentToolArguments(tool, toolCall);
716
717
  } catch {
717
- // Lenience covers schema mismatches; a parse failure has no args to hand over.
718
- if (!tool.lenientArgValidation || "__parseError" in toolCall.arguments) {
719
- this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
720
- return undefined;
721
- }
722
- validatedArgs = { ...toolCall.arguments };
723
- delete validatedArgs.__rawJson;
718
+ this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
719
+ return undefined;
724
720
  }
725
721
  let executionArgs: Record<string, unknown>;
726
722
  try {
package/src/telemetry.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * OpenTelemetry instrumentation for the agent loop.
3
3
  *
4
4
  * Implements the OpenTelemetry GenAI semantic conventions
5
- * (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `omp.gen_ai.*`
5
+ * (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `zeta.gen_ai.*`
6
6
  * extension attributes for run summaries, dashboard summaries, and cost hints
7
7
  * that are useful to downstream observability UIs.
8
8
  *
@@ -126,47 +126,47 @@ export const enum OpenAIAttr {
126
126
 
127
127
  /** Project extension attributes. Kept out of the reserved `gen_ai.*` namespace. */
128
128
  export const enum OmpGenAIAttr {
129
- AgentStepNumber = "omp.gen_ai.agent.step.number",
130
- AgentStepCount = "omp.gen_ai.agent.step.count",
131
- RequestReasoningEffort = "omp.gen_ai.request.reasoning.effort",
132
- RequestToolChoice = "omp.gen_ai.request.tool.choice",
133
- RequestAvailableTools = "omp.gen_ai.request.available_tools",
134
- RequestMessages = "omp.gen_ai.request.messages",
135
- ResponseText = "omp.gen_ai.response.text",
136
- ResponseToolCalls = "omp.gen_ai.response.tool_calls",
137
- ResponseUpstreamProvider = "omp.gen_ai.response.upstream_provider",
138
- UsageTotalTokens = "omp.gen_ai.usage.total_tokens",
139
- UsageServerSideTools = "omp.gen_ai.usage.server_tool_requests",
140
- CostEstimatedUsd = "omp.gen_ai.cost.estimated_usd",
141
- CostInputUsd = "omp.gen_ai.cost.input_usd",
142
- CostOutputUsd = "omp.gen_ai.cost.output_usd",
143
- CostUnavailableReason = "omp.gen_ai.cost.unavailable_reason",
144
- ToolStatus = "omp.gen_ai.tool.status",
145
- ToolCallIntent = "omp.gen_ai.tool.call.intent",
146
- HandoffFromAgentName = "omp.gen_ai.handoff.from_agent.name",
147
- HandoffFromAgentId = "omp.gen_ai.handoff.from_agent.id",
148
- HandoffToAgentName = "omp.gen_ai.handoff.to_agent.name",
149
- HandoffToAgentId = "omp.gen_ai.handoff.to_agent.id",
129
+ AgentStepNumber = "zeta.gen_ai.agent.step.number",
130
+ AgentStepCount = "zeta.gen_ai.agent.step.count",
131
+ RequestReasoningEffort = "zeta.gen_ai.request.reasoning.effort",
132
+ RequestToolChoice = "zeta.gen_ai.request.tool.choice",
133
+ RequestAvailableTools = "zeta.gen_ai.request.available_tools",
134
+ RequestMessages = "zeta.gen_ai.request.messages",
135
+ ResponseText = "zeta.gen_ai.response.text",
136
+ ResponseToolCalls = "zeta.gen_ai.response.tool_calls",
137
+ ResponseUpstreamProvider = "zeta.gen_ai.response.upstream_provider",
138
+ UsageTotalTokens = "zeta.gen_ai.usage.total_tokens",
139
+ UsageServerSideTools = "zeta.gen_ai.usage.server_tool_requests",
140
+ CostEstimatedUsd = "zeta.gen_ai.cost.estimated_usd",
141
+ CostInputUsd = "zeta.gen_ai.cost.input_usd",
142
+ CostOutputUsd = "zeta.gen_ai.cost.output_usd",
143
+ CostUnavailableReason = "zeta.gen_ai.cost.unavailable_reason",
144
+ ToolStatus = "zeta.gen_ai.tool.status",
145
+ ToolCallIntent = "zeta.gen_ai.tool.call.intent",
146
+ HandoffFromAgentName = "zeta.gen_ai.handoff.from_agent.name",
147
+ HandoffFromAgentId = "zeta.gen_ai.handoff.from_agent.id",
148
+ HandoffToAgentName = "zeta.gen_ai.handoff.to_agent.name",
149
+ HandoffToAgentId = "zeta.gen_ai.handoff.to_agent.id",
150
150
  // Marks chat spans emitted outside the agent loop (compaction, handoff, branch
151
151
  // summary, image inspection, …). Lets dashboards split oneshot cost / latency
152
152
  // from main-turn cost without overloading the semconv `gen_ai.operation.name`.
153
- OneshotKind = "omp.gen_ai.oneshot.kind",
153
+ OneshotKind = "zeta.gen_ai.oneshot.kind",
154
154
  // Gateway / proxy (LiteLLM, Helicone, Portkey, …) — populated when a known
155
155
  // gateway header pattern is detected on the upstream response. The base
156
156
  // `gen_ai.provider.name` continues to track the *upstream* provider (e.g.
157
157
  // `anthropic`) that the gateway routed to.
158
- GatewayName = "omp.gen_ai.gateway.name",
159
- GatewayEndpoint = "omp.gen_ai.gateway.endpoint",
160
- GatewayCallId = "omp.gen_ai.gateway.call_id",
161
- GatewayRoutedTo = "omp.gen_ai.gateway.routed_to",
158
+ GatewayName = "zeta.gen_ai.gateway.name",
159
+ GatewayEndpoint = "zeta.gen_ai.gateway.endpoint",
160
+ GatewayCallId = "zeta.gen_ai.gateway.call_id",
161
+ GatewayRoutedTo = "zeta.gen_ai.gateway.routed_to",
162
162
  /** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
163
- GatewayResponseCacheStatus = "omp.gen_ai.gateway.response_cache.status",
163
+ GatewayResponseCacheStatus = "zeta.gen_ai.gateway.response_cache.status",
164
164
  /** Caller-level reason a judgment ran (`find`, `ttsr`, `judge_batch`, …). */
165
- JudgmentPurpose = "omp.gen_ai.judgment.purpose",
165
+ JudgmentPurpose = "zeta.gen_ai.judgment.purpose",
166
166
  /** Questions the caller asked in one judgment request. */
167
- JudgmentQuestions = "omp.gen_ai.judgment.questions",
167
+ JudgmentQuestions = "zeta.gen_ai.judgment.questions",
168
168
  /** Questions answered from the local judgment cache instead of the provider. */
169
- JudgmentCachedQuestions = "omp.gen_ai.judgment.cached_questions",
169
+ JudgmentCachedQuestions = "zeta.gen_ai.judgment.cached_questions",
170
170
  }
171
171
 
172
172
  /** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
@@ -204,17 +204,25 @@ export interface ChatUsageSnapshot {
204
204
 
205
205
  /** Context passed to the cost estimator. */
206
206
  export interface CostEstimatorContext {
207
+ /** OTel provider label (`gen_ai.provider.name`). */
207
208
  readonly provider: string;
209
+ /** Provider id before OTel normalization (e.g. `openai-codex` for the `openai` label). */
210
+ readonly providerId: string | undefined;
211
+ /** Model id reported on the response; a provider may report a served model instead of the requested one. */
208
212
  readonly model: string;
213
+ /** Requested model id, when the caller knows it. */
214
+ readonly modelId: string | undefined;
209
215
  readonly serviceTier: ServiceTier | undefined;
210
216
  readonly usage: ChatUsageSnapshot;
217
+ /** Cost the provider already computed for this request; the value persisted with the message. */
218
+ readonly usageCost: Usage["cost"];
211
219
  }
212
220
 
213
221
  /**
214
222
  * Cost estimator result.
215
- * { usd: number } — cost is known; emitted as omp.gen_ai.cost.estimated_usd
223
+ * { usd: number } — cost is known; emitted as zeta.gen_ai.cost.estimated_usd
216
224
  * { unavailable: string } — cost is intentionally unknown; emitted as
217
- * omp.gen_ai.cost.unavailable_reason
225
+ * zeta.gen_ai.cost.unavailable_reason
218
226
  * undefined — no opinion; nothing emitted
219
227
  */
220
228
  export type CostEstimate =
@@ -263,7 +271,7 @@ export interface ChatUsageEvent {
263
271
  *
264
272
  * Use this to reconcile gateway-issued ids (e.g. `x-litellm-call-id`) with
265
273
  * downstream billing / spend dashboards. Known gateway patterns are also
266
- * auto-stamped on the chat span as `omp.gen_ai.gateway.*` attributes.
274
+ * auto-stamped on the chat span as `zeta.gen_ai.gateway.*` attributes.
267
275
  */
268
276
  readonly headers: Readonly<Record<string, string>> | undefined;
269
277
  }
@@ -828,15 +836,27 @@ function serializeRequestMessagesForTelemetry(
828
836
  ): string | undefined {
829
837
  const serializer = telemetry.config.contentSerializer?.requestMessages;
830
838
  if (serializer) return callContentSerializer(telemetry, "requestMessages", () => serializer(request));
839
+ const systemParts = normalizeSystemPromptParts(request.systemPrompt);
840
+ const requestMessages = request.messages ?? [];
841
+ const total = systemParts.length + requestMessages.length;
842
+ if (total === 0) return undefined;
843
+ // Summarize only what survives the message cap; the rest is counted.
831
844
  const messages: TelemetryMessageSummary[] = [];
832
- for (const text of normalizeSystemPromptParts(request.systemPrompt))
845
+ for (const text of systemParts) {
846
+ if (messages.length === MAX_TELEMETRY_MESSAGE_COUNT) break;
833
847
  messages.push({ role: "system", content: summarizeTelemetryValue(text) });
834
- if (request.messages) {
835
- for (const message of request.messages) {
836
- messages.push({ role: message.role, content: summarizeTelemetryValue(message.content) });
837
- }
838
848
  }
839
- return messages.length === 0 ? undefined : stringifyJsonAttribute(limitTelemetryMessages(messages));
849
+ for (const message of requestMessages) {
850
+ if (messages.length === MAX_TELEMETRY_MESSAGE_COUNT) break;
851
+ messages.push({ role: message.role, content: summarizeTelemetryValue(message.content) });
852
+ }
853
+ if (total > MAX_TELEMETRY_MESSAGE_COUNT) {
854
+ messages.push({
855
+ role: "system",
856
+ content: { kind: "truncated", omittedMessages: total - MAX_TELEMETRY_MESSAGE_COUNT },
857
+ });
858
+ }
859
+ return stringifyJsonAttribute(messages);
840
860
  }
841
861
 
842
862
  function serializeResponseTextForTelemetry(telemetry: AgentTelemetry, message: AssistantMessage): string | undefined {
@@ -1014,17 +1034,6 @@ function callContentSerializer(
1014
1034
  }
1015
1035
  }
1016
1036
 
1017
- function limitTelemetryMessages(messages: readonly TelemetryMessageSummary[]): TelemetryMessageSummary[] {
1018
- const limited = messages.slice(0, MAX_TELEMETRY_MESSAGE_COUNT);
1019
- if (messages.length > MAX_TELEMETRY_MESSAGE_COUNT) {
1020
- limited.push({
1021
- role: "system",
1022
- content: { kind: "truncated", omittedMessages: messages.length - MAX_TELEMETRY_MESSAGE_COUNT },
1023
- });
1024
- }
1025
- return limited;
1026
- }
1027
-
1028
1037
  function limitTelemetryToolCalls(toolCalls: readonly TelemetryToolCallSummary[]): TelemetryToolCallSummary[] {
1029
1038
  const limited = toolCalls.slice(0, MAX_TELEMETRY_ARRAY_ITEMS);
1030
1039
  if (toolCalls.length > MAX_TELEMETRY_ARRAY_ITEMS) {
@@ -1138,6 +1147,8 @@ export async function finishChatSpan(
1138
1147
  message: AssistantMessage,
1139
1148
  options: {
1140
1149
  readonly stepNumber: number;
1150
+ /** Requested model id; `message.model` may name a served fallback model instead. */
1151
+ readonly modelId?: string;
1141
1152
  readonly serviceTier?: ServiceTier;
1142
1153
  readonly responseHeaders?: Readonly<Record<string, string>>;
1143
1154
  readonly baseUrl?: string;
@@ -1147,7 +1158,7 @@ export async function finishChatSpan(
1147
1158
  applyChatResponseAttributes(span, message);
1148
1159
  applyUsageAttributes(span, message.usage);
1149
1160
  applyGatewayAttributes(span, options.responseHeaders, options.baseUrl);
1150
- const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber);
1161
+ const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber, options.modelId);
1151
1162
  if (telemetry) {
1152
1163
  await emitChatUsage(telemetry, span, {
1153
1164
  operation: GenAIOperation.Chat,
@@ -1367,10 +1378,12 @@ function applyCostEstimate(
1367
1378
  message: AssistantMessage,
1368
1379
  serviceTier: ServiceTier | undefined,
1369
1380
  stepNumber: number | undefined,
1381
+ modelId: string | undefined,
1370
1382
  ): AppliedCostEstimate {
1371
1383
  if (!telemetry) return EMPTY_COST;
1372
1384
  return applyCostEstimateForUsage(telemetry, span, {
1373
1385
  model: message.model,
1386
+ modelId,
1374
1387
  provider: message.provider,
1375
1388
  serviceTier,
1376
1389
  stepNumber,
@@ -1383,6 +1396,7 @@ function applyCostEstimateForUsage(
1383
1396
  span: Span,
1384
1397
  input: {
1385
1398
  readonly model: string;
1399
+ readonly modelId: string | undefined;
1386
1400
  readonly provider: string | undefined;
1387
1401
  readonly serviceTier: ServiceTier | undefined;
1388
1402
  readonly stepNumber: number | undefined;
@@ -1398,9 +1412,12 @@ function applyCostEstimateForUsage(
1398
1412
  try {
1399
1413
  result = estimator({
1400
1414
  provider,
1415
+ providerId: input.provider,
1401
1416
  model: input.model,
1417
+ modelId: input.modelId,
1402
1418
  serviceTier: input.serviceTier,
1403
1419
  usage,
1420
+ usageCost: input.usage.cost,
1404
1421
  });
1405
1422
  } catch (err) {
1406
1423
  emitTelemetryWarning(telemetry, {
@@ -1620,6 +1637,7 @@ export async function recordManualChatTelemetry(
1620
1637
  if (telemetry) {
1621
1638
  const applied = applyCostEstimateForUsage(telemetry, span, {
1622
1639
  model: options.responseModel ?? options.model.id,
1640
+ modelId: options.model.id,
1623
1641
  provider: options.model.provider,
1624
1642
  serviceTier: options.serviceTier,
1625
1643
  stepNumber: options.stepNumber,
@@ -1771,10 +1789,10 @@ export interface InstrumentedChatSpanOptions {
1771
1789
  /** Step index recorded on the span; defaults to `-1` for non-loop calls. */
1772
1790
  readonly stepNumber?: number;
1773
1791
  /**
1774
- * Tag stamped onto `omp.gen_ai.oneshot.kind`. Values used by the agent:
1792
+ * Tag stamped onto `zeta.gen_ai.oneshot.kind`. Values used by the agent:
1775
1793
  * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
1776
- * `handoff`, `branch_summary`, `image_question`. Free-form to allow callers
1777
- * outside this package to add new kinds without bumping the helper.
1794
+ * `handoff`, `branch_summary`, `image_question`, `skill_description`. Free-form
1795
+ * to allow callers outside this package to add new kinds without bumping the helper.
1778
1796
  */
1779
1797
  readonly oneshotKind?: string;
1780
1798
  /** Extra span attributes applied verbatim. */
@@ -1889,6 +1907,7 @@ export async function instrumentedCompleteSimple<TApi extends Api>(
1889
1907
  : await runOnce();
1890
1908
  await finishChatSpan(telemetry, chatSpan, message, {
1891
1909
  stepNumber,
1910
+ modelId: model.id,
1892
1911
  serviceTier: options.serviceTier,
1893
1912
  responseHeaders: capturedHeaders,
1894
1913
  baseUrl: model.baseUrl,
@@ -2098,33 +2117,33 @@ export function fireOnRunEnd(telemetry: AgentTelemetry, summary: AgentRunSummary
2098
2117
  }
2099
2118
  }
2100
2119
 
2101
- /** Aggregate `omp.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
2120
+ /** Aggregate `zeta.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
2102
2121
  export const enum OmpGenAIAggregateAttr {
2103
- ChatsCount = "omp.gen_ai.agent.chats.count",
2104
- ChatsTotalLatencyMs = "omp.gen_ai.agent.chats.total_latency_ms",
2105
- ChatsStopReasonPrefix = "omp.gen_ai.agent.chats.stop_reason.",
2106
- ToolsCount = "omp.gen_ai.agent.tools.count",
2107
- ToolsOkCount = "omp.gen_ai.agent.tools.ok.count",
2108
- ToolsErrorCount = "omp.gen_ai.agent.tools.error.count",
2109
- ToolsSkippedCount = "omp.gen_ai.agent.tools.skipped.count",
2110
- ToolsBlockedCount = "omp.gen_ai.agent.tools.blocked.count",
2111
- ToolsTimeoutCount = "omp.gen_ai.agent.tools.timeout.count",
2112
- ToolsAbortedCount = "omp.gen_ai.agent.tools.aborted.count",
2113
- ToolsTotalLatencyMs = "omp.gen_ai.agent.tools.total_latency_ms",
2114
- ToolsInvoked = "omp.gen_ai.agent.tools.invoked",
2115
- ToolsAvailable = "omp.gen_ai.agent.tools.available",
2116
- ToolsUnused = "omp.gen_ai.agent.tools.unused",
2117
- UsageInputTokensTotal = "omp.gen_ai.agent.usage.input_tokens.total",
2118
- UsageOutputTokensTotal = "omp.gen_ai.agent.usage.output_tokens.total",
2119
- UsageCacheReadInputTokensTotal = "omp.gen_ai.agent.usage.cache_read.input_tokens.total",
2120
- UsageCacheCreationInputTokensTotal = "omp.gen_ai.agent.usage.cache_creation.input_tokens.total",
2121
- UsageReasoningOutputTokensTotal = "omp.gen_ai.agent.usage.reasoning.output_tokens.total",
2122
- UsageTotalTokensTotal = "omp.gen_ai.agent.usage.total_tokens.total",
2123
- CostEstimatedUsdTotal = "omp.gen_ai.agent.cost.estimated_usd.total",
2124
- ErrorsCount = "omp.gen_ai.agent.errors.count",
2125
- }
2126
-
2127
- /** Stamp the aggregate `omp.gen_ai.agent.*` attributes on the given span. */
2122
+ ChatsCount = "zeta.gen_ai.agent.chats.count",
2123
+ ChatsTotalLatencyMs = "zeta.gen_ai.agent.chats.total_latency_ms",
2124
+ ChatsStopReasonPrefix = "zeta.gen_ai.agent.chats.stop_reason.",
2125
+ ToolsCount = "zeta.gen_ai.agent.tools.count",
2126
+ ToolsOkCount = "zeta.gen_ai.agent.tools.ok.count",
2127
+ ToolsErrorCount = "zeta.gen_ai.agent.tools.error.count",
2128
+ ToolsSkippedCount = "zeta.gen_ai.agent.tools.skipped.count",
2129
+ ToolsBlockedCount = "zeta.gen_ai.agent.tools.blocked.count",
2130
+ ToolsTimeoutCount = "zeta.gen_ai.agent.tools.timeout.count",
2131
+ ToolsAbortedCount = "zeta.gen_ai.agent.tools.aborted.count",
2132
+ ToolsTotalLatencyMs = "zeta.gen_ai.agent.tools.total_latency_ms",
2133
+ ToolsInvoked = "zeta.gen_ai.agent.tools.invoked",
2134
+ ToolsAvailable = "zeta.gen_ai.agent.tools.available",
2135
+ ToolsUnused = "zeta.gen_ai.agent.tools.unused",
2136
+ UsageInputTokensTotal = "zeta.gen_ai.agent.usage.input_tokens.total",
2137
+ UsageOutputTokensTotal = "zeta.gen_ai.agent.usage.output_tokens.total",
2138
+ UsageCacheReadInputTokensTotal = "zeta.gen_ai.agent.usage.cache_read.input_tokens.total",
2139
+ UsageCacheCreationInputTokensTotal = "zeta.gen_ai.agent.usage.cache_creation.input_tokens.total",
2140
+ UsageReasoningOutputTokensTotal = "zeta.gen_ai.agent.usage.reasoning.output_tokens.total",
2141
+ UsageTotalTokensTotal = "zeta.gen_ai.agent.usage.total_tokens.total",
2142
+ CostEstimatedUsdTotal = "zeta.gen_ai.agent.cost.estimated_usd.total",
2143
+ ErrorsCount = "zeta.gen_ai.agent.errors.count",
2144
+ }
2145
+
2146
+ /** Stamp the aggregate `zeta.gen_ai.agent.*` attributes on the given span. */
2128
2147
  function applyAggregateAttributes(span: Span, summary: AgentRunSummary, coverage: AgentRunCoverage): void {
2129
2148
  span.setAttribute(OmpGenAIAggregateAttr.ChatsCount, summary.chats.total);
2130
2149
  span.setAttribute(OmpGenAIAggregateAttr.ChatsTotalLatencyMs, summary.chats.totalLatencyMs);
package/src/tokenizer.ts CHANGED
@@ -5,6 +5,7 @@ import { materializeString, stringifyJson } from "@linxiraos/pi-utils";
5
5
  import { LRUCache } from "@linxiraos/pi-utils/lru";
6
6
  import * as snapcompact from "@linxiraos/pi-snapcompact";
7
7
  import { isEstimateCacheable, messageEstimateVersion } from "./compaction/message-cache";
8
+ import { base64ImageSize, estimateImageContentTokens } from "./image-tokens";
8
9
  import type { AgentMessage } from "./types";
9
10
 
10
11
  const testEnv = Bun.env.NODE_ENV === "test";
@@ -131,12 +132,6 @@ export interface TokenBudgetCheck {
131
132
  exact: boolean;
132
133
  }
133
134
 
134
- /**
135
- * Image content has no tokenizer representation; charge a fixed estimate
136
- * matching what providers typically bill for inline images.
137
- */
138
- const IMAGE_TOKEN_ESTIMATE = 1200;
139
-
140
135
  /**
141
136
  * Memoized estimates for one message under this tokenizer's encoding, split by
142
137
  * the {@link MessageCountOptions.excludeEncryptedReasoning} option so the two
@@ -154,13 +149,15 @@ interface MessageEstimate {
154
149
  * Model-aware local token counter. Immutable: the catalog-resolved encoding
155
150
  * is fixed at construction, so a cached count can never straddle two
156
151
  * encodings. An `Agent` owns one for its active model (swapping the instance
157
- * when the model's encoding changes); one-shot flows construct their own for
158
- * the model that will be billed. Known tokenizer families use exact native
159
- * counts; unknown models keep the fast byte estimate (or o200k when
160
- * `PI_TOKENIZER_ACCURATE=1`).
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`).
161
156
  */
162
157
  export class Tokenizer {
163
158
  readonly #encoding: natives.Encoding | null;
159
+ readonly #frameBilling: snapcompact.FrameBilling;
160
+ readonly frameBillingKey: string;
164
161
 
165
162
  /** Exact counts only; byte fallbacks remain mode-dependent and uncached. */
166
163
  readonly #nativeCounts = new LRUCache<string, number>({
@@ -179,8 +176,10 @@ export class Tokenizer {
179
176
  */
180
177
  #estimates = new WeakMap<AgentMessage, MessageEstimate>();
181
178
 
182
- constructor(model?: Pick<Model, "tokenizer"> | null) {
179
+ constructor(model?: (Pick<Model, "tokenizer"> & snapcompact.ShapeTarget) | null) {
183
180
  this.#encoding = tokenizerEncodingForModel(model);
181
+ this.#frameBilling = snapcompact.frameBilling(model ?? undefined);
182
+ this.frameBillingKey = snapcompact.frameBillingKey(this.#frameBilling);
184
183
  }
185
184
 
186
185
  get encoding(): natives.Encoding | null {
@@ -219,7 +218,8 @@ export class Tokenizer {
219
218
  * Settled historical messages are counted once and reused until an owner
220
219
  * (prune/shake/strip-images) calls `invalidateMessageCache`; streaming
221
220
  * assistants bypass the memo entirely (see the message-cache settle-gate
222
- * invariant). Image blocks charge a fixed per-image estimate.
221
+ * invariant). Image blocks charge {@link estimateImageContentTokens}, the
222
+ * same dimension-based estimate the native remote-compaction probe applies.
223
223
  */
224
224
  countMessage(message: AgentMessage, options?: MessageCountOptions): number {
225
225
  const floored = options?.excludeEncryptedReasoning === true;
@@ -273,7 +273,7 @@ export class Tokenizer {
273
273
  if (block.type === "text" && block.text) {
274
274
  fragments.push(block.text);
275
275
  } else if (block.type === "image") {
276
- extra += IMAGE_TOKEN_ESTIMATE;
276
+ extra += estimateImageContentTokens(block);
277
277
  }
278
278
  }
279
279
  }
@@ -323,7 +323,7 @@ export class Tokenizer {
323
323
  if (block.type === "text" && block.text) {
324
324
  fragments.push(block.text);
325
325
  } else if (block.type === "image") {
326
- extra += IMAGE_TOKEN_ESTIMATE;
326
+ extra += estimateImageContentTokens(block);
327
327
  }
328
328
  }
329
329
  }
@@ -336,11 +336,12 @@ export class Tokenizer {
336
336
  if (message.blocks) {
337
337
  for (const block of message.blocks) {
338
338
  if (block.type === "text") fragments.push(block.text);
339
- else extra += snapcompact.FRAME_TOKEN_ESTIMATE;
339
+ else extra += this.#frameTokens(block.data);
340
340
  }
341
341
  } else if (message.images) {
342
- // Snapcompact frames render at ≥1568px; providers bill the downscaled cap.
343
- extra += message.images.length * snapcompact.FRAME_TOKEN_ESTIMATE;
342
+ for (const image of message.images) {
343
+ extra += this.#frameTokens(image.data);
344
+ }
344
345
  }
345
346
  }
346
347
  break;
@@ -352,4 +353,10 @@ export class Tokenizer {
352
353
  if (fragments.length === 0) return extra;
353
354
  return extra + this.countTokens(fragments);
354
355
  }
356
+
357
+ /** One snapcompact frame at the active model's price for its pixel size; unreadable frames cost the ceiling. */
358
+ #frameTokens(data: string): number {
359
+ const size = base64ImageSize(data);
360
+ return size ? snapcompact.frameTokens(this.#frameBilling, size) : snapcompact.FRAME_TOKEN_ESTIMATE;
361
+ }
355
362
  }
@@ -0,0 +1,38 @@
1
+ import { type ToolCall, validateToolArguments } from "@linxiraos/pi-ai";
2
+ import type { AgentTool } from "./types";
3
+
4
+ /**
5
+ * Where a call's `__parseError`/`__rawJson` keys came from.
6
+ * - `"parsed"`: a provider stream parsed the arguments, so `__parseError` is a
7
+ * genuine parse-failure sentinel and is never handed to a tool.
8
+ * - `"payload"`: the arguments are a payload the model wrote (eval tool bridge,
9
+ * `xd://` device content), so those keys are forged and only stripped.
10
+ */
11
+ export type ToolArgumentSource = "parsed" | "payload";
12
+
13
+ /**
14
+ * Validate a call's arguments against `tool`'s schema, honoring
15
+ * {@link AgentTool.lenientArgValidation}: on a schema mismatch a lenient tool
16
+ * receives its raw arguments minus the internal `__parseError`/`__rawJson`
17
+ * sentinels. Malformed JSON from a provider stream is never lenient.
18
+ *
19
+ * @throws the validation error when the tool is strict or the arguments failed to parse.
20
+ */
21
+ export function validateAgentToolArguments(
22
+ tool: AgentTool,
23
+ toolCall: ToolCall,
24
+ source: ToolArgumentSource = "parsed",
25
+ ): ToolCall["arguments"] {
26
+ try {
27
+ return validateToolArguments(tool, toolCall);
28
+ } catch (error) {
29
+ const args: unknown = toolCall.arguments;
30
+ if (!tool.lenientArgValidation) throw error;
31
+ if (args === null || typeof args !== "object" || Array.isArray(args)) return args as ToolCall["arguments"];
32
+ if (source === "parsed" && "__parseError" in args) throw error;
33
+ const fallback = { ...(args as Record<string, unknown>) };
34
+ delete fallback.__parseError;
35
+ delete fallback.__rawJson;
36
+ return fallback;
37
+ }
38
+ }
package/src/types.ts CHANGED
@@ -328,6 +328,16 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
328
328
  * background.
329
329
  */
330
330
  hasBackgroundCompletions?: () => boolean | Promise<boolean>;
331
+ /**
332
+ * Peeks whether a passive aside (an extension or user message queued with
333
+ * `deliverAs: "aside"`) is waiting for injection at the next boundary.
334
+ *
335
+ * Same rules as {@link hasBackgroundCompletions}: non-consuming, ends only
336
+ * *interruptible* waits, never raises {@link ToolCallContext.steeringSignal}.
337
+ * The host still injects the aside once at the boundary; ordinary running
338
+ * tools finish first.
339
+ */
340
+ hasQueuedAsides?: () => boolean | Promise<boolean>;
331
341
 
332
342
  /**
333
343
  * Returns follow-up messages to process after the agent would otherwise stop.
@@ -341,7 +351,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
341
351
  * Returns non-interrupting "aside" messages to inject at a step boundary.
342
352
  *
343
353
  * Polled after each tool batch (before the next LLM call) AND at the yield
344
- * check. Unlike steering, these NEVER abort in-flight tools — they are passive
354
+ * check. Unlike steering, these never abort foreground tools (only an
355
+ * interruptible `wait` ends early, via the peek hooks) — they are passive
345
356
  * notifications (e.g. background-job completions, late LSP diagnostics) that
346
357
  * should reach the model between requests without waiting for the agent to
347
358
  * fully stop. Returned messages are appended to the context with normal