A re-runnable harness that analyzes tooling inefficiencies across agent systems — Claude Code, Codex, Hermes, and other AgentsView-supported runtimes — from their on-disk session transcripts. The goal is evidence for where agent work wastes context, time, retries, or tool calls, emitted as a single markdown report.
Builds on the native-tool-vs-Bash benchmarking methodology from claude-mem observation #8376.
- Cross-agent tool cost — which tools, agents, projects, and workflows dump the most context back into sessions.
- Tooling inefficiency patterns — repeated failed calls, slow tools, edit churn, retry loops, context pressure, subagent fan-out.
- Deferral / discovery tax — what deferred-tool loading and searching
(e.g.
ToolSearch) costs across Claude Code, Codex, and Hermes. - Controlled tool-vs-shell probes — for comparable local tasks, when native tools (Grep/Glob/Read) are cheaper or more reliable than shell commands.
The primary metric is context cost = joined tool-result payload tokens
(chars / 4). Cache flags are caveat-only and never rank tools; failure /
slow / retry-churn feed the inefficiency callouts only.
- Markdown only — no HTML report (the
session-reportskill owns that). - No live token-API calls — all numbers derive from on-disk transcripts.
- Read-only — never mutates transcripts or the probe corpus's source projects.
- Python standard library only — no third-party runtime dependencies.
Runtime needs a stdlib Python ≥3.13 (provisioned via
uv; see Usage). - No web-chat benchmarking — local/agentic surfaces with inspectable sessions only.
raw roots + AgentsView exports
│
loaders (sources.py) ── acquisition: bytes → lines
│
parsers (parsers.py) ── interpretation: lines → ToolCall / ParseResult
│
adapters (adapters.py + registry.py) ── SessionRef → ParseResult
│
┌────┴──────────────────┬────────────────────┐
passive.py (CLI / scan) probe.py complex.py + complex_runner.py
│ │ (library: locate-then-fix)
reducer.py → report.py │ (ClaudeParser keep_raw + track_turns)
freeze.py (opt-in pin) │
run_manifest.py (S40 opt-in) │
└──────────┬──────────────┴────────────────────┘
reports/*.md (complex profile is rendered in-process; no CLI yet)
Acquisition and interpretation are orthogonal, so they are separate ABCs:
- A
SessionLoaderturns aSessionRefinto lines. It owns the NUL sniff, which therefore runs before schema detection — a SQLite dump has no first JSON line to detect. - A
TranscriptParserinterprets already-acquired lines. It never opens a file and never decides which schema it is looking at. - A
SessionAdaptercomposes the two.registry.pick_adapterwalks an ordered list ofclaims(ref)predicates and returns the first match.
Hermes SQLite is keyed on source; everything else is keyed on content.
Hermes archive sessions claim by agent == "hermes" and path is None — a
SQLite read with no lines. Every other session is content-sniffed over a
bounded 100-line window, because schema is a property of the payload, not of
the producer: cowork emits Claude's exact schema and parses with zero
registry entries of its own. A hermes sessions export --format trace file
is also content-sniffed: it speaks Claude's shape but declares
version == "hermes-agent", so HermesTraceParser claims it (S29) rather
than letting ClaudeParser swallow a usage-less export as a measured zero.
No parser is the default. An unrecognized transcript raises UnknownSchema
(a RuntimeError, so passive.main's existing per-session guard demotes it to
skipped_roots). Previously such a session fell through to the Claude parser,
matched nothing, and reported a healthy zero. codex is now claimed by
CodexParser, which joins its three paired response_item shapes —
function_call, custom_tool_call, and tool_search_call — on payload.call_id
(S33 / TB-12). cursor still lands in skipped_roots, pending a parser of its own.
codex's web_search_call has no call_id and no output record, so it is not
joined as a call; instead it is counted in ParseResult.unjoinable and named in the
Summary (Unjoinable tool records), so codex's ~4% web-search undercount is surfaced
rather than silently absent (S38 / TB-24).
transcript.py— the schema-neutral records:ToolCall(withUsageProvenanceand parse-time inefficiency tags),ParseResult(optional session-grain cache sums,unjoinable, optionalturns), andresult_len. Path-basedparse_sessionis gone; open lines (witherrors="replace"for TB-10) and callClaudeParser.parseorpick_adapter(ref).parse(ref).parsers.py— one class per schema.ClaudeParserjoins each assistanttool_useblock to its result by id, stamps inefficiency tags at emit (CQ 3.1), and sums session-grain cache read/creation (S39). Optionalkeep_raw_input/track_turns(CQ 7.1) let probe reuse this pass instead of a second Claude-shaped walker.HermesTraceParsersubclasses it for the claude-shaped hermes trace export and stamps every callABSENT_BY_EXPORT(S29). Malformed lines are counted and skipped, never fatal.adapters.py—detect_parser,UnknownSchema,AmbiguousSchema, andComposedAdapter(the terminal fallback).PARSERScurrently holdsClaudeParser,HermesTraceParser, andCodexParser; Claude and HermesTrace partition onversion, Codex on top-leveltype.registry.py— the ordered adapter list andpick_adapter. Exists to break thehermes.py↔adapters.pyimport cycle. Adding an agent means adding an entry here, never editing a dispatcher.sources.py— multi-agent discovery plus the loaders. Either scans raw local transcript roots or pages the AgentsView CLI (--index-source auto | agentsview | raw).autotries AgentsView first and falls back to raw scanning, recording the reason — including when a healthy probe is followed by a mid-listing nonzero exit or hang (TB-38; partial agentsview refs are discarded, never spliced). Raw discovery stampsSessionRef.is_subagentfor<project>/<session-uuid>/subagents/*.jsonlwhile keeping the owning project as the first path segment (S13). Exports that are not JSONL (e.g. a SQLite dump with a NUL in the header) raiseNonTranscriptExportand are skipped by name (TB-10).hermes.py— direct read-only SQLite adapter for Hermes sessions (TB-11). Discovery still comes from AgentsView; only the read is redirected becausesession exportreturns the whole default-profile database. Per-call usage isABSENT_BY_SCHEMA; session-rowcache_read_tokensis surfaced onParseResultfor the Agent Breakdown caveat (S32 / TB-20), never attributed per call.passive.py— CLI and scan orchestration only: argparse, discovery /--freezereplay, per-ref parse, date-range filter, typed skips. Re-exports reducer/report symbols so historicalfrom toolbench.passive import …imports keep working.reducer.py— incremental corpus aggregation (S11). Folds each session'sParseResultinto per-agent / per-tool counters and discards the call list — never a whole-corpuslist[ToolCall]. Schema-neutral: it only counts tags already stamped at parse time.report.py— five-section markdown render (S14) plus corpus fingerprint helpers (S36) and sampling disclosure (S41:sampledcolumn, uneven- sampling apportionment). Sections: agent breakdown (session-grain cache caveats + census fractions), tool leaderboard (cache_assistedasyes/no/n/a/n/a*), model breakdown, inefficiency callouts, summary (discovery reconcile, unjoinable records, S39 cache totals).freeze.py— write-once / replay corpus manifest for--freeze(S37). Round-tripsSessionRef(includingis_subagent) so replay bypasses live discovery without an import cycle onpassive. On replay, a path under…/subagents/…still counts as a subagent even if a pre-TB-29 manifest stored"is_subagent": false— the path is ground truth (TB-29). Manifest format v2 (toolbench-freeze-2) optionally persists the freeze-timeAgentCensusunder acensuskey so replay can disclose real historical fractions (TB-37); absence of the key (v1, or a failed freeze-time census) still marks fractions unavailable and names the manifest version.run_manifest.py— JSON reader for--run-manifest(S40). Defines a run's branch set (branchesrequired; empty/missing is refused). Not.lattice/orchestration/agents.md— that file drops its Branch column when the run finishes.probe.py— scores matched tool-vs-Bash probe pairs from a dedicated session JSONL and emits a context-token + usage comparison table underreports/. Joins viaClaudeParser(keep_raw_input=True, track_turns=True)— one Claude walk, not a private duplicate. Tool arms match structurally (name + corpus target); bash arms match by sentinel. Usage is attributed only when the API response is isolable (onetool_use, no prose/reasoning — S26). Turns are keyed solely byrequestId(S30); hermes-trace input is refused withNonIsolableTurns.complex.py/complex_runner.py— locate-then-fix library (no CLI yet). Measures tokens to a verified outcome across four toolset arms rather than cost-per-call. See Complex debug probe below; design lives underdocs/superpowers/specs/2026-07-12-complex-debug-probe-design.md.
Five files are vendored under tools/ — a log-spaced size spread
(~121 → ~2,242 lines) so the tool-vs-Bash comparison shows how context cost
scales with target size:
| File | Lines |
|---|---|
regex_check.py |
121 |
mcp.py |
352 |
monitor.py |
768 |
llm_extraction.py |
1,332 |
code_analysis.py |
2,242 |
They are committed so probes re-run from a clean checkout with no external
absolute paths. Probe output lands in reports/, kept separate from these
inputs.
The active probe (S16–S18) answers cost per call. The complex probe asks a different question: which toolset reaches a verified fix for the fewest context tokens? That changes the unit from tokens-per-call to tokens-to- outcome, so the agent chooses its own path and step count dominates.
Status: library shipped (toolbench/complex.py, toolbench/complex_runner.py);
no CLI yet. Fixtures live under probes/complex/; pinned
corpora under corpus/ (manifest.json + vendor.sh). Design:
docs/superpowers/specs/2026-07-12-complex-debug-probe-design.md.
| Piece | Role |
|---|---|
complex.py |
Load defects from fixtures, score a trial (LOCATED: + oracle), build/render a routing profile |
complex_runner.py |
Provision a hermetic worktree, shared deps cache, injectable launch/oracle, run_trial |
probes/complex/<repo>-<id>-*/ |
defect.patch, truth.json, prediction.md, oracle.json, prompt.md |
corpus/manifest.json |
Pinned SHAs + dep/warmup/provision recipes for wids, maltese, rich |
Operator constraints (verified in code):
- Prompt is always
PROMPT.mdfromprovision_worktree— never the defect rationale (that leaks the predicted winner). MissingPROMPT.mdraisesUnprovisionedWorktree. - Dep cache defaults under
tempfile.gettempdir()/vendor-cache-<uid>and must diverge from the corpus at the filesystem root (only common ancestor/). A walkable shared ancestor (e.g. both under$HOME) re-opens a pristine- source leak via..from a trial'snode_modulessymlink. - The cache base must be a real, private directory owned by this uid — not a
symlink (replaceable cache base →
UnsafeDepsCache), not world-accessible, not under a writable non-sticky ancestor. Contents are symlinked into every trial and executed by oracles. - Arms are enforced by transcript audit (
arm_violations+ read-scope), not filesystem walls: any resolved read outside the trial tree voids the trial. Bash/control arms hold a full shell; the profile discloses that their read-scope audit is best-effort.
Call the library from tests or a future CLI; do not shell a real claude from
the hermetic suite — launch / oracle are injectable (S24 pattern).
Implemented. toolbench/ ships all of tickets T1–T6 in
BUILDPLAN.md: the scaffold, the transcript parser, the
multi-agent source layer, the passive analyzer, and the active probes.
Post-merge hardening covers TB-8 (subagent --project filter), TB-9
(callout denominators), TB-10 (non-UTF-8 / non-transcript exports),
TB-11 (Hermes SQLite direct read — discovery still via AgentsView),
probe isolability (S26 / TB-14–16), schema dispatch (S27–S28 /
TB-13), usage provenance (S29–S30 / TB-18: producer-aware
UsageProvenance on every ToolCall, and probe.py refusing corpora it
cannot key to the billing unit), the gate itself running every test
(S31 / TB-19: the documented command is pytest, not unittest discover, which silently missed 37 module-level tests), session-grain
Hermes cache surfacing (S32 / TB-20: Agent Breakdown caveat, never
folded into the per-call cache_assisted column), the codex schema
(S33 / TB-12: CodexParser joins three paired response_item shapes on
payload.call_id), typed skips + discovery reconcile (S34–S35 / TB-21 /
TB-23), corpus fingerprint + --freeze (S36–S37 / TB-22), unjoinable
records (S38 / TB-24), Claude session-grain cache read+creation
(S39 / TB-26), and per-run cache-token grouping via --run-manifest,
entry-grain by gitBranch (S40 / TB-27). Follow-ons name the
detached-HEAD attribution blind spot (TB-28) and make
--exclude-subagents match the real nested
<project>/<session-uuid>/subagents/ layout (TB-29). AgentsView hang
bounds + operator ceiling (TB-32 / TB-39), mid-listing auto fallback
without splicing (TB-38), and per-agent sampling disclosure with
apportionment (S41 / TB-33 / TB-35) — including census on the
zero-match path (TB-34) and freeze-time census in manifest v2 (TB-37) —
are shipped. The complex debug probe library (complex.py /
complex_runner.py) is implemented as a library (fixtures under
probes/complex/; no CLI yet). CQ follow-ons split passive into
reducer/report, fold probe into ClaudeParser
(keep_raw_input / track_turns), and stamp inefficiency tags at emit.
The strict gate (uv run ruff check ., uv run mypy --strict toolbench tests,
uv run pytest -q) is green — 594 tests passing (3 skipped when the
live hermes archive / optional live paths are absent). mypy --strict
covers tests as well as toolbench.
Source-of-truth documents:
SPEC.md— 41 numbered acceptance criteria (S1–S41).EVALUATION.md— verification map for every criterion.BUILDPLAN.md— decided architecture and the T1–T6 tickets plus post-merge TB/T rows.docs/2026-07-07-tool-benchmarks-design.md— full v2 design spec.protocols/active-probes.md— probe corpus, arm matching (S17), isolability (S26), and the seeded#8376baseline table.protocols/probe-run-sheet.md— executable ten-turn operator run sheet for scoring a fresh probe session.docs/superpowers/specs/2026-07-12-complex-debug-probe-design.md— locate-then-fix complex probe (library shipped; no CLI yet)..claude/skills/cache-token-metrics/SKILL.md— operator recipe for per-run cache-token diffs (S39).
Three source adapters. The first two are selected per-session by
--index-source; the third is selected by agent.
- Claude Code raw transcripts — scans on-disk JSONL session files
directly under a root (default
~/.claude/projects). - AgentsView — pages the
agentsviewCLI for any AgentsView-registered runtime (Claude Code, Codex, Hermes, …), yielding oneSessionRefper session with cursor-based pagination. - Hermes SQLite — reads hermes sessions straight from
~/.hermes($HERMES_HOMEoverrides).agentsview session exportreturnsrc=0and streams the whole 37 MB default-profile database for every hermes session instead of that session's transcript, so hermes contributed zero tool calls until this adapter landed (TB-11). Per-call usage is absent by schema; when the session row carriescache_read_tokens, that appears as an Agent Breakdown caveat only (S32).
A fourth path is content-detected, not selected by --index-source:
hermes sessions export --format trace JSONL is claimed by
HermesTraceParser (S29). Valid for passive (cache_assisted → n/a);
refused by probe (NonIsolableTurns, S30).
Hermes discovery still comes from AgentsView; only the read is redirected.
The corpus is defined as what agentsview session list returns, and every
agent is sampled through that one path. Enumerating the hermes archive here
would redefine the corpus for a single agent and skew every cross-agent rate.
Known limitation. Hermes is under-sampled. agentsview session list --agent hermes reports 89 sessions while agentsview stats --agent hermes reports 789
from the same archive — one binary, two subsystems, an 8.9× disagreement. That
is an upstream defect
(kenn-io/agentsview#1048),
not a curation to work around by forking discovery into one adapter. The export
bug this adapter exists for is
#1047.
The project is uv-managed (pyproject.toml +
uv.lock, empty runtime deps, dev group ruff/mypy/pytest).
Requires Python ≥3.13.
# Bootstrap (once per checkout; also runs implicitly under `uv run`)
uv sync
# Passive analyzer — default scope is every agent, every project
uv run python -m toolbench.passive --agent all --all
# Scope by project / time / index source
uv run python -m toolbench.passive --project my-repo --since 2026-06-01
uv run python -m toolbench.passive --all --index-source agentsview
uv run python -m toolbench.passive --all --date-from 2026-06-01 --date-to 2026-06-30
uv run python -m toolbench.passive --all --exclude-subagents --out reports/2026-07-08-tool-usage.md
# Reproducible before/after: freeze the corpus once, then replay it to compare.
# First run writes the manifest; every later run scans the frozen set and names
# what has vanished since (TB-22).
uv run python -m toolbench.passive --all --freeze reports/corpus.manifest # writes
uv run python -m toolbench.passive --all --freeze reports/corpus.manifest # replays
# Active tool-vs-Bash probes. Score a dedicated probe session; without
# --session every arm is seeded and the report is refused (SeededReportError).
# Operator run sheet: protocols/probe-run-sheet.md (ten arms, ten turns).
uv run python -m toolbench.probe --session /path/to/probe-session.jsonl --out reports/active-probe-comparison.md
uv run python -m toolbench.probe --allow-seeded # baseline table only; measures nothing
# Per-run cache-token metrics (S40) — --run-manifest groups per-entry usage
# by gitBranch, folded into the passive analyzer itself (no separate module).
# Prefer from repo root via -m; from ~ use the path form in
# .claude/skills/cache-token-metrics/SKILL.md (module resolve fails outside
# the checkout).
uv run python -m toolbench.passive --agent claude --run-manifest run.json --tickets 12
# Tests
uv run pytest -q- Fresh session only. Mentions of sentinels, the run sheet, or
toolbench/probe.pyare discarded as contamination (MENTION_MARKERS). - One tool call per API response. Usage is keyed by
requestId(S26 / S30). Batching, prose, or reasoning in an arm turn blanks the usage column (—) while keeping real context tokens — it does not re-seed the cell. There is no timestamp fallback; a corpus withoutrequestId(includinghermes sessions export --format trace) raisesNonIsolableTurns. - Usage columns are not yet comparable (TB-17).
output_tokensbills the whole emittedtool_useblock. The bash arm must carry a sentinel comment (and often adescription) that the tool arm cannot carry, so bash usage is inflated by ~15–20 tokens of instrumentation — enough to swamp the measured gap. Trust the context-token columns; do not conclude "MCP is cheaper on output tokens" from the usage pair until TB-17 lands a stated correction (or drops those columns). - Turn 0 before arms. Confirm serena has an active project with a
non-corpus target (
pyproject.toml) so a failed arm call is not scored as a successful match. - Native Claude transcript only. Score a Claude Code probe session, not
a hermes trace export —
passiveaccepts both;probedoes not (S30).
auto(default) — tries AgentsView first; on failure, falls back to scanning the raw root directly and records the fallback reason in the report's Summary section.agentsview— AgentsView only; a source error is fatal.raw— raw local transcript roots only; a source error is fatal.
"Failure" means any of three things, not two (TB-32). AgentsView can be absent
(binary not on PATH), broken (nonzero exit), or hung — a daemon that accepts
the connection and never answers signals neither of the first two, so every agentsview
call is bounded by AGENTSVIEW_TIMEOUT_S (60s, sources.py) and a breach is raised as
AgentsViewTimeout. Where that surfaces depends on when the daemon stops answering:
- at the
autoprobe → fallback to raw, reason named in the Summary (agentsview timed out after 60.0s and was killed: …); - mid-listing, after a healthy probe, during pagination →
autostill falls back to raw (TB-38): the partial agentsview listing is discarded and the corpus is rescanned wholesale from the filesystem — never spliced onto truncated agentsview refs (a mixed corpus would break the fingerprint identity TB-22 protects). Explicit--index-source agentsviewstays fatal for the same mid-listing failures; - mid-scan, on a per-session
export→ that session is skipped under theexport_timeoutreason and the scan continues (a sick daemon costs sessions, not the whole run); - under
--index-source agentsview→ fatal, as any source error is there. No silent fallback: the operator asked for AgentsView explicitly.
A vanished binary mid-discovery (FileNotFoundError) keeps its narrower handling: a
named MISSING_SOURCE skip and an unavailable census, no raw rescan — a gone binary
is not evidence the raw root is healthier.
Overrides that ceiling (TB-39). Default 60.0 — omit the flag and behaviour is exactly
as above.
| value | meaning |
|---|---|
> 0 |
bound each agentsview call at that many seconds |
0 |
unbounded — timeout=None; a hung daemon blocks the run forever |
< 0 |
rejected at parse |
Raise it when a large archive makes a healthy daemon exceed 60s (otherwise our own
default truncates the corpus and blames it on export_timeout skips); lower it when
debugging a daemon you already suspect is hung.
The Summary names the timeout only when it changed what you are looking at — if it
truncated the corpus (≥1 export_timeout skip), or if the run was unbounded. A clean
bounded run says nothing. The unbounded case is disclosed precisely because no skip can
ever record it: an unbounded call never times out, so a clean report from one is not
evidence of a healthy daemon — it may only be evidence of luck.
--agent filters AgentsView listing only. Under --index-source raw the
discovery root is Claude Code sessions, so --agent is a no-op there.
--project matches the owning project directory under the raw root
(first path segment after the root), not path.parent.name. Nested
subagent transcripts at <project>/<session-uuid>/subagents/*.jsonl are attributed to that
owning project with SessionRef.is_subagent=True, and are only dropped when
you pass --exclude-subagents.
The corpus is a moving target: claude-mem observer transcripts age out of a ~30-day sliding window mid-scan, so its tail deletes itself at roughly re-run cadence, and the live session appends calls while it is read. Two reports are therefore not automatically diffable — a delta between them may be the corpus moving, not your code (TB-22).
Corpus fingerprint: <digest> (<N> sessions scanned)(always emitted in the Summary, S36) is a hash over the scanned set — the sessions that produced the numbers. It folds each session's identity and every content count the Summary renders (calls, malformed lines, unjoinable records), so a vanished tail (an id leaves the set) and any kind of append (a new call, a malformed line, or aweb_search_call) all move it. Two reports whose fingerprints match are diffable; if they differ, do not attribute the delta to code until you know why the input set moved.--freeze <manifest>(S37) makes a before/after actually reproducible. The first run writes the discovered ref list to the manifest; every later run replays it — scanning exactly the frozen set instead of re-discovering — and reports(<V> vanished since freeze)for refs whose transcripts have since been deleted (--verbosenames them). Over an unchanged corpus a replay is byte-identical; when the tail has moved, the vanished count names the mechanism instead of letting it masquerade as a code effect.- Manifest v2 + freeze-time census (TB-37). New freezes write
toolbench-freeze-2and, when the freeze-time census succeeded, persist it under acensuskey. Replay then shows real per-agentsampledfractions with an explicit Historical denominator caveat — the archive size as of freeze time, not today's. A v1 manifest (or a v2 write whose census itself failed) still marks fractions unavailable and names the manifestversionin the disclosure; absence of the key is what matters, not the version string alone.
- Manifest v2 + freeze-time census (TB-37). New freezes write
--limit truncates discovery in recency order across the whole archive, not
per agent (S41 / TB-33). Each Agent Breakdown row therefore rests on a different
fraction of that agent's history, and an agent whose work is all older than the
window can vanish at sessions == 0.
- The
sampledcell isreached / census_total (fraction). Agents present in the archive but never reached still get a row —sessions == 0means looked-and- found-none, not never-looked. - When sampling is uneven, the report names causes from observed signals only
and apportions the per-agent remainder (
total - sampled) between truncation and attrition (TB-35). A--limitthat was passed but never bit is not truncation; a negative remainder is flagged as census/scan drift. - Cross-agent ratios are comparable only when no uneven-sampling line prints.
- A zero-match early return ("no sessions matched…") still appends the census the
run already built (TB-34) — a narrow
--since/--date-*window must not read as an empty archive.
The fast test suite is hermetic — it fakes the agentsview CLI, points
$HERMES_HOME at a fixture database, and never touches ~/.claude or
~/.hermes, so the inner loop never depends on a live daemon. One test in
tests/test_hermes.py reads the real hermes archive to pin the schema
compatibility envelope, and skips when that archive is absent.
Hermes databases are always opened file:…?mode=ro. A running hermes owns
those files; the adapter never writes to them.
Inefficiency callouts are written as N of M calls (P%) and name the
worst tool when the count is non-zero, for example:
- Failures: 147 of 997 calls (14.7%); top: Bash (109)
- ToolSearch/deferral tax: 12 of 997 calls (1.2%), 3400 tokens
Ties for "top" break alphabetically so the report stays deterministic. A zero count omits the top-offender clause.
The Tool Leaderboard's cache_assisted column is caveat-only (S19) and
uses four values (S29): yes (a hit was observed), no (usage was
measurable and zero hits), n/a (usage unavailable for every call in the
bucket — e.g. hermes SQLite or hermes-trace), n/a* (mixed). Neither
n/a form is a measured zero.
When Hermes sessions carry session-row cache_read_tokens, the Agent
Breakdown adds a caveat line such as M of N sessions carry session-grain cache_read_tokens > 0 (S32). Claude sessions contribute the Summary line
Session-grain cache tokens: read=… creation=… (… measured sessions; S39 caveat, not ranked) — read and creation together, because a prefix-sharing
change can trade one for the other while TOTAL_BILLED stays flat. Neither
signal is ever divided into a per-call rate or mixed into cache_assisted.
With --run-manifest, the Summary also emits a Run cache tokens block
(S40 caveat, never ranked):
- Run cache tokens (run tb-27): read=… creation=… (N candidate sessions; S40 caveat, not ranked)
- per ticket (T): read=… creation=…
- unattributed: read=… creation=… (same-session work off the run's branches)
- detached-HEAD (unattributable): read=… creation=… input=… output=…
(M sessions; may include run delegators -- run total may be low)
- matched no entries: feat/missing-branch
Attribution is per entry by gitBranch, not per session. unattributed
is spillover inside candidate sessions (sessions with ≥1 entry on a run
branch) — not corpus-wide main. detached-HEAD is usage stamped
gitBranch="HEAD" (a detached checkout): it cannot match any manifest
branch, so it is named and never folded into the run total (TB-28).
Input/output appear on that line because an uncached detached turn can have
zero cache and still be real billed work. A large detached or unattributed
line means the run headline may understate what the orchestration spent.
| Symptom | Likely cause | What to do |
|---|---|---|
Summary scanned is far below the corpus size |
Not a bug: many discovered sessions skip (dead index entries, parser gaps) | Read the Sessions discovered: D / scanned: M / skipped: K line and the Skipped by reason histogram — they reconcile the gap by typed reason (S34/S35). scanned was never the corpus size. |
Run dies with UnicodeDecodeError |
Pre-TB-10 behavior, or a custom strict-decode runner | In-tree readers use errors="replace". One bad session should be counted under Skipped by reason as decode_error, not abort the corpus. |
Summary counts a non_transcript skip for a non-Hermes session |
AgentsView session export returned binary with returncode 0 |
Expected for off-contract exports; the NUL sniff rejects them before parse (TB-10). Run with --verbose to name the session. Hermes sessions should not hit this path — they route through hermes.py. |
--project X silently omits every subagent |
Pre-TB-8 filter matched path.parent.name (subagents) |
Current code matches the owning project dir. Re-run on current main. |
Callouts are bare integers (Failures: 865) |
Pre-TB-9 report formatting | Current callouts include denominators and a top offender. |
--agent hermes yields far fewer sessions than expected |
AgentsView session list under-counts Hermes vs stats (~89 vs ~789) |
Known upstream limit (#1048); discovery is intentionally not forked into the Hermes adapter (S9b). |
| Hermes session skipped / archive not found | $HERMES_HOME / ~/.hermes missing, or session only in an unread profile DB |
Confirm HERMES_HOME; it counts under the non_transcript reason and --verbose names the session. Profile DBs under profiles/*/state.db are searched. |
Malformed lines explodes into the hundreds of thousands |
Binary export absorbed as text (would happen without the NUL sniff) | Should not occur on current code — binary payloads are rejected before parse. |
| Empty selection message | No sessions matched filters, or all matched sessions were skipped | Check --project / --since / --date-*, and the (skipped K: reason=count) suffix on the message; the census disclosure that follows (TB-34) distinguishes a narrow window from a truly empty archive. --verbose names each session. |
toolbench.probe without --session refuses to write |
Seeded-only report is blocked (SeededReportError) |
Pass --session PATH, or --allow-seeded for the baseline table only. |
Probe usage column shows — but context tokens are real |
Arm matched, but the API response was not isolable (prose, thinking, or batched tool_use — S26) |
Re-run from protocols/probe-run-sheet.md; one tool call per turn, no surrounding prose. |
| Bash usage looks ~15–20 tokens higher than the tool arm | Sentinel + optional Bash description are billed into bash output_tokens only (TB-17) |
Expected until TB-17. Compare context-token columns; treat usage as non-comparable. |
cache_assisted shows n/a / n/a* for hermes (or hermes-trace) |
Per-call usage is absent by schema or dropped by the trace export (S29) | Expected. Do not read n/a as "no cache hits". Session-grain cache, when present, appears as an Agent Breakdown caveat (S32), not in this column. |
toolbench.probe raises NonIsolableTurns on a hermes trace file |
Trace export has no requestId; probe keys turns only by that field (S30) |
Score a native Claude Code probe session instead. Trace remains valid input to passive. |
cursor sessions appear only under the unknown_schema skip reason |
No parser claims cursor's schema yet (UnknownSchema, S28) |
Expected until a CursorParser lands. It must not appear as a healthy zero-call agent; tally_skips/--verbose surface the count and ids (S34). |
Sessions skipped under the export_timeout reason |
The AgentsView daemon stopped answering mid-scan; each export is bounded at AGENTSVIEW_TIMEOUT_S (TB-32) |
Not a bad session — a sick daemon. The probe passed, so the hang began later; the scan degrades to skips rather than dying. Restart AgentsView and re-run, or use --index-source raw. A run where many sessions carry this reason is not a corpus to trust. |
--index-source auto used to exit 1 after a healthy probe |
Mid-listing failure used to be fatal; now falls back to raw and discards the partial listing (TB-38) | Expected on current main. Explicit --index-source agentsview still exits 1 — that is the strict path. |
cache_assisted shows n/a for every codex tool |
codex has no per-call usage channel; it bills per turn via token_count events (ABSENT_BY_SCHEMA, S33) |
Expected. Do not read n/a as "no cache hits". |
codex reports 0 errors no matter what failed |
codex encodes exit status in the output text and sets status: completed even for failed tools (S33) |
Expected. error is never inferred from output prose. Use output_chars / the raw transcript to inspect failures. |
codex web searches never appear in the leaderboard |
web_search_call carries no call_id and emits no output record, so it cannot be joined (S33 / TB-24) |
Expected. They are not joinable calls, so leaderboard/ratio counts exclude them. The count is not lost: the Summary's Unjoinable tool records (seen, not joined) line names it as codex/web_search_call (S38). |
| Fingerprints differ between two "same" runs | Corpus moved: vanished observer tail and/or live append (S36) | Do not attribute the delta to code. Re-run with --freeze (S37) or compare only when digests match. |
--freeze replay reports vanished sessions |
Frozen refs' transcripts aged out or AgentsView source file not found |
Expected when the sliding window deletes mid-corpus. --verbose names them; rewrite the manifest only when you intentionally want a new pin. |
--freeze replay shows "Historical denominator" |
Manifest v2 carried a freeze-time census (TB-37) | Expected. Fractions are archive size at freeze time, not today. Do not treat them as a live census. |
--freeze replay still says fractions unavailable |
Manifest has no census key (v1, or freeze-time census failed) |
Expected. Rewrite the freeze on current main if you want historical fractions; the disclosure names the manifest version. |
| Agent Breakdown ratios look incomparable across agents | --limit truncates in whole-archive recency order (S41) |
Read the sampled column and the uneven-sampling line. Compare across agents only when that line is absent. |
toolbench.passive via -m fails from ~ |
Package isn't on sys.path outside the checkout |
From ~, invoke by file path per the cache-token-metrics skill; from the repo root, -m toolbench.passive works. |
| Summary cache read ↓ but creation ↑ by ~the same | Prefix-sharing moved cost between buckets (S39/S40) | Not a win. Compare read and creation together; read alone misleads. |
--run-manifest run total looks too low vs wall-clock spend |
Detached-HEAD usage (gitBranch="HEAD") cannot match any branch set (TB-28) |
Read the detached-HEAD (unattributable) line (includes input/output). Do not fold it into the run — a detached delegator is indistinguishable from unrelated detached work. |
--run-manifest shows a large unattributed line |
Candidate sessions also ran on non-run branches (straddle spillover, S40) | Expected. The run total is only the in-set entry slice; do not treat session totals as run-owned. |
--run-manifest path.md (or empty branches) exits 1 |
Manifest must be JSON with a non-empty branches list (S40) |
Use a dispatch-time JSON like .lattice/orchestration/run-tb21-23.json; agents.md cannot serve (Branch column is discarded on completion). |
--exclude-subagents still includes nested subagents / freeze replay ignores the flag |
Pre-TB-29 discovery checked rel.parts[1] == "subagents" (flat layout that does not exist on disk); freeze manifests could pin stale "is_subagent": false |
Current code matches "subagents" in rel.parts[1:-1] and ORs path re-derivation on freeze replay. Re-run on current main; rewrite the freeze manifest only if you intentionally want a new pin. |
Complex trial raises UnsafeDepsCache |
Dep cache shares a walkable ancestor with the corpus, is a symlink, or is not private to this uid | Pass deps_base= (or set $TMPDIR) so cache and corpus diverge at /; never point the cache at a replaceable symlink. |
Complex trial raises UnprovisionedWorktree |
run_trial was called without provision_worktree (no PROMPT.md) |
Call provision_worktree first. There is no fallback prompt — the rationale would leak the predicted winner. |
Before any PR: uv run ruff check ., uv run mypy --strict toolbench tests,
and uv run pytest -q must be green (S31 — the documented command must
collect every test, including module-level test_* functions that
unittest discover silently misses).