@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.
- package/CHANGELOG.md +6 -243
- package/README.md +1 -1
- package/dist/types/agent.d.ts +27 -7
- package/dist/types/compaction/anthropic.d.ts +14 -8
- package/dist/types/compaction/branch-summarization.d.ts +1 -1
- package/dist/types/compaction/compaction-v2-streaming.d.ts +5 -1
- package/dist/types/compaction/compaction.d.ts +24 -2
- package/dist/types/compaction/entries.d.ts +2 -2
- package/dist/types/compaction/messages.d.ts +6 -0
- package/dist/types/compaction/openai.d.ts +14 -2
- package/dist/types/compaction/pruning.d.ts +64 -16
- package/dist/types/compaction/tool-protection.d.ts +2 -1
- package/dist/types/compaction/utils.d.ts +5 -0
- package/dist/types/image-tokens.d.ts +29 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +1 -1
- package/dist/types/telemetry.d.ts +70 -60
- package/dist/types/tokenizer.d.ts +9 -6
- package/dist/types/tool-arguments.d.ts +19 -0
- package/dist/types/types.d.ts +12 -1
- package/package.json +8 -8
- package/src/agent-loop.ts +191 -34
- package/src/agent.ts +126 -21
- package/src/append-only-context.ts +38 -29
- package/src/compaction/anthropic.ts +30 -12
- package/src/compaction/branch-summarization.ts +1 -1
- package/src/compaction/compaction-v2-streaming.ts +23 -16
- package/src/compaction/compaction.ts +163 -73
- package/src/compaction/entries.ts +2 -2
- package/src/compaction/message-cache.ts +1 -1
- package/src/compaction/messages.ts +25 -0
- package/src/compaction/openai.ts +65 -28
- package/src/compaction/pruning.ts +328 -96
- package/src/compaction/tool-protection.ts +8 -2
- package/src/compaction/utils.ts +9 -0
- package/src/image-tokens.ts +77 -0
- package/src/index.ts +2 -0
- package/src/prompts/dsml-tool-call-leak.md +19 -0
- package/src/speculative-execution.ts +5 -9
- package/src/telemetry.ts +100 -81
- package/src/tokenizer.ts +24 -17
- package/src/tool-arguments.ts +38 -0
- 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 {
|
|
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 =
|
|
716
|
+
validatedArgs = validateAgentToolArguments(tool, toolCall);
|
|
716
717
|
} catch {
|
|
717
|
-
|
|
718
|
-
|
|
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 `
|
|
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 = "
|
|
130
|
-
AgentStepCount = "
|
|
131
|
-
RequestReasoningEffort = "
|
|
132
|
-
RequestToolChoice = "
|
|
133
|
-
RequestAvailableTools = "
|
|
134
|
-
RequestMessages = "
|
|
135
|
-
ResponseText = "
|
|
136
|
-
ResponseToolCalls = "
|
|
137
|
-
ResponseUpstreamProvider = "
|
|
138
|
-
UsageTotalTokens = "
|
|
139
|
-
UsageServerSideTools = "
|
|
140
|
-
CostEstimatedUsd = "
|
|
141
|
-
CostInputUsd = "
|
|
142
|
-
CostOutputUsd = "
|
|
143
|
-
CostUnavailableReason = "
|
|
144
|
-
ToolStatus = "
|
|
145
|
-
ToolCallIntent = "
|
|
146
|
-
HandoffFromAgentName = "
|
|
147
|
-
HandoffFromAgentId = "
|
|
148
|
-
HandoffToAgentName = "
|
|
149
|
-
HandoffToAgentId = "
|
|
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 = "
|
|
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 = "
|
|
159
|
-
GatewayEndpoint = "
|
|
160
|
-
GatewayCallId = "
|
|
161
|
-
GatewayRoutedTo = "
|
|
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 = "
|
|
163
|
+
GatewayResponseCacheStatus = "zeta.gen_ai.gateway.response_cache.status",
|
|
164
164
|
/** Caller-level reason a judgment ran (`find`, `ttsr`, `judge_batch`, …). */
|
|
165
|
-
JudgmentPurpose = "
|
|
165
|
+
JudgmentPurpose = "zeta.gen_ai.judgment.purpose",
|
|
166
166
|
/** Questions the caller asked in one judgment request. */
|
|
167
|
-
JudgmentQuestions = "
|
|
167
|
+
JudgmentQuestions = "zeta.gen_ai.judgment.questions",
|
|
168
168
|
/** Questions answered from the local judgment cache instead of the provider. */
|
|
169
|
-
JudgmentCachedQuestions = "
|
|
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
|
|
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
|
-
*
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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 `
|
|
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
|
|
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 `
|
|
2120
|
+
/** Aggregate `zeta.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
|
|
2102
2121
|
export const enum OmpGenAIAggregateAttr {
|
|
2103
|
-
ChatsCount = "
|
|
2104
|
-
ChatsTotalLatencyMs = "
|
|
2105
|
-
ChatsStopReasonPrefix = "
|
|
2106
|
-
ToolsCount = "
|
|
2107
|
-
ToolsOkCount = "
|
|
2108
|
-
ToolsErrorCount = "
|
|
2109
|
-
ToolsSkippedCount = "
|
|
2110
|
-
ToolsBlockedCount = "
|
|
2111
|
-
ToolsTimeoutCount = "
|
|
2112
|
-
ToolsAbortedCount = "
|
|
2113
|
-
ToolsTotalLatencyMs = "
|
|
2114
|
-
ToolsInvoked = "
|
|
2115
|
-
ToolsAvailable = "
|
|
2116
|
-
ToolsUnused = "
|
|
2117
|
-
UsageInputTokensTotal = "
|
|
2118
|
-
UsageOutputTokensTotal = "
|
|
2119
|
-
UsageCacheReadInputTokensTotal = "
|
|
2120
|
-
UsageCacheCreationInputTokensTotal = "
|
|
2121
|
-
UsageReasoningOutputTokensTotal = "
|
|
2122
|
-
UsageTotalTokensTotal = "
|
|
2123
|
-
CostEstimatedUsdTotal = "
|
|
2124
|
-
ErrorsCount = "
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
/** Stamp the aggregate `
|
|
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
|
|
158
|
-
* the model that will be billed. Known
|
|
159
|
-
* counts; unknown models keep the fast
|
|
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
|
|
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 +=
|
|
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 +=
|
|
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 +=
|
|
339
|
+
else extra += this.#frameTokens(block.data);
|
|
340
340
|
}
|
|
341
341
|
} else if (message.images) {
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
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
|