-
Notifications
You must be signed in to change notification settings - Fork 607
fix(runner): stop reporting context-window size as a run's token usage #5710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -1163,7 +1163,9 @@ export function createSandboxAgentOtel( | |||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| function stampUsage(span: Span, u: AgentUsage | undefined): void { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!u) return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| // No tokens and no cost is not a measured zero, it is the absence of a measurement: | ||||||||||||||||||||||||||||||||||||||||||||||||
| // stamping zeros would assert a run cost nothing and spent nothing. | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!u || (u.total <= 0 && u.cost <= 0)) return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| span.setAttribute("gen_ai.usage.input_tokens", u.input); | ||||||||||||||||||||||||||||||||||||||||||||||||
| span.setAttribute("gen_ai.usage.output_tokens", u.output); | ||||||||||||||||||||||||||||||||||||||||||||||||
| span.setAttribute("gen_ai.usage.prompt_tokens", u.input); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -1446,16 +1448,19 @@ export function createSandboxAgentOtel( | |||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (kind === "usage_update") { | ||||||||||||||||||||||||||||||||||||||||||||||||
| // ACP usage_update carries only `used` (context tokens) and `cost.amount`. The | ||||||||||||||||||||||||||||||||||||||||||||||||
| // per-call input/output split is NOT on the stream; it rides on the PromptResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||
| // which the sandbox-agent engine reads. Keep total + cost here and leave the split to the caller. | ||||||||||||||||||||||||||||||||||||||||||||||||
| // ACP usage_update carries `used` and `cost.amount`. `used` is the agent's CONTEXT-WINDOW | ||||||||||||||||||||||||||||||||||||||||||||||||
| // occupancy at this point in the turn, NOT the tokens this run spent, so it is dropped: | ||||||||||||||||||||||||||||||||||||||||||||||||
| // reporting it as a total produced runs shaped `input 0 / output 0 / total <context size>`. | ||||||||||||||||||||||||||||||||||||||||||||||||
| // The token split is not on the stream at all; it rides on the PromptResponse, which the | ||||||||||||||||||||||||||||||||||||||||||||||||
| // sandbox-agent engine reads and hands back through `setUsage`. Cost is the only run total | ||||||||||||||||||||||||||||||||||||||||||||||||
| // here, so an update without one carries nothing worth recording. | ||||||||||||||||||||||||||||||||||||||||||||||||
| const cost = update.cost?.amount; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const total = update.used; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (typeof cost !== "number") return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| usage = { | ||||||||||||||||||||||||||||||||||||||||||||||||
| input: usage?.input ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||
| output: usage?.output ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||
| total: typeof total === "number" ? total : usage?.total ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||
| cost: typeof cost === "number" ? cost : usage?.cost ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||
| total: usage?.total ?? 0, | ||||||||||||||||||||||||||||||||||||||||||||||||
| cost, | ||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||
| record({ type: "usage", ...usage }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
1457
to
1465
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Skip all-zero When Build the next usage value first. Record it only when its total tokens or cost is positive. Add a regression test for Proposed fix const cost = update.cost?.amount;
if (typeof cost !== "number") return;
-usage = {
+const nextUsage = {
input: usage?.input ?? 0,
output: usage?.output ?? 0,
total: usage?.total ?? 0,
cost,
};
+if (nextUsage.total <= 0 && nextUsage.cost <= 0) return;
+usage = nextUsage;
record({ type: "usage", ...usage });📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| /** | ||
| * Unit tests for how the sandbox-agent ACP tracer treats `usage_update`. | ||
| * | ||
| * ACP's `usage_update.used` is the agent's context-window occupancy, not the tokens a run | ||
| * spent. Reporting it as a token total produced runs shaped `input 0 / output 0 / total | ||
| * <context size>` with no cost — a number a reader cannot tell apart from a real total. These | ||
| * tests pin that the context size never reaches a `usage` event, `usage()`, or a span, while | ||
| * the harness-reported split (delivered through `setUsage`) still does. | ||
| * | ||
| * Spans export over OTLP from a module-level provider, so we spy on the OTel API tracer and | ||
| * capture what each span records (same approach as otel-skills-error.test.ts). | ||
| * | ||
| * Run: pnpm test (or: pnpm exec vitest run tests/unit/otel-usage-context-size.test.ts) | ||
| */ | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
| import { trace, type Span } from "@opentelemetry/api"; | ||
|
|
||
| import { createSandboxAgentOtel } from "../../src/tracing/otel.ts"; | ||
| import type { AgentEvent } from "../../src/protocol.ts"; | ||
|
|
||
| interface FakeSpan { | ||
| name: string; | ||
| attributes: Record<string, unknown>; | ||
| } | ||
|
|
||
| /** Replace the OTel tracer so every span built records into a captured array. */ | ||
| function spyTracer(): FakeSpan[] { | ||
| const spans: FakeSpan[] = []; | ||
| const makeSpan = (name: string): Span => { | ||
| const span: FakeSpan = { name, attributes: {} }; | ||
| spans.push(span); | ||
| const api = { | ||
| setAttribute(key: string, value: unknown) { | ||
| span.attributes[key] = value; | ||
| return api; | ||
| }, | ||
| setAttributes(attrs: Record<string, unknown>) { | ||
| Object.assign(span.attributes, attrs); | ||
| return api; | ||
| }, | ||
| recordException() {}, | ||
| setStatus() { | ||
| return api; | ||
| }, | ||
| end() {}, | ||
| spanContext() { | ||
| return { | ||
| traceId: "0".repeat(32), | ||
| spanId: "0".repeat(16), | ||
| traceFlags: 1, | ||
| }; | ||
| }, | ||
| isRecording: () => true, | ||
| addEvent: () => api, | ||
| updateName: () => api, | ||
| }; | ||
| return api as unknown as Span; | ||
| }; | ||
| vi.spyOn(trace, "getTracer").mockReturnValue({ | ||
| startSpan: (name: string) => makeSpan(name), | ||
| startActiveSpan: ((name: string, fn: (s: Span) => unknown) => | ||
| fn(makeSpan(name))) as any, | ||
| } as any); | ||
| return spans; | ||
| } | ||
|
|
||
| const contextSizeUpdate = (used: number, cost?: number) => ({ | ||
| sessionUpdate: "usage_update", | ||
| used, | ||
| ...(cost === undefined ? {} : { cost: { amount: cost } }), | ||
| }); | ||
|
|
||
| const usageKeys = (span: FakeSpan | undefined) => | ||
| Object.keys(span?.attributes ?? {}).filter((k) => | ||
| k.startsWith("gen_ai.usage."), | ||
| ); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("usage_update carries context size, not tokens", () => { | ||
| it("reports nothing when the stream knows only the context size", () => { | ||
| const spans = spyTracer(); | ||
| const emitted: AgentEvent[] = []; | ||
| const otel = createSandboxAgentOtel({ | ||
| harness: "claude", | ||
| model: "anthropic/claude-haiku", | ||
| emit: (e) => emitted.push(e), | ||
| }); | ||
| otel.start({ prompt: "hi" }); | ||
| otel.handleUpdate(contextSizeUpdate(63369)); | ||
|
|
||
| expect(otel.usage()).toBeUndefined(); | ||
| expect(emitted.filter((e) => e.type === "usage")).toEqual([]); | ||
|
|
||
| // The engine finds no harness split either, so nothing overrides the stream before finish. | ||
| otel.setUsage(undefined); | ||
| otel.finish(); | ||
| expect(usageKeys(spans.find((s) => s.name === "invoke_agent"))).toEqual([]); | ||
| expect( | ||
| emitted.some((e) => e.type === "usage"), | ||
| "no usage event anywhere in the run", | ||
| ).toBe(false); | ||
| }); | ||
|
|
||
| it("keeps the stream cost, without inventing a token total from the context size", () => { | ||
| const spans = spyTracer(); | ||
| const otel = createSandboxAgentOtel({ | ||
| harness: "claude", | ||
| model: "anthropic/claude-haiku", | ||
| }); | ||
| otel.start({ prompt: "hi" }); | ||
| otel.handleUpdate(contextSizeUpdate(63369, 0.04)); | ||
|
|
||
| expect(otel.usage()).toEqual({ input: 0, output: 0, total: 0, cost: 0.04 }); | ||
| otel.finish(); | ||
| const agentSpan = spans.find((s) => s.name === "invoke_agent"); | ||
| expect(agentSpan?.attributes["gen_ai.usage.cost"]).toBe(0.04); | ||
| expect(agentSpan?.attributes["gen_ai.usage.total_tokens"]).toBe(0); | ||
| }); | ||
|
|
||
| it("emits a token-only usage event with no cost key when the harness priced nothing", () => { | ||
| const spans = spyTracer(); | ||
| const emitted: AgentEvent[] = []; | ||
| const otel = createSandboxAgentOtel({ | ||
| harness: "codex", | ||
| model: "openai-codex/gpt-5.5", | ||
| emit: (e) => emitted.push(e), | ||
| emitSpans: true, | ||
| }); | ||
| otel.start({ prompt: "hi" }); | ||
| // No `usage_update` at all — codex reports a token split but never a cost. | ||
| otel.setUsage({ input: 12, output: 3, total: 15 }); | ||
| otel.finish(); | ||
|
|
||
| const usageEvent = emitted.find((e) => e.type === "usage") as Record< | ||
| string, | ||
| unknown | ||
| >; | ||
| expect(usageEvent).toEqual({ | ||
| type: "usage", | ||
| input: 12, | ||
| output: 3, | ||
| total: 15, | ||
| }); | ||
| expect("cost" in usageEvent).toBe(false); | ||
|
|
||
| // Nothing downstream may see a zero: the agent span carries no cost either. | ||
| const agentSpan = spans.find((s) => s.name === "invoke_agent"); | ||
| expect(agentSpan?.attributes["gen_ai.usage.cost"]).toBeUndefined(); | ||
| const chatSpan = spans.find((s) => s.name.startsWith("chat")); | ||
| expect(chatSpan?.attributes["gen_ai.usage.total_tokens"]).toBe(15); | ||
| expect(chatSpan?.attributes["gen_ai.usage.cost"]).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("stamps the harness-reported split, which arrives through setUsage", () => { | ||
| const spans = spyTracer(); | ||
| const otel = createSandboxAgentOtel({ | ||
| harness: "claude", | ||
| model: "anthropic/claude-haiku", | ||
| }); | ||
| otel.start({ prompt: "hi" }); | ||
| otel.handleUpdate(contextSizeUpdate(63369, 0.04)); | ||
| otel.setUsage({ input: 12, output: 3, total: 15, cost: 0.04 }); | ||
| otel.finish(); | ||
|
|
||
| const agentSpan = spans.find((s) => s.name === "invoke_agent"); | ||
| expect(agentSpan?.attributes["gen_ai.usage.input_tokens"]).toBe(12); | ||
| expect(agentSpan?.attributes["gen_ai.usage.output_tokens"]).toBe(3); | ||
| expect(agentSpan?.attributes["gen_ai.usage.total_tokens"]).toBe(15); | ||
| expect(agentSpan?.attributes["gen_ai.usage.cost"]).toBe(0.04); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: Agenta-AI/agenta
Length of output: 9875
🏁 Script executed:
Repository: Agenta-AI/agenta
Length of output: 407
Normalize absent cost in
stampUsage.stampUsagechecksu.total <= 0 && u.cost <= 0, butu.costis optional. For{ input: 0, output: 0, total: 0 }with nocost,undefined <= 0is false, so the guard falls through and writes zero token attributes. Useconst cost = u.cost ?? 0for the empty-record guard, and keep theu.cost > 0check when stamping thegen_ai.usage.costattribute. Add a regression case for{ input: 0, output: 0, total: 0 }with nocost.Source: Coding guidelines