Skip to content

test(core): measure what an agent step costs - #56423

Open
dat-rd wants to merge 1 commit into
anomalyco:devfrom
dat-rd:agent-cost-eval
Open

dat-rd wants to merge 1 commit into
anomalyco:devfrom
dat-rd:agent-cost-eval

Conversation

@dat-rd

@dat-rd dat-rd commented Oct 17, 2026 •

Copy link
Copy Markdown

Issue for this PR

Closes #56422

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds a way to measure what an agent step costs, so changes to context assembly, the cache policy, or compaction can be judged by their effect on the bill instead of by whether a marker still landed where a test expected one.

test/fixture/llm-meter.ts is a provider stand-in that counts the tokens of the request it is handed and reports a cache read only for a prefix an earlier request already wrote, read off the same CacheHint markers cache-policy.ts places. It prices both sides of the cache: a read costs 0.1 uncached input tokens and a write 1.25, Anthropic's five-minute multipliers. Writes matter for the questions this is meant to answer: every moving tail marker writes the new turn, so a meter that treats writes as free makes placing more markers look free.

bill(requests) prices a sequence of real requests with the same accounting meter() uses, so a test can meter what the runner actually sent instead of rebuilding the request by hand.

The two meter test files are now one (test/llm-meter.test.ts), and the policy table is reduced to the comparisons that survive a cost-model change: auto beats no tail, no tail beats none, and none reads nothing back. Ranking auto against tail: 2 and tail: 3 was removed because it would fail a future policy that is cheaper on real bills.

Measured over 20 steps with the shipped policy and a caching provider (input cost in uncached-token units; output is identical across policies and excluded):

policy reuse input tokens input cost vs auto
auto (tools + system + one tail) 91.1% 10,765 2,172 —
system + tools, no tail 17.6% 10,765 9,080 +318%
none 0% 10,765 10,765 +396%

The compaction case now meters SessionRunner's own requests. The summary request re-sends the old history; the step after it carries the checkpoint. In a 4k-window session the summary request sent 2,838 input tokens; the step after it sent 1,605, of which 388 were read from the prefix the summary request had just written — 43% fewer input tokens. A 785-character summary stands in for the model's output, so a summary that grew past what it replaced would fail the input comparison.

How did you verify your code works?

  • bun run script/test.ts test/llm-meter.test.ts test/session-runner.test.ts — 261 pass, 0 fail.
  • Full packages/core suite: 7,463 pass, 0 fail, 28 skipped across 323 files.
  • bun run typecheck:tsc in packages/core — clean.
  • oxlint — 0 warnings on the changed files.
  • The write price is checked: setting it to 1 (free) fails the write-pricing case.
  • The compaction comparison uses the runner's requests, not the test's prompts: the step's own input is compared to the summary request's, and the summary is 785 characters rather than a one-line stub.

Prettier is clean on the fixture and the meter test. session-runner.test.ts has one pre-existing formatting difference at line 2091, unrelated to this change, which I left untouched.

No production code is touched. The meter lives in test/fixture/ and only reads requests the runner already produced.

Screenshots / recordings

None. No user-visible change.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

@opencode-agent opencode-agent Bot left a comment

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.

The new tests pass and type-check cleanly, but I'd like changes before this goes in: the cost model leaves out cache writes, and the compaction test checks almost nothing about compaction.

What I ran: bun test test/llm-meter.test.ts test/llm-meter-step.test.ts passed 12 of 12, the new session-runner case passes, and bun run typecheck in packages/core is clean. Public API: unchanged (test files only).

Concerns:

  1. Cache writes are free in the meter (see the inline comment). Real Anthropic billing charges extra for writes, and every tail marker writes on every step. So the policy table, the "default is cheapest" test and the "+88% with no caching" figure all favour placing more markers. Write cost is also what #48542 and #52761 are about.
  2. The meter never sees a request from the runner. Both meter test files build requests by calling applyCachePolicy directly on a fixed prompt, and the one SessionRunner case doesn't use the meter. So it measures the policy function on synthetic input, not what an agent step costs. The numbers in the description (68,400 → 3,819 characters, the 20-step table) aren't checked by any test.
  3. The compaction case mostly checks its own inputs (see the inline comment).
  4. shows the default policy is the cheapest… locks today's "auto" choice in under this simplified cost model, so a later policy change that is cheaper on real bills could fail it.

I'd suggest cutting this down to one meter test file that models cache writes, plus either a case that runs the meter through the runner or no runner case at all. The two new files largely repeat each other and packages/ai/test/cache-policy.test.ts.

Related: #48542, #52761, #51818, #55243

Comment thread packages/core/test/fixture/llm-meter.ts Outdated
inputTokens: total,
nonCachedInputTokens: uncached,
cacheReadInputTokens: cached,
cacheWriteInputTokens: 0,

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.

Cache writes are always reported as 0, and relativeCost has no write price. Anthropic charges more for a cache write than for normal input, and every tail marker writes on every step. Without that cost, the meter favours placing more markers: "none" looks much more expensive than it is, and placing more markers looks free. If the meter is meant to judge cache policies by their cost, it needs to count the tokens each marker writes and price them.

const compaction = context.find((message) => message.type === "compaction")
expect(compaction?.type === "compaction" && compaction.status === "completed").toBe(true)

const original = "Earlier question ".repeat(180).length + "Recent exact request ".repeat(180).length

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.

This checks very little about compaction:

  • original is a constant built from the test's own prompts.
  • The summary is the scripted "## Objective\n- measured summary", so replacement < original holds whatever compaction does.
  • billable counts only text, not tools, and the scripted summary is tiny, so the last check passes for any reasonable runner too.

It wouldn't catch a growing recent (#51818) or a summary request that costs more than it saves. Either run it through the meter with a realistic summary size, or drop the case.

expect(withPolicy.uncached()).toBeLessThan(without.uncached() / 4)
})

it("shows the default policy is the cheapest of the placements that work", () => {

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.

This freezes today's "auto" placement as the cheapest under this cost model, which leaves out cache writes. A later policy change that is cheaper on real bills could fail this test. I'd keep only the "none" vs "auto" vs no-tail comparison, or drop the ranking.

The meter prices cache reads at a tenth of uncached input and writes at 1.25, so a policy
that marks more prefixes is not treated as free. bill() applies the same accounting to a
sequence of real requests, and the compaction case uses it on SessionRunner's own summary
and post-compaction requests instead of summing the test's prompt lengths.
@dat-rd

dat-rd commented Nov 13, 2026

Copy link
Copy Markdown
Author

@opencode-agent All four points are addressed in the new head (d6b9ebd5a):

  1. Cache writes are priced. The meter now models Anthropic's five-minute multipliers: a read costs 0.1 uncached input tokens, a write 1.25. It charges a write for the tokens between the prefix an earlier request wrote and this request's furthest breakpoint, and full price for the rest. A dedicated case sends the same request twice and checks that the first write costs 1.25 × input and the second read costs 0.1 × input; making writes free fails that case.

  2. One meter test file. llm-meter-step.test.ts is gone. Its useful cases moved into llm-meter.test.ts, which now covers accounting and step cost without repeating the same prompt setup.

  3. The compaction case meters the runner's requests. It now calls bill() on the summary request and the post-compaction request that SessionRunner produced, with the cache policy the compiler applies. Measured: summary request 2,838 input tokens, following step 1,605 (388 read from the shared prefix), 43% fewer. The summary is 785 characters instead of a one-line stub, and the test no longer compares a constant built from its own prompts or counts characters.

  4. The ranking is gone. Only auto vs no-tail vs none is left, plus the invariant that none reads nothing back. No test asserts that the current auto placement is the cheapest of all placements.

For reference, the measured 20-step numbers with writes included are in the PR description: auto 2,172 input-cost units, no-tail 9,080, none 10,765. The full packages/core suite passes (7,463 pass, 0 fail, 28 skipped).

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Measure what an agent step costs, so cache and compaction changes can be judged by their effect

1 participant