Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/four-rules-peel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@voltagent/core": patch
---

fix: tighten prompt-context usage telemetry

- redact nested large binary fields when estimating prompt context usage
- exclude runtime-only tool metadata from tool schema token estimates
- avoid emitting cached and reasoning token span attributes when their values are zero
47 changes: 47 additions & 0 deletions packages/core/src/agent/agent-observability.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,53 @@ describe("Agent with Observability", () => {
unsubscribe();
});

it("should not emit zero cached or reasoning usage on llm spans", async () => {
const events: any[] = [];
const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => {
events.push(event);
});
Comment on lines +216 to +219
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify explicit any usage in TypeScript test files.
rg -nP --type=ts '\bevents:\s*any\[\]' -C2

Repository: VoltAgent/voltagent

Length of output: 5900


🏁 Script executed:

#!/bin/bash
# Find WebSocketEventEmitter definition and its onWebSocketEvent callback signature
rg -n 'onWebSocketEvent\s*\(' -A3 packages/core/src/agent/agent-observability.spec.ts | head -20

Repository: VoltAgent/voltagent

Length of output: 658


🏁 Script executed:

#!/bin/bash
# Search for WebSocketEventEmitter class definition
rg -n 'class WebSocketEventEmitter' -A20

Repository: VoltAgent/voltagent

Length of output: 2286


🏁 Script executed:

#!/bin/bash
# Find the type/interface for events in WebSocketEventEmitter
fd -t f -e ts -e tsx | xargs rg -l 'WebSocketEventEmitter' | head -5

Repository: VoltAgent/voltagent

Length of output: 313


🏁 Script executed:

#!/bin/bash
# Find ObservabilityWebSocketEvent type definition
rg -n 'type ObservabilityWebSocketEvent|interface ObservabilityWebSocketEvent' -A10

Repository: VoltAgent/voltagent

Length of output: 843


🏁 Script executed:

#!/bin/bash
# Check if ObservabilityWebSocketEvent is exported and available for import
rg -n 'export.*ObservabilityWebSocketEvent' -B2 -A2

Repository: VoltAgent/voltagent

Length of output: 460


🏁 Script executed:

#!/bin/bash
# Check what imports are currently in the test file
head -30 packages/core/src/agent/agent-observability.spec.ts

Repository: VoltAgent/voltagent

Length of output: 957


🏁 Script executed:

#!/bin/bash
# Verify if ObservabilityWebSocketEvent is exported from observability/index.ts or main index
rg -n 'export.*ObservabilityWebSocketEvent' packages/core/src/observability/index.ts packages/core/src/index.ts

Repository: VoltAgent/voltagent

Length of output: 45


🏁 Script executed:

#!/bin/bash
# Check what's exported from observability index
cat packages/core/src/observability/index.ts | head -50

Repository: VoltAgent/voltagent

Length of output: 2179


🏁 Script executed:

#!/bin/bash
# Check if ObservabilityWebSocketEvent is available in observability exports
rg 'export' packages/core/src/observability/index.ts | grep -i websocket

Repository: VoltAgent/voltagent

Length of output: 151


🏁 Script executed:

#!/bin/bash
# Get full exports from observability/index.ts
rg '^export' packages/core/src/observability/index.ts

Repository: VoltAgent/voltagent

Length of output: 913


🏁 Script executed:

#!/bin/bash
# Check where WebSocketEventEmitter is exported from
rg -n 'export.*WebSocketEventEmitter' packages/core/src/observability/

Repository: VoltAgent/voltagent

Length of output: 200


🏁 Script executed:

#!/bin/bash
# Get the complete export statements from observability index
cat packages/core/src/observability/index.ts | grep -A5 'export {'

Repository: VoltAgent/voltagent

Length of output: 1258


🏁 Script executed:

#!/bin/bash
# Check what's exported that contains WebSocketEventEmitter or if it's only exported from processors
rg 'WebSocketEventEmitter' packages/core/src/observability/index.ts

Repository: VoltAgent/voltagent

Length of output: 87


🏁 Script executed:

#!/bin/bash
# Check the import statement in the test to see where WebSocketEventEmitter comes from
grep 'WebSocketEventEmitter' packages/core/src/agent/agent-observability.spec.ts

Repository: VoltAgent/voltagent

Length of output: 1160


🏁 Script executed:

#!/bin/bash
# Check if ObservabilityWebSocketEvent is exported from observability/index.ts
rg 'ObservabilityWebSocketEvent' packages/core/src/observability/index.ts

Repository: VoltAgent/voltagent

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Get all type exports from observability/index.ts
rg 'export type' packages/core/src/observability/index.ts

Repository: VoltAgent/voltagent

Length of output: 229


🏁 Script executed:

#!/bin/bash
# Get the complete import statement from the test file
head -10 packages/core/src/agent/agent-observability.spec.ts | grep -A5 'import.*observability'

Repository: VoltAgent/voltagent

Length of output: 334


🏁 Script executed:

#!/bin/bash
# Check the exact lines 216-219 and surrounding context
sed -n '214,225p' packages/core/src/agent/agent-observability.spec.ts

Repository: VoltAgent/voltagent

Length of output: 527


Import and use ObservabilityWebSocketEvent to maintain type safety.

Line 216 uses any[], which defeats TypeScript's compile-time safety. Replace with the proper event type already exported from the observability module:

  1. Add ObservabilityWebSocketEvent to the import from "../observability":

    import { NodeVoltAgentObservability, WebSocketEventEmitter, ObservabilityWebSocketEvent } from "../observability";
  2. Type the events array and callback parameter:

    const events: ObservabilityWebSocketEvent[] = [];
    const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event: ObservabilityWebSocketEvent) => {
      events.push(event);
    });

The ObservabilityWebSocketEvent interface is already defined and exported; no need for a local type definition. This applies to all similar instances in this test file.

As per coding guidelines: **/*.ts — Maintain type safety in TypeScript-first codebase.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/agent-observability.spec.ts` around lines 216 - 219,
Replace the untyped usage of events with the exported
ObservabilityWebSocketEvent type: add ObservabilityWebSocketEvent to the
existing import from "../observability" (alongside NodeVoltAgentObservability
and WebSocketEventEmitter), change the declaration const events: any[] to const
events: ObservabilityWebSocketEvent[], and type the callback parameter in
WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => ...) to (event:
ObservabilityWebSocketEvent) => { ... } ; apply the same replacement for any
other occurrences in this test file to restore TypeScript type-safety.


mockModel.doGenerate = async () => ({
finishReason: makeFinishReason("stop"),
usage: makeProviderUsage(10, 20),
content: [{ type: "text", text: "No extra usage" }],
warnings: [],
logprobs: undefined,
providerDetails: undefined,
});

const agent = new Agent({
name: "usage-agent",
purpose: "Testing llm usage emission",
instructions: "You are a usage test agent",
model: mockModel as any,
observability,
});

const result = await agent.generateText("Track usage");

expect(result.text).toBe("No extra usage");

const endSpans = events
.filter((event) => event.type === "span:end")
.map((event) => event.span);

const llmSpan = endSpans.find(
(span) =>
span.attributes["span.type"] === "llm" &&
span.attributes["llm.operation"] === "generateText",
);

expect(llmSpan).toBeDefined();
expect(llmSpan.attributes["llm.usage.prompt_tokens"]).toBe(10);
expect(llmSpan.attributes["llm.usage.completion_tokens"]).toBe(20);
expect(llmSpan.attributes["llm.usage.total_tokens"]).toBe(30);
expect(llmSpan.attributes["llm.usage.cached_tokens"]).toBeUndefined();
expect(llmSpan.attributes["llm.usage.reasoning_tokens"]).toBeUndefined();

unsubscribe();
});

it("should preserve root span provider cost when post-processing fails after a successful model call", async () => {
const events: any[] = [];
const unsubscribe = WebSocketEventEmitter.getInstance().onWebSocketEvent((event) => {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4265,10 +4265,10 @@ export class Agent {
if (totalTokens !== undefined) {
span.setAttribute("llm.usage.total_tokens", totalTokens);
}
if (cachedInputTokens !== undefined) {
if (cachedInputTokens !== undefined && cachedInputTokens > 0) {
span.setAttribute("llm.usage.cached_tokens", cachedInputTokens);
}
if (reasoningTokens !== undefined) {
if (reasoningTokens !== undefined && reasoningTokens > 0) {
span.setAttribute("llm.usage.reasoning_tokens", reasoningTokens);
}
}
Expand Down
82 changes: 82 additions & 0 deletions packages/core/src/agent/prompt-context-usage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,86 @@ describe("prompt context usage estimation", () => {
"usage.prompt_context.tool_count": 2,
});
});

it("sanitizes nested binary args recursively and ignores provider-only metadata", () => {
const circularArgsA: Record<string, unknown> = {
content: {
metadata: {
data: "x".repeat(8_000),
},
},
attachments: [{ image: "y".repeat(8_000) }],
};
circularArgsA.self = circularArgsA;

const circularArgsB: Record<string, unknown> = {
content: {
metadata: {
data: "short",
},
},
attachments: [{ image: "tiny" }],
};
circularArgsB.self = circularArgsB;

const toolAEstimate = estimatePromptContextUsage({
tools: {
searchDocs: {
description: "Search the documentation",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ answer: z.string() }),
providerOptions: {
openai: {
metadata: "provider-only".repeat(2_000),
},
},
needsApproval: true,
args: circularArgsA,
},
},
});

const toolBEstimate = estimatePromptContextUsage({
tools: {
searchDocs: {
description: "Search the documentation",
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ answer: z.string() }),
providerOptions: {
openai: {
metadata: "ignored",
},
},
needsApproval: false,
args: circularArgsB,
},
},
});

expect(toolAEstimate?.toolTokensEstimated).toBeGreaterThan(0);
expect(toolAEstimate?.toolTokensEstimated).toBe(toolBEstimate?.toolTokensEstimated);
});

it("ignores non-plain args values when estimating tool tokens", () => {
const withArrayArgs = estimatePromptContextUsage({
tools: {
searchDocs: {
description: "Search the documentation",
inputSchema: z.object({ query: z.string() }),
args: ["x".repeat(10_000)],
},
},
});

const withoutArgs = estimatePromptContextUsage({
tools: {
searchDocs: {
description: "Search the documentation",
inputSchema: z.object({ query: z.string() }),
},
},
});

expect(withArrayArgs?.toolTokensEstimated).toBe(withoutArgs?.toolTokensEstimated);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This equality-only assertion can pass when both estimates are undefined, so the test may miss regressions in token estimation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/agent/prompt-context-usage.spec.ts, line 151:

<comment>This equality-only assertion can pass when both estimates are undefined, so the test may miss regressions in token estimation.</comment>

<file context>
@@ -68,4 +68,86 @@ describe("prompt context usage estimation", () => {
+      },
+    });
+
+    expect(withArrayArgs?.toolTokensEstimated).toBe(withoutArgs?.toolTokensEstimated);
+  });
 });
</file context>
Suggested change
expect(withArrayArgs?.toolTokensEstimated).toBe(withoutArgs?.toolTokensEstimated);
expect(withArrayArgs?.toolTokensEstimated).toBeGreaterThan(0);
expect(withoutArgs?.toolTokensEstimated).toBeGreaterThan(0);
expect(withArrayArgs?.toolTokensEstimated).toBe(withoutArgs?.toolTokensEstimated);
Fix with Cubic

});
});
60 changes: 56 additions & 4 deletions packages/core/src/agent/prompt-context-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const BINARY_PART_TYPES = new Set([
"media",
]);
const LARGE_BINARY_KEYS = new Set(["audio", "base64", "bytes", "data", "image"]);
const CIRCULAR_REFERENCE_PLACEHOLDER = "[circular]";

type PromptMessage = {
role?: string;
Expand Down Expand Up @@ -164,12 +165,25 @@ function serializePromptValue(value: unknown): string {
}

function sanitizeRecord(record: Record<string, unknown>): Record<string, unknown> {
return sanitizeRecordValue(record, new Set<object>());
}

function sanitizeRecordValue(
record: Record<string, unknown>,
seen: Set<object>,
): Record<string, unknown> {
if (seen.has(record)) {
return { circular: CIRCULAR_REFERENCE_PLACEHOLDER };
}

seen.add(record);
const sanitized: Record<string, unknown> = {};

for (const [key, value] of Object.entries(record)) {
sanitized[key] = LARGE_BINARY_KEYS.has(key) ? "[omitted]" : value;
sanitized[key] = LARGE_BINARY_KEYS.has(key) ? "[omitted]" : sanitizeValue(value, seen);
}

seen.delete(record);
Comment on lines +168 to +186
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Circular protection still misses recursive content traversal.

Line 157 recursively calls serializePromptValue(record.content) without seen tracking, so payloads like a.content = b; b.content = a can still hit infinite recursion before the sanitizer fallback executes.

Proposed fix
-function serializePromptValue(value: unknown): string {
+function serializePromptValue(value: unknown, seen: Set<object> = new Set()): string {
   if (typeof value === "string") {
     return value;
   }

   if (typeof value === "number" || typeof value === "boolean") {
     return String(value);
   }

   if (Array.isArray(value)) {
-    return value
-      .map((entry) => serializePromptValue(entry))
-      .filter((entry) => entry.trim().length > 0)
-      .join("\n");
+    if (seen.has(value)) return CIRCULAR_REFERENCE_PLACEHOLDER;
+    seen.add(value);
+    try {
+      return value
+        .map((entry) => serializePromptValue(entry, seen))
+        .filter((entry) => entry.trim().length > 0)
+        .join("\n");
+    } finally {
+      seen.delete(value);
+    }
   }

   if (!value || typeof value !== "object") {
     return "";
   }
+  if (seen.has(value as object)) return CIRCULAR_REFERENCE_PLACEHOLDER;
+  seen.add(value as object);

-  const record = value as Record<string, unknown>;
-  const type = typeof record.type === "string" ? record.type : undefined;
+  try {
+    const record = value as Record<string, unknown>;
+    const type = typeof record.type === "string" ? record.type : undefined;

-  if (typeof record.text === "string") {
-    return record.text;
-  }
+    if (typeof record.text === "string") {
+      return record.text;
+    }

-  if (type && BINARY_PART_TYPES.has(type)) {
-    return `[${type}]`;
-  }
+    if (type && BINARY_PART_TYPES.has(type)) {
+      return `[${type}]`;
+    }

-  if (type === "tool-call") {
-    const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
-    const input = serializePromptValue(record.input);
-    return input ? `tool-call ${toolName}: ${input}` : `tool-call ${toolName}`;
-  }
+    if (type === "tool-call") {
+      const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
+      const input = serializePromptValue(record.input, seen);
+      return input ? `tool-call ${toolName}: ${input}` : `tool-call ${toolName}`;
+    }

-  if (type === "tool-result") {
-    const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
-    const output = serializePromptValue(record.output);
-    return output ? `tool-result ${toolName}: ${output}` : `tool-result ${toolName}`;
-  }
+    if (type === "tool-result") {
+      const toolName = typeof record.toolName === "string" ? record.toolName : "tool";
+      const output = serializePromptValue(record.output, seen);
+      return output ? `tool-result ${toolName}: ${output}` : `tool-result ${toolName}`;
+    }

-  if ("content" in record) {
-    const nestedContent = serializePromptValue(record.content);
-    if (nestedContent) {
-      return nestedContent;
+    if ("content" in record) {
+      const nestedContent = serializePromptValue(record.content, seen);
+      if (nestedContent) {
+        return nestedContent;
+      }
     }
-  }

-  return safeStringify(sanitizeRecord(record));
+    return safeStringify(sanitizeRecord(record));
+  } finally {
+    seen.delete(value as object);
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/agent/prompt-context-usage.ts` around lines 168 - 186,
sanitizeRecordValue currently recurses into nested content via
serializePromptValue(record.content) without passing the seen Set, which allows
circular structures (e.g., a.content = b; b.content = a) to recurse infinitely;
fix by threading the seen set through the recursion — either call
sanitizeValue(record.content, seen) instead of
serializePromptValue(record.content) from inside sanitizeRecordValue, or extend
serializePromptValue to accept a seen: Set<object> parameter and pass it along
from sanitizeRecordValue so circular detection is preserved (update related
callers accordingly).

return sanitized;
}

Expand Down Expand Up @@ -200,9 +214,7 @@ function serializeToolDefinition(name: string, tool: unknown): Record<string, un
outputSchema: normalizeSchema(candidate.outputSchema ?? candidate.output_schema),
}
: {}),
...(candidate.providerOptions ? { providerOptions: candidate.providerOptions } : {}),
...(candidate.args ? { args: sanitizeRecord(candidate.args as Record<string, unknown>) } : {}),
...(candidate.needsApproval !== undefined ? { needsApproval: candidate.needsApproval } : {}),
...(isPlainObject(candidate.args) ? { args: sanitizeRecord(candidate.args) } : {}),
};
}

Expand All @@ -221,3 +233,43 @@ function normalizeSchema(schema: unknown): unknown {

return schema;
}

function sanitizeValue(value: unknown, seen: Set<object>): unknown {
if (value === null || value === undefined) {
return value;
}

if (typeof value !== "object") {
return value;
}

if (value instanceof Date || value instanceof RegExp) {
return value;
}

if (Array.isArray(value)) {
if (seen.has(value)) {
return [CIRCULAR_REFERENCE_PLACEHOLDER];
}

seen.add(value);
const sanitized = value.map((entry) => sanitizeValue(entry, seen));
seen.delete(value);
return sanitized;
}

if (!isPlainObject(value)) {
return value;
}

return sanitizeRecordValue(value, seen);
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}

const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
Loading