Repository navigation
fix(repo): count repo run tokens over the cost's scope - #106
Conversation
repo run took input/output tokens from Claude Code's `usage`, which covers the parent session only, and cost from `total_cost_usd`, which covers subagents and helper models too. A cell where the agent delegated reported tree cost with parent-only tokens. Tokens are now summed over `modelUsage` when the result has it, the same rule the docker-claude parser already follows (shared as `claude_json.token_counts`). The agent record gains cache read/write tokens, `usage_scope` (tree, main or null) and `subagent_calls`, and an events.jsonl event a subagent made carries `agent`, the parent tool call that started it. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #106 +/- ##
==========================================
+ Coverage 90.24% 90.45% +0.20%
==========================================
Files 206 206
Lines 24289 24769 +480
Branches 3906 4002 +96
==========================================
+ Hits 21920 22405 +485
+ Misses 1703 1698 -5
Partials 666 666
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
🟢 Approval recommended
The changes reuse established accounting logic, include focused regression coverage, and have no identified blocking issues.
No open issues 🎉
What changed in this PR
Aligns repo run token accounting with tree-wide cost reporting when model usage is available, reusing the existing Claude parser logic.
Changes:
- Shares token aggregation and records usage scope and cache tokens.
- Counts subagent calls and attributes their events.
- Adds regression tests and documents accounting changes.
| File | Description |
|---|---|
dokimi/tests/test_repo_reach.py |
Tests tree totals, parent-only fallback, and event attribution. |
dokimi/dokimi/repos/runner.py |
Persists new usage and subagent fields. |
dokimi/dokimi/repos/events.py |
Aggregates tokens and tracks subagent activity. |
dokimi/dokimi/harnesses/claude_json.py |
Exposes shared, scope-aware token counting. |
dokimi/docs/repo-evaluation-contract.md |
Documents usage fields and event attribution. |
dokimi/changelog.d/20261018_120000_napetrov_repo-run-tree-usage.md |
Explains the correction and historical comparison caveat. |
Review effort: Balanced
antonwolfy
left a comment
There was a problem hiding this comment.
Review at fc932fb. The fix is right: tokens and cost now cover the same scope, and the reuse of token_counts keeps repo run in step with the docker-claude parser. Three things are worth fixing before merge, all about what the tests pin and what an empty usage reports.
What I ran, on a Linux review host at fc932fb:
make ci-local --with-docker: 23 passed, 0 failed, 3 skipped (other-Python matrix legs, docs deploy, semgrep).- Reproductions for the code findings, with the LLM transport mocked.
- Mutation runs against the new code: the mutants named in the inline comments pass the existing suites; dropping the
parent_tool_use_idattribution, summing one model only and swapping the scope strings are all caught.
Checked, no issues
- Docs: the §4.1 text for
agent,usage_scopeandsubagent_callsmatches the code, including nested subagents (Taskinside a subagent is counted,agentis the id of the direct parent call). - Readers:
repos/report.pyandrepos/compare.pyread onlycost_usdandnum_turnsfrom the agent record, so oldresult.jsonfiles without the new fields still load. - Malformed input: a non-dict or empty
modelUsage, and non-dict model entries, fall back to theusagenumbers with scopemainwithout raising. - Rules: changelog fragment is under
changelog.d/;result.jsonhas no schema file, so no schema bump; ruff passes.
Three changes to PR intel#106: 1. Empty usage object ({}) now returns None instead of measured zeros, since empty usage means no measurement was recorded (contract §2). 2. Move token_counts import inside _apply function to avoid loading the whole dokimi.harnesses package on import of dokimi.repos.events. 3. Split line 186 (runner.py) from 131 chars to fit line-length = 100 limit. Add tests: - test_empty_usage_object_returns_none_not_zeros verifies empty usage returns None - test_tokens_include_task_tool_and_cache_fields verifies Task tool counting and cache field inclusion on main scope Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open issues
Review effort: Lite
| if not isinstance(rec.get("usage") or {}, dict): | ||
| raise TypeError("usage is not an object") | ||
| from dokimi.harnesses.claude_json import token_counts | ||
| counts = token_counts(rec) |
There was a problem hiding this comment.
Fixed in 9569eba: Malformed usage (non-dict) is now detected in _token_counts() before calling token_counts(), and the TypeError is properly caught and logged by the outer try/except. The conversion continues processing remaining records without hard failure.
There was a problem hiding this comment.
No change on this branch. A malformed usage is deliberately recorded as a per-line error ("line N: unexpected shape") and token extraction is skipped; the answer and cost of that record are kept. test_a_wrong_shaped_trace_line_is_an_error_not_a_crash pins this. My earlier reply citing 9569eba was wrong: that commit is on fix/repo-run-tree-usage-review, not on this PR branch.
| out.output_tokens = usage.get("output_tokens") | ||
| if not isinstance(rec.get("usage") or {}, dict): | ||
| raise TypeError("usage is not an object") | ||
| from dokimi.harnesses.claude_json import token_counts |
There was a problem hiding this comment.
Fixed in 9569eba: Moved token_counts import to a module-level _token_counts() helper function. The import now happens once per call instead of on every result record, eliminating the lookup overhead for large transcripts.
There was a problem hiding this comment.
No change on this branch. The import runs once per run (one result record per stream), and a module-level import would make dokimi.repos.events load the whole dokimi.harnesses package (its init pulls in docker_solver and the runner). Same correction as above: 9569eba is not on this branch.
antonwolfy
left a comment
There was a problem hiding this comment.
Review at 3ea412a. Four of the five earlier threads are fixed: the empty usage case, the Task and main-branch cache pins, the lazy import and the long line. One test gap from my first review is still open, and I found a smaller one of the same kind.
What I ran, on a Linux review host at 3ea412a:
make ci-local --with-docker: 18 passed, 0 failed, 6 skipped (network, job conditions, semgrep).- Reproductions with the LLM transport mocked and the container runner faked.
- Mutation runs against
tests/test_repo_reach.py: droppingor not usage,_AGENT_TOOLS = {"Agent"}or{"Task"}, zeroing the cache reads on either path, swapping the scope strings and dropping theagentattribution are all caught now. Two mutants survive, named below.
Still open from my earlier review:
dokimi/dokimi/repos/runner.py:201-203: nothing tests thatrun_cellwritescache_read_tokens,cache_write_tokens,usage_scopeandsubagent_callstoresult.json. Mutation check: removing those four kwargs still gives all oftest_repo_run.py,test_repo_runner.py,test_repo_reach.py,test_repo_reach_edges.py,test_repo_manifest.pyandtest_repo_compare.pypassing (337 passed). Mocking the container runtime is not needed:FakeRunnerandfake_harnessintests/repo_eval_fixture.pyalready driverun_cellthroughrepo run(test_repo_run.py:21-37). AFakeRunnersubclass whoserunwrites a stream-jsonagent.stdoutfor the agent call (anAgenttool_use, a childReadwithparent_tool_use_id, a result withmodelUsagefor two models) passes at the head, and fails withcache_read_tokensNoneonce the kwargs are removed. It assertsresult.json["agent"]hasinput_tokens/output_tokensequal to the model sums,cache_read_tokens,cache_write_tokens,usage_scope == "tree"andsubagent_calls == 1.
Checked, no issues
- Empty usage:
{"usage": {}}givesusage_scope Noneand all four countsNonethroughconvert. The docker-claude parser gives identical output to the base on 18 edge inputs (empty, non-dict, missing keys, bool, non-int,modelUsageas list). - Import:
import dokimi.repos.eventsalone no longer loadsdokimi.harnesses. The load happens on the firstresultrecord. - Malformed usage:
"usage": "n/a"still yields onetrace_errorsentry and keeps cost, turns and answer. - Docs and rules: the new §4.1 text matches the field names and the
tree/main/nullsemantics. The changelog fragment name and format are right, andresult.jsonhas no schema, so there is no bump.
run_cell's result.json fields were only checked on Transcript. A fake harness now reports a subagent and a modelUsage block, and the batch test reads the token fields back from result.json. Nested Agent/Task calls are counted in subagent_calls, as the contract says. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
|
antonwolfy
left a comment
There was a problem hiding this comment.
LGTM. The new tests close both gaps from my earlier review: result.json is now pinned end to end, and nested Task calls are counted in the test. One nit inline.
I agree with @xaleryb that trace: text writes subagent_calls: 0 where "not measured" is null for every other new field.
What I ran, on a Linux review host at 8d019e5:
make ci-localwith docker: 23 passed, 0 failed, 3 skipped (other-Python matrix legs, docs deploy, semgrep).- Mutation runs over
test_repo_reach.py,test_repo_run.pyandtest_repo_runner.py: 31 mutants. Dropping any of the fourrun_cellkwargs, swapping cache read and write, counting onlyAgentor onlyTask, skipping nested calls, swapping the scope strings and summing one model only are all caught. The survivors are named below or are equivalent.
Checked, no issues
- Earlier threads:
test_result_json_carries_the_token_fields_of_the_whole_treedrivesrun_cellthroughrepo runand asserts(37, 11, 100, 5),usage_scope == "tree"andsubagent_calls == 1.test_tokens_cover_the_subagents_the_cost_coversnow assertssubagent_calls == 2with a nestedTask. - Readers:
repos/report.pyandrepos/compare.pyread onlycost_usdandnum_turns, so oldresult.jsonfiles still load. - Docs and rules: the new §4.1 text matches the field names and the
tree/main/nullsemantics; the changelog fragment is inchangelog.d/;result.jsonhas no schema, so there is no bump.
| "input_tokens": None, "output_tokens": None, "plugins": [], "over_budget": False, "caps": caps, | ||
| "network": network, "trace_errors": []} | ||
| "input_tokens": None, "output_tokens": None, "cache_read_tokens": None, | ||
| "cache_write_tokens": None, "usage_scope": None, "subagent_calls": None, |
There was a problem hiding this comment.
Nit: the failure-path defaults of the new keys are not pinned.
Mutation check: changing "usage_scope": None, "subagent_calls": None here to "usage_scope": "tree", "subagent_calls": 0 still passes test_repo_run.py, test_repo_reach.py and test_repo_runner.py (154 passed). A cell whose agent was killed before it wrote a trace would then report a measured zero delegation and a tree-wide scope, where the contract says null means no measurement.
Suggestion: in a killed-harness test, assert usage_scope and subagent_calls are None in result.json.
A killed or unreadable agent left subagent_calls at 0, which read as a measured zero delegation. It stays null until the harness's result record is parsed, and the failure-path tests now pin the null defaults. Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>


Summary
repo runmixed scopes: tokens came from Claude Code's parent-onlyusage, cost from tree-widetotal_cost_usd. Now tokens use the same rule the docker-claude parser already follows.result.jsonagent record also gainscache_read_tokens,cache_write_tokens,usage_scope,subagent_calls. Anevents.jsonlevent made by a subagent carriesagent(the parent'sAgent/Tasktool-use id). Contract §4.1 is updated.Evidence
Agentcall,usage= 7/2 andmodelUsagemain 7/2 + sub 30/9 →input_tokens=7, output_tokens=2next tocost_usd=0.5covering both.After:
test_tokens_cover_the_subagents_the_cost_covers→(37, 11, cache 100, 5),usage_scope="tree",subagent_calls=1, child Read event hasagent: "t1".test_tokens_without_model_usage_are_the_parents_only→"main".make ci-local: 18 pass. Bandit fails locally only because it scans a local.venv; with that excluded,bandit -r . -ll -x ./.venvis clean.Merge Danger
Door: two-way
Blast Radius: reports
Token totals of new cells where the agent delegated go up compared with older cells in the same batch. The changelog says to re-run those cells before comparing. Only fields are added; none are removed.
🤖 Generated with Claude Code