adk-tracegauge Cost Regression Gate for ADK agents¶
adk-tracegauge is a
statistically-validated CI cost-regression gate for ADK agents: snapshot a real
per-invocation USD cost distribution from an eval run, and fail the build only when a cost
increase is both statistically and practically significant. It also registers a real
per-invocation PASS/FAIL dollar-cost threshold metric inside adk eval itself — useful
for inline cost visibility while iterating, and complementary to (not a replacement for) the
CI gate.
ADK's built-in evaluation metrics report quality — trajectory match, response similarity, safety, hallucination — but none of them report cost. This fills that gap.
The CI gate (
adk-tracegauge check) is the primary, recommended path — not theadk evalmetric below.adk eval's own process exit code does not reflect PASSED/FAILED (see Known ADK-side limitations), so it cannot gate a CI job on its own;adk-tracegauge checkhas its own real, distinguishable exit codes and is proven to work standalone. See Use with agent below — the metric path still requires the plugin to be wired in either way.
Use cases¶
- Cost regression gates (primary):
adk-tracegauge checkfails CI when a prompt or model change measurably increases mean cost per invocation, using a percentile bootstrap — not a naive point-estimate delta — so a build doesn't fail on noise. - Inline cost visibility during eval iteration: the
adk_tracegauge_cost_usdmetric reports a real dollar score and PASSED/FAILED verdict per invocation directly inadk evaloutput, against a threshold you set. - Model/config comparison: compare real dollar cost across model choices
(
gemini-2.5-flashvs.gemini-2.5-pro) or prompt variants on the same eval set.
Prerequisites¶
- Python 3.10 or later
google-adk[eval]>=2.6.0,<2.8.0— the[eval]extra is required, not optional (see Installation)- A priced model: any current-generation Gemini model (ADK's native backend), or
Claude/GPT reached through ADK's
LiteLlmintegration (e.g.model="anthropic/claude-opus-5"), or a local/self-hosted model (Ollama, vLLM) with an explicit opt-in — see Available metrics for the full pricing scope and how unresolved models are handled.
Installation¶
If adk-tracegauge isn't found right after installing (CommandNotFoundException in
PowerShell, "not recognized" in cmd on Windows), this is a PATH issue, not a broken install —
pip install outside a venv defaults to a per-user install, and the console script lands in a
per-user directory that isn't on PATH by default. Two fixes: use python -m adk_tracegauge in
place of adk-tracegauge everywhere on this page (works regardless of PATH, from
adk-tracegauge>=0.3.1), or add the script directory pip install printed a WARNING about
(typically %APPDATA%\Python\PythonXYZ\Scripts on Windows) to PATH. Installing into an
activated virtual environment avoids this entirely.
google-adk[eval] is pulled in as a dependency; adk-tracegauge has no other required
runtime dependency (its dollar-cost arithmetic is implemented in-house — it does not depend
on any external cost-computation library). The [eval] extra is required: google-adk's
own evaluator registry unconditionally imports every built-in evaluator at module load time,
including ones that need pandas/jinja2/rouge-score — without the extra, import
adk_tracegauge fails with ModuleNotFoundError: No module named 'pandas'.
Use with agent¶
The plugin has to capture real token usage before either path (the CLI gate or the adk
eval metric) has anything to price. Wire after_model_callback directly onto your agent —
this is the only integration code either path needs:
from google.adk.agents.llm_agent import LlmAgent
import adk_tracegauge # registers the adk_tracegauge_cost_usd metric as an import side effect
from adk_tracegauge import TraceGaugeUsagePlugin
_usage_plugin = TraceGaugeUsagePlugin()
root_agent = LlmAgent(
name="assistant",
model="gemini-2.5-flash",
instruction="You are a helpful assistant.",
after_model_callback=_usage_plugin.after_model_callback,
)
This works with the standard adk eval CLI directly — no App/plugin-list wiring needed
for the primary paths (adk-tracegauge check or the adk eval metric). A separate,
hand-rolled App(plugins=[...]) harness is only needed for the optional sub-agent
cost-rollup pattern described in the project's own README — out of scope for this page.
The CI cost-regression gate: adk-tracegauge check¶
adk-tracegauge (the console script this package installs) has two subcommands: snapshot
(persist a UsageStore's priced invocations to a JSON file) and check (a percentile
bootstrap comparing two snapshots). Write a zero-argument entrypoint that runs your eval —
AgentEvaluator.evaluate(), a real adk eval CLI invocation, or your own harness — with
the plugin wired in per the previous section, then:
adk-tracegauge snapshot --entrypoint my_eval_suite:run_and_return_store --output baseline.json
adk-tracegauge snapshot --entrypoint my_eval_suite:run_and_return_store --output current.json
adk-tracegauge check --baseline baseline.json --current current.json
adk-tracegauge check exits 0 (no significant regression), 1 (regression: the cost
increase is both statistically significant — the bootstrap confidence interval excludes
zero — and clears a configurable practical-significance floor), or 3 (insufficient
data — fewer than --min-n, default 30, priced invocations in either snapshot; a bootstrap
CI is not statistically meaningful below that). Every run also prints its own achieved
statistical power — the smallest cost increase the bootstrap test could reliably (80%
power) have detected given that run's own observed variance and sample size — with an
explicit warning whenever your configured significance floor is smaller than that
achievable figure. See the project's own README ("Known limitations") for the full,
honestly-reported detection-power numbers this estimate is validated against.
Paired mode: the default, whenever a pairing key resolves¶
At a realistic ADK eval-set size (tens of cases, not hundreds), an unpaired two-sample
comparison can be substantially underpowered. adk-tracegauge check defaults to
--mode auto, and that default prefers a paired bootstrap — the same before/after eval
case compared against itself, cancelling case-to-case cost variance rather than averaging
over it — whenever a stable pairing key resolves with at least --min-n (default 30)
overlapping cases between the two snapshots. Paired mode is dramatically more sensitive at
the same n whenever real per-case cost variance exists; only when no such key
resolves, or too few cases overlap, does check automatically fall back to the two-sample
comparison — never silently: the resolved mode and key are printed on every run.
Pairing needs a stable key that identifies "the same eval case" across both runs. For the
standard adk eval CLI workflow, that key is each case's own authored eval_id from the
.evalset.json file, recovered by pointing adk-tracegauge snapshot --eval-history at the
.evalset_result.json file adk eval writes after every run. No flag is needed to opt in —
--mode auto finds and uses this key automatically; --mode paired/--mode two-sample
remain available to force one method by name (--mode paired fails loudly, naming the
actual overlap count, rather than silently falling back, if too few cases match).
One real detail that matters here: after_model_callback only ever populates an
in-memory store — it does not survive a plain shell adk eval process exiting. So the
entrypoint your adk-tracegauge snapshot command runs must invoke the same underlying
evaluation call in-process (via click.testing.CliRunner against cli_eval, the exact
function adk eval itself runs), not shell out to adk eval as a separate step — otherwise
the snapshot step sees an empty store. A minimal entrypoint that does this correctly:
# my_eval_suite.py
from __future__ import annotations
from pathlib import Path
_EVAL_SET = "eval_data/my_eval_set.evalset.json"
_CONFIG = "test_config.json"
def _run_adk_eval_in_process(agent_dir: str, dest: str) -> None:
from click.testing import CliRunner
from google.adk.cli.cli_tools_click import cli_eval
history_dir = Path(agent_dir) / ".adk" / "eval_history"
for f in history_dir.glob("*.evalset_result.json"):
f.unlink() # start from a clean slate so exactly one new file exists after
result = CliRunner().invoke(
cli_eval, [agent_dir, _EVAL_SET, "--config_file_path", _CONFIG], catch_exceptions=False
)
if result.exit_code != 0:
raise RuntimeError(f"adk eval failed:\n{result.output}")
# adk eval names this file with a timestamp -- copy it to a fixed,
# predictable path so --eval-history below can name it ahead of time.
[new_file] = sorted(history_dir.glob("*.evalset_result.json"))
Path(dest).write_bytes(new_file.read_bytes())
def run_baseline():
_run_adk_eval_in_process("baseline_agent_app", "baseline.evalset_result.json")
def run_current():
_run_adk_eval_in_process("current_agent_app", "current.evalset_result.json")
adk-tracegauge snapshot --entrypoint my_eval_suite:run_baseline --output baseline.json \
--eval-history baseline.evalset_result.json
adk-tracegauge snapshot --entrypoint my_eval_suite:run_current --output current.json \
--eval-history current.evalset_result.json
adk-tracegauge check --baseline baseline.json --current current.json
Note there is no --mode flag above — check defaults to --mode auto, which resolves
eval_case_id from the two --eval-history files and selects paired mode on its own. Real
output, from a genuine injected regression (32-case eval set, a fixed per-call prompt-token
bump added to the "current" agent variant — above the real default --min-n=30, a genuine
gate-passing verdict, not a demo that bypasses the real refusal floor), re-verified fresh
this session against a clean-built adk-tracegauge wheel installed into a fresh venv outside
any repo checkout, with google-adk==2.7.0:
adk-tracegauge check: mode=paired (key=eval_case_id, 32 overlapping eval_case_ids matched between baseline and current)
adk-tracegauge check [method=paired]: n_baseline=32 n_current=32 (min_n=30)
mean_baseline=$0.005306 mean_current=$0.007106
achieved power: minimum reliably-detectable effect at 80% power, given this run's observed variance/n, is ~$0.000000 (+0.00% of mean baseline) [normal approximation to the bootstrap CI -- see _regression.py module docstring for validated accuracy]
observed effect: +0.001800 USD (+33.93%), 98% CI [+0.001800, +0.001800] (n_boot=10000, seed=42)
statistically_significant=True practically_significant=True (floors: min_effect_usd=0.000100 OR min_effect_pct=5.00%)
REGRESSION: cost increased significantly (CI excludes zero) AND the increase clears the configured practical-significance floor.
Measured false-positive rate for the shipped default (--confidence 0.98,
--min-n 30), stated honestly, not just "it works": at n=30, paired mode's false-positive
rate is 1.46% [1.16%, 1.83%] (73/5,000 trials), two-sample's is 1.30% [1.02%, 1.65%]
(65/5,000 trials). An earlier (2,000-trial) measurement reported paired mode's FPR as
higher than two-sample's at the shipped n=30 (1.40% vs. 0.85%) — a follow-up audit
(docs/audit/FPR_ANOMALY.md) found that comparison was never actually significance-tested
and does not hold up when tested (z=0.69, p=0.49 on the corrected, extended 5,000-trial
measurement; at confidence=0.95/n=30 the original ranking even flips, paired 2.98% <
two-sample 3.18%). Both modes DO show a real, already-documented, generic small-n
percentile-bootstrap anti-conservatism (elevated FPR relative to nominal at n≤50,
roughly equally in both modes) — see docs/audit/FPR_ANOMALY.md for the full
investigation.
On power — no single figure, by design, and two different noise regimes, not one. Paired
mode structurally cancels case-to-case cost heterogeneity (that's the whole reason --mode
auto prefers it whenever a pairing key resolves — see "Shipped default" in the package
README), so its detection power depends on the within-case cost CV rather than the raw
per-invocation CV two-sample mode sees. An earlier version of this page (and the package
README) stated one power figure ("99.22% at n=30") as if it applied universally; a
follow-up audit found power at that same n swings from ~99% to single digits depending on
the within-case noise assumption — and a further reconciliation
(docs/audit/Q1A_RECONCILIATION.md) found the gap is not a bug in either measurement, but two
genuinely different, both-legitimate assumptions about how within-case noise behaves:
- Fixed absolute dollar noise (the originally-published grid's own generator) — approximates an evalset of near-identical cases, where response-length noise is roughly constant in dollars regardless of that case's own cost.
- Proportional (CV-scaled) noise (the package's CV-sweep table) — approximates an evalset of genuinely varying task complexity, where noise scales with each case's own cost level.
How to tell which applies to you: cases that are variations on one task land closer to the
fixed-noise regime; cases spanning genuinely different complexity (short factual questions
mixed with long-form generation) land closer to the proportional regime — real measurement on
a mixed-complexity evalset landed solidly in the proportional regime. The package README's
"Power depends on your own cost variance" section publishes BOTH regimes' full n-swept power
tables side by side, not one replacing the other. The one number that is correct regardless
of which regime you're in: adk-tracegauge check prints an "achieved power" line computed
directly from YOUR run's own observed variance and n on every invocation, unconditionally —
never from an assumed constant or a regime choice. That per-run number, not either table, is
what applies to your actual workload. Figures — Wilson 95% confidence intervals — come from
the package's own scripts/measure_regression_confidence_grid.py,
scripts/measure_power_by_cv_grid.py, and scripts/measure_absolute_sd_grid.py; see the
package README ("Known limitations" and "What this gate can and cannot detect") for the full
FPR grid across confidence ∈ {0.95, 0.98, 0.99} and n ∈ {30, 50}.
Also: the adk_tracegauge_cost_usd metric inside adk eval¶
With the plugin wired in as shown above, register a threshold and adk eval itself prints
a real dollar score and PASSED/FAILED verdict per invocation:
// test_config.json — the threshold this run must stay under, per invocation
{"criteria": {"adk_tracegauge_cost_usd": 5.00}}
Overall Eval Status: PASSED
Metric: adk_tracegauge_cost_usd, Status: PASSED, Score: 2.8, Threshold: 5.0
Real output, reproduced against the published adk-tracegauge==0.3.0 install. See
examples/01_minimal_cost_gate.py
in the adk-tracegauge repo for a runnable, fully deterministic version of this example — it
wires a fake model returning a fixed token count (1M input + 1M output tokens on
gemini-2.5-flash, pricing to a real, reproducible $2.80) so it needs no API key or live
model call to reproduce. The same script also runs a second case with threshold=1.00 to show
the FAILED path (Score: 2.8, Threshold: 1.0).
One real thing worth knowing before you rely on this path for anything CI-shaped: adk
eval's own process exit code does not reflect PASSED/FAILED — the real result lives in
adk eval's stdout table and the persisted .adk/eval_history/*.evalset_result.json file,
not in $?. Use this path for inline visibility while iterating; use adk-tracegauge check
(above) for CI gating.
Available metrics¶
| Metric | What it reports |
|---|---|
adk_tracegauge_cost_usd |
Dollar cost of one invocation, summed across every real model call within it (tool loops and sub-agent delegation can mean more than one model call per invocation). Raw USD in score; a real PASSED/FAILED verdict against a required max-USD-per-invocation threshold (CostThresholdCriterion(threshold=...), or the deprecated EvalMetric.threshold=) — the evaluator raises ValueError at construction time if no threshold is set, rather than defaulting to a permissive always-PASSED sentinel. A per-call token/cost breakdown lives in the result's rationale. |
CostThresholdCriterion compares the opposite direction from ADK's built-in convention
(PASSED iff cost <= threshold, since cost is lower-is-better) — see Known ADK-side
limitations for the one place this inversion isn't honored
correctly by ADK itself.
Reports raw counts and dollars only — no calibrated efficiency bands, no trajectory-quality
judging. Pricing covers current-generation Gemini, Claude, and GPT models
(reached natively or through ADK's LiteLlm integration), plus local/self-hosted
models (Ollama, vLLM) — the latter require an explicit opt-in (ADK_TRACEGAUGE_ASSUME_LOCAL)
since a paid cloud product shares the same model-string prefix as local inference. An
invocation whose model isn't recognized reports score=None and NOT_EVALUATED, naming the
specific unresolved model — never a number computed from a fallback rate. See the project's
own README ("Pricing") for the full model list and the custom-price extension mechanism.
Known ADK-side limitations¶
Three real, source-confirmed limitations in google-adk itself, independent of this
package's own correctness — worth knowing regardless of which path above you use:
AgentEvaluator.evaluate()'s pytest-style pass/fail is directionally unreliable for a lower-is-better metric like cost, at any threshold. ADK's harness recomputes PASSED/FAILED itself from the deprecated legacythresholdscalar viamean(scores) >= threshold— hardcoded higher-is-better — instead of reading the metric's own, already-correcteval_status.adk eval/LocalEvalServiceare unaffected — they readeval_statusdirectly and are always correct. Trustadk eval,adk-tracegauge check, or this metric's owneval_status; neverAgentEvaluator.evaluate()'s assert/no-assert outcome for this specific metric.adk-tracegaugeemits a real runtime warning when it detects this situation. A fix has been prepared and is pending submission upstream togoogle/adk-python.adk eval's own process exit code does not reflect PASSED/FAILED, on any metric — the CLI prints a real pass/fail summary but the process always exits0. This is exactly whyadk-tracegauge check, notadk eval, is the recommended CI-gating path above. A fix has been prepared and is pending submission upstream togoogle/adk-python.LocalEvalServicediscards a metric's per-invocation result whenever itsoverall_eval_statusisNOT_EVALUATED— the statusadk_tracegauge_cost_usdreports for an individual invocation whose model didn't resolve, a streaming anomaly was detected, or an unpriced token category was present (see Available metrics); ADK substitutes an empty result rather than surfacing the real rationale in that case. Open design question, not yet resolved: google/adk-python#6725.
Neither of the first two limitations is specific to adk-tracegauge — both apply to any
custom metric registered the same way. adk-tracegauge check's own exit codes are unaffected
by any of the three, since it never depends on adk eval's own exit behavior or on
LocalEvalService's per-invocation result surfacing.
Resources¶
- GitHub repository: source code,
issues, and the full design rationale (including the measured statistical detection-power
numbers behind
adk-tracegauge check) inREADME.md. - PyPI package: releases and install instructions.
- ADK Evaluation Guide: background on ADK's evaluation framework and the
MetricEvaluatorRegistrythis package registers against. - google/adk-python#6725: the open upstream issue behind this page's third "Known ADK-side limitations" entry above.