Skip to content

Commit 25b21d0

Browse files
authored
feat(core): add prompt context telemetry breakdown (#1169)
1 parent d3e3ca0 commit 25b21d0

4 files changed

Lines changed: 325 additions & 2 deletions

File tree

.changeset/green-planes-jump.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@voltagent/core": patch
3+
---
4+
5+
feat: add estimated prompt context telemetry for observability
6+
7+
- record estimated prompt-context breakdown for system instructions, conversation messages, and tool schemas on LLM spans
8+
- expose cached and reasoning token usage on LLM spans for observability consumers
9+
- add tests for prompt-context estimation helpers

packages/core/src/agent/agent.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@ import {
109109
import type { AgentHooks, OnToolEndHookResult, OnToolErrorHookResult } from "./hooks";
110110
import { stripDanglingOpenAIReasoningFromModelMessages } from "./model-message-normalizer";
111111
import { AgentTraceContext, addModelAttributesToSpan } from "./open-telemetry/trace-context";
112+
import {
113+
estimatePromptContextUsage,
114+
promptContextUsageEstimateToAttributes,
115+
} from "./prompt-context-usage";
112116
import type {
113117
BaseMessage,
114118
BaseTool,
@@ -4101,7 +4105,16 @@ export class Agent {
41014105
},
41024106
): Span {
41034107
const { label, ...spanParams } = params;
4104-
const attributes = this.buildLLMSpanAttributes(spanParams);
4108+
const promptContextUsageEstimate = estimatePromptContextUsage({
4109+
messages: params.messages,
4110+
tools: params.tools,
4111+
});
4112+
const attributes = {
4113+
...this.buildLLMSpanAttributes(spanParams),
4114+
...(promptContextUsageEstimate
4115+
? promptContextUsageEstimateToAttributes(promptContextUsageEstimate)
4116+
: {}),
4117+
};
41054118
const span = oc.traceContext.createChildSpan(`llm:${params.operation}`, "llm", {
41064119
kind: SpanKind.CLIENT,
41074120
label,
@@ -4240,7 +4253,8 @@ export class Agent {
42404253
return;
42414254
}
42424255

4243-
const { promptTokens, completionTokens, totalTokens } = normalizedUsage;
4256+
const { promptTokens, completionTokens, totalTokens, cachedInputTokens, reasoningTokens } =
4257+
normalizedUsage;
42444258

42454259
if (promptTokens !== undefined) {
42464260
span.setAttribute("llm.usage.prompt_tokens", promptTokens);
@@ -4251,6 +4265,12 @@ export class Agent {
42514265
if (totalTokens !== undefined) {
42524266
span.setAttribute("llm.usage.total_tokens", totalTokens);
42534267
}
4268+
if (cachedInputTokens !== undefined) {
4269+
span.setAttribute("llm.usage.cached_tokens", cachedInputTokens);
4270+
}
4271+
if (reasoningTokens !== undefined) {
4272+
span.setAttribute("llm.usage.reasoning_tokens", reasoningTokens);
4273+
}
42544274
}
42554275

42564276
private recordProviderCost(span: Span, providerMetadata?: unknown): void {
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { describe, expect, it } from "vitest";
2+
import { z } from "zod";
3+
import {
4+
estimatePromptContextUsage,
5+
promptContextUsageEstimateToAttributes,
6+
} from "./prompt-context-usage";
7+
8+
describe("prompt context usage estimation", () => {
9+
it("estimates system, message, and tool context separately", () => {
10+
const estimate = estimatePromptContextUsage({
11+
messages: [
12+
{
13+
role: "system",
14+
content: "You are a careful assistant.",
15+
},
16+
{
17+
role: "user",
18+
content: "Summarize the latest release notes.",
19+
},
20+
{
21+
role: "assistant",
22+
content: [{ type: "text", text: "Let me inspect them." }],
23+
},
24+
],
25+
tools: {
26+
searchDocs: {
27+
description: "Search the documentation",
28+
inputSchema: z.object({
29+
query: z.string(),
30+
topK: z.number().int().optional(),
31+
}),
32+
},
33+
},
34+
});
35+
36+
expect(estimate).toBeDefined();
37+
expect(estimate?.systemMessageCount).toBe(1);
38+
expect(estimate?.toolCount).toBe(1);
39+
expect(estimate?.systemTokensEstimated).toBeGreaterThan(0);
40+
expect(estimate?.nonSystemMessageTokensEstimated).toBeGreaterThan(0);
41+
expect(estimate?.toolTokensEstimated).toBeGreaterThan(0);
42+
expect(estimate?.messageTokensEstimated).toBe(
43+
(estimate?.systemTokensEstimated ?? 0) + (estimate?.nonSystemMessageTokensEstimated ?? 0),
44+
);
45+
expect(estimate?.totalTokensEstimated).toBe(
46+
(estimate?.messageTokensEstimated ?? 0) + (estimate?.toolTokensEstimated ?? 0),
47+
);
48+
});
49+
50+
it("returns prompt context usage span attributes", () => {
51+
const attributes = promptContextUsageEstimateToAttributes({
52+
systemTokensEstimated: 12,
53+
messageTokensEstimated: 34,
54+
nonSystemMessageTokensEstimated: 22,
55+
toolTokensEstimated: 18,
56+
totalTokensEstimated: 52,
57+
systemMessageCount: 1,
58+
toolCount: 2,
59+
});
60+
61+
expect(attributes).toEqual({
62+
"usage.prompt_context.system_tokens_estimated": 12,
63+
"usage.prompt_context.message_tokens_estimated": 34,
64+
"usage.prompt_context.non_system_message_tokens_estimated": 22,
65+
"usage.prompt_context.tool_tokens_estimated": 18,
66+
"usage.prompt_context.total_tokens_estimated": 52,
67+
"usage.prompt_context.system_message_count": 1,
68+
"usage.prompt_context.tool_count": 2,
69+
});
70+
});
71+
});
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { safeStringify } from "@voltagent/internal/utils";
2+
import type { ToolSet } from "ai";
3+
import { zodSchemaToJsonUI } from "../utils/toolParser";
4+
5+
const ESTIMATED_CHARS_PER_TOKEN = 4;
6+
const BINARY_PART_TYPES = new Set([
7+
"audio",
8+
"file",
9+
"image",
10+
"input_audio",
11+
"input_image",
12+
"media",
13+
]);
14+
const LARGE_BINARY_KEYS = new Set(["audio", "base64", "bytes", "data", "image"]);
15+
16+
type PromptMessage = {
17+
role?: string;
18+
content?: unknown;
19+
};
20+
21+
export interface PromptContextUsageEstimate {
22+
systemTokensEstimated: number;
23+
messageTokensEstimated: number;
24+
nonSystemMessageTokensEstimated: number;
25+
toolTokensEstimated: number;
26+
totalTokensEstimated: number;
27+
systemMessageCount: number;
28+
toolCount: number;
29+
}
30+
31+
export function estimatePromptContextUsage(params: {
32+
messages?: PromptMessage[];
33+
tools?: ToolSet;
34+
}): PromptContextUsageEstimate | undefined {
35+
let systemTokensEstimated = 0;
36+
let messageTokensEstimated = 0;
37+
let nonSystemMessageTokensEstimated = 0;
38+
let systemMessageCount = 0;
39+
40+
for (const message of params.messages ?? []) {
41+
const serializedMessage = serializePromptMessage(message);
42+
if (!serializedMessage) {
43+
continue;
44+
}
45+
46+
const estimatedTokens = estimateTokensFromText(serializedMessage);
47+
messageTokensEstimated += estimatedTokens;
48+
49+
if (message.role === "system") {
50+
systemTokensEstimated += estimatedTokens;
51+
systemMessageCount += 1;
52+
continue;
53+
}
54+
55+
nonSystemMessageTokensEstimated += estimatedTokens;
56+
}
57+
58+
const serializedTools = Object.entries(params.tools ?? {}).map(([name, tool]) =>
59+
serializeToolDefinition(name, tool),
60+
);
61+
const toolTokensEstimated =
62+
serializedTools.length > 0 ? estimateTokensFromText(safeStringify(serializedTools)) : 0;
63+
const totalTokensEstimated = messageTokensEstimated + toolTokensEstimated;
64+
65+
if (totalTokensEstimated === 0) {
66+
return undefined;
67+
}
68+
69+
return {
70+
systemTokensEstimated,
71+
messageTokensEstimated,
72+
nonSystemMessageTokensEstimated,
73+
toolTokensEstimated,
74+
totalTokensEstimated,
75+
systemMessageCount,
76+
toolCount: serializedTools.length,
77+
};
78+
}
79+
80+
export function promptContextUsageEstimateToAttributes(
81+
estimate: PromptContextUsageEstimate,
82+
): Record<string, number> {
83+
return {
84+
"usage.prompt_context.system_tokens_estimated": estimate.systemTokensEstimated,
85+
"usage.prompt_context.message_tokens_estimated": estimate.messageTokensEstimated,
86+
"usage.prompt_context.non_system_message_tokens_estimated":
87+
estimate.nonSystemMessageTokensEstimated,
88+
"usage.prompt_context.tool_tokens_estimated": estimate.toolTokensEstimated,
89+
"usage.prompt_context.total_tokens_estimated": estimate.totalTokensEstimated,
90+
"usage.prompt_context.system_message_count": estimate.systemMessageCount,
91+
"usage.prompt_context.tool_count": estimate.toolCount,
92+
};
93+
}
94+
95+
function estimateTokensFromText(text: string): number {
96+
if (!text) {
97+
return 0;
98+
}
99+
100+
return Math.ceil(text.length / ESTIMATED_CHARS_PER_TOKEN);
101+
}
102+
103+
function serializePromptMessage(message: PromptMessage): string {
104+
const content = serializePromptValue(message.content).trim();
105+
if (!content) {
106+
return "";
107+
}
108+
109+
const role = typeof message.role === "string" ? message.role.toUpperCase() : "MESSAGE";
110+
return `${role}:\n${content}`;
111+
}
112+
113+
function serializePromptValue(value: unknown): string {
114+
if (typeof value === "string") {
115+
return value;
116+
}
117+
118+
if (typeof value === "number" || typeof value === "boolean") {
119+
return String(value);
120+
}
121+
122+
if (Array.isArray(value)) {
123+
return value
124+
.map((entry) => serializePromptValue(entry))
125+
.filter((entry) => entry.trim().length > 0)
126+
.join("\n");
127+
}
128+
129+
if (!value || typeof value !== "object") {
130+
return "";
131+
}
132+
133+
const record = value as Record<string, unknown>;
134+
const type = typeof record.type === "string" ? record.type : undefined;
135+
136+
if (typeof record.text === "string") {
137+
return record.text;
138+
}
139+
140+
if (type && BINARY_PART_TYPES.has(type)) {
141+
return `[${type}]`;
142+
}
143+
144+
if (type === "tool-call") {
145+
const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
146+
const input = serializePromptValue(record.input);
147+
return input ? `tool-call ${toolName}: ${input}` : `tool-call ${toolName}`;
148+
}
149+
150+
if (type === "tool-result") {
151+
const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
152+
const output = serializePromptValue(record.output);
153+
return output ? `tool-result ${toolName}: ${output}` : `tool-result ${toolName}`;
154+
}
155+
156+
if ("content" in record) {
157+
const nestedContent = serializePromptValue(record.content);
158+
if (nestedContent) {
159+
return nestedContent;
160+
}
161+
}
162+
163+
return safeStringify(sanitizeRecord(record));
164+
}
165+
166+
function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
167+
const sanitized: Record<string, unknown> = {};
168+
169+
for (const [key, value] of Object.entries(record)) {
170+
sanitized[key] = LARGE_BINARY_KEYS.has(key) ? "[omitted]" : value;
171+
}
172+
173+
return sanitized;
174+
}
175+
176+
function serializeToolDefinition(name: string, tool: unknown): Record<string, unknown> {
177+
if (!tool || typeof tool !== "object") {
178+
return { name };
179+
}
180+
181+
const candidate = tool as Record<string, unknown>;
182+
183+
return {
184+
name,
185+
...(typeof candidate.type === "string" ? { type: candidate.type } : {}),
186+
...(typeof candidate.id === "string" ? { id: candidate.id } : {}),
187+
...(typeof candidate.description === "string" ? { description: candidate.description } : {}),
188+
...(candidate.inputSchema || candidate.parameters || candidate.input_schema || candidate.schema
189+
? {
190+
inputSchema: normalizeSchema(
191+
candidate.inputSchema ??
192+
candidate.parameters ??
193+
candidate.input_schema ??
194+
candidate.schema,
195+
),
196+
}
197+
: {}),
198+
...(candidate.outputSchema || candidate.output_schema
199+
? {
200+
outputSchema: normalizeSchema(candidate.outputSchema ?? candidate.output_schema),
201+
}
202+
: {}),
203+
...(candidate.providerOptions ? { providerOptions: candidate.providerOptions } : {}),
204+
...(candidate.args ? { args: sanitizeRecord(candidate.args as Record<string, unknown>) } : {}),
205+
...(candidate.needsApproval !== undefined ? { needsApproval: candidate.needsApproval } : {}),
206+
};
207+
}
208+
209+
function normalizeSchema(schema: unknown): unknown {
210+
if (!schema || typeof schema !== "object") {
211+
return schema;
212+
}
213+
214+
try {
215+
if ("_def" in (schema as Record<string, unknown>)) {
216+
return zodSchemaToJsonUI(schema);
217+
}
218+
} catch (_error) {
219+
return schema;
220+
}
221+
222+
return schema;
223+
}

0 commit comments

Comments
 (0)