Skip to content

feat(context): context trace, the prompt size of every model call in a Claude Code trace - #68

Merged
napetrov merged 6 commits into
intel:mainfrom
napetrov:feat/context-trace
Oct 16, 2026
Merged

napetrov merged 6 commits into
intel:mainfrom
napetrov:feat/context-trace

Conversation

@napetrov

Copy link
Copy Markdown
Contributor

Summary

Until now the tracks scored what an agent produced, not what its context window held. This adds python cli.py context trace. It reads a Claude Code stream-json trace and reports the prompt size at every model call. No model call.

stream-json line                         -> what it adds
system/init                              -> surface: tools, skills, agents, MCP servers, loader plugins
assistant (message.id, usage)            -> one call per id: prompt = input + cache_creation + cache_read
  parent_tool_use_id set                 -> a subagent call, counted apart (its own window)
  tool_use                               -> per-tool call count; same name+input again = repeat
user/tool_result                         -> per-tool result chars; is_error = failed call
system/compact_boundary                  -> compaction (trigger, pre_tokens)
result                                   -> num_turns, subtype
dokimi/context/trace.py      parse_trace / read_trace -> ContextTrace.summary(window)
dokimi/commands/context.py   `context trace TRACE... [--window N] [--calls] [--json]`
docs/context-window.md       columns, and how to compare free / loaded / with-skill runs

When given several traces, it prints one comparison table, so the layers of a context compare side by side: free workspace, guidance loaded, a run without the skill and a run with it. The page says how to keep the free run clean: no CLAUDE.md in any ancestor, and CLAUDE_CONFIG_DIR pointing at an empty directory.

Evidence

  • Before: python cli.py context trace run.jsonl fails with invalid choice: 'context'. The only token data for a Claude Code run was the total from the final result event, so neither the start-up load nor the peak was visible.

  • After: the same task (fix two bugs in a small repo) run with Sonnet 5.5 in four arms, one rep each, read by the new command:

    | trace              | calls | first  | peak   | growth | compact | tools | failed | repeat | result chars |
    | none               | 4     | 15,864 | 18,102 | 2,238  | 0       | 3     | 0      | 0      | 4,159        |
    | skill              | 4     | 15,909 | 17,388 | 1,479  | 0       | 3     | 0      | 0      | 1,887        |
    | skill + 30 unused  | 5     | 18,607 | 19,757 | 1,150  | 0       | 4     | 0      | 0      | 1,219        |
    | AGENTS.md          | 4     | 16,108 | 17,367 | 1,259  | 0       | 3     | 0      | 0      | 1,861        |
    

    All four arms pass the task. The difference is in the context: the skill roughly halves history growth and tool-result volume, and 30 unrelated skills add about 2.7k tokens to every call. The parser was first checked against a live trace from Claude Code 2.1.296. A call written as several messages under one id counts once; on the probe trace, first (21,390) matches that call's three usage fields summed.

  • tests/test_context_trace.py (11 tests): one call per message id, a subagent kept apart, a compaction with its position, a failed and a repeated tool call, list-content results, bad lines, a file that is not a trace, the table, JSON output, exit code 1.

  • make ci-local: lint, mypy, tests (4304 passed), changed-line coverage, docs build and schema-compat all pass. Docker-only rows were skipped.

Merge Danger

Door: two-way. It adds a new command group and package; nothing existing reads them.

Blast Radius: none

No persisted format: the command prints to stdout, and no schema is added. --window is required for the fill fraction, because the trace does not state the model's window and the command does not guess it.

Checklist

  • Tests added/updated and pytest passes
  • ruff, mypy, and schema checks pass
  • Docs updated (or N/A) — see the docs-drift checklist in CONTRIBUTING.md
  • No ad-hoc run artifacts committed

🤖 Generated with Claude Code

@vchamarthi vchamarthi 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.

Thanks, this is a nice, self-contained addition. Deduping calls by message id and keeping subagent windows apart are exactly the two things that make these numbers trustworthy, and the fixture in the tests exercises both. A couple of small things inline.

name = str(block.get("name") or "")
names[str(block.get("id") or "")] = name
out.tools.setdefault(name, ToolStats()).calls += 1
signature = (agent, name, json.dumps(block.get("input"), sort_keys=True, default=str))

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.

Is repeat meant to include retries after a failed call and re-reads after the file changed (e.g. Read, Edit, Read on the same path, or running the same test command twice)? Those all match on name and input today, so the doc's 're-reading what it already has' might overstate it. Could be worth either excluding calls whose earlier twin errored, or softening the wording in context-window.md.

Comment thread dokimi/dokimi/commands/context.py Outdated
rows.append(row)

if args.json:
print(json.dumps(rows if len(rows) > 1 else rows[0], indent=2))

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.

Nit: with --json, one trace gives an object and several give a list, so anything scripting over this has to check the type. Always emitting a list (or always an object keyed by trace) would be a bit easier to consume, if no caller already relies on the single-object form.

@codecov-commenter

codecov-commenter commented Oct 16, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 96.53465% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 88.59%. Comparing base (921cfb6) to head (b19dd61).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
dokimi/dokimi/context/trace.py 94.81% 2 Missing and 5 partials ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #68      +/-   ##
==========================================
+ Coverage   88.14%   88.59%   +0.44%     
==========================================
  Files         198      201       +3     
  Lines       22616    22911     +295     
  Branches     3606     3663      +57     
==========================================
+ Hits        19934    20297     +363     
+ Misses       1999     1914      -85     
- Partials      683      700      +17     
Files with missing lines Coverage Δ
dokimi/dokimi/commands/context.py 100.00% <100.00%> (ø)
dokimi/dokimi/context/__init__.py 100.00% <100.00%> (ø)
dokimi/dokimi/context/trace.py 94.81% <94.81%> (ø)

... and 26 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…a Claude Code trace

Reads a stream-json trace into per-call prompt tokens (input + cache_creation +
cache_read, one call per message id, subagent calls apart), compaction events,
and tool-result size, failures and repeats per tool. Several traces print as
one comparison table, so free / loaded / with-skill runs compare side by side.
No model call.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
Claude Code 2.1.296 writes post_tokens next to pre_tokens; a forced
compaction measured 107,547 -> 99,842, so the drop is not assumed.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
str.splitlines() also breaks on U+2028, U+2029 and U+0085, which
JSON.stringify and json.dumps(ensure_ascii=False) keep raw inside strings.
A record holding one was cut into invalid halves and dropped, together with
its model call and tool result, with only a stderr warning and exit 0.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
A UTF-8 BOM dropped the init record (empty surface), a non-UTF-8 byte or a
UTF-16 file ended in a UnicodeDecodeError traceback, and a usage value of
Infinity raised an uncaught OverflowError. Decode with utf-8-sig and
errors=replace, and treat OverflowError as an unexpected shape of one line.

Signed-off-by: Alexander Rybkin <alexander.rybkin@intel.com>
@xaleryb

xaleryb commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Ran context trace at 8f0984b on 13 hostile traces and a 100 MB one (20,000 calls, about 1.2 s, 340 MB RSS), plus the full suite, ruff, mypy and bandit. The arithmetic matches an independent sum (3+100+1000 = 1103), partial-then-final blocks with equal prompt fields give one call, subagent calls are counted apart, --json is deterministic, CRLF parses cleanly, and the docs example (21673/200000 = 11%) is right.

Two defects, fixed in the commits pushed here (each test red before):

  1. 13f758d: parse_trace uses raw.splitlines(), which also splits on U+2028, U+2029 and U+0085 that JSON.stringify keeps raw inside strings. An assistant text with U+2028 gives two "not JSON" warnings, the call is lost and the exit code is 0 (3-call trace: calls=2, first=115 where 110 is expected). Now split on "\n".
  2. a5db543: a BOM drops the init record (the surface), a UTF-16 file (what PowerShell 5 writes for claude ... > run.jsonl) or one stray byte raises a UnicodeDecodeError traceback, and a usage value of Infinity raises OverflowError. Now utf-8-sig with errors="replace" and OverflowError caught: UTF-16 ends as a TraceError (exit 1), a bad byte is a one-line warning.

Open, your call: for a repeated message.id, by_message[key].update(call) lets a later block with absent or empty usage overwrite the real prompt size with 0 (m1 with usage 3/100/1000, then m1 with {}: first=peak=0, no warning; expected 1103). Real streams repeat the cache fields on every block, so I did not see it on genuine traces, but the docs promise "same prompt, fuller output" and no test covers it; merge per field with max() or overwrite only the fields present. events.py:75 has the same splitlines() flaw (not touched here). docs/context-window.md has no SPDX header, which #62's license-header job will require, and #39's changelog gate will need a fragment.

@antonwolfy antonwolfy 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.

Review at a5db543. The design is right: one call per message id, subagents counted apart, and the prompt as the sum of the three input fields. The four commits fix real parsing hazards. The problems are in the edges of the two metrics the docs lean on, repeat and the repeated-id merge, and in the CLI's handling of one bad file among several.

What I ran, on a Linux review host at a5db543:

  • make ci-local: 19 passed, 0 failed, 4 skipped (docker, semgrep, deploy legs). 4,375 tests, changed-line coverage 98% (185 lines; uncovered: commands/context.py:60, trace.py:180, trace.py:210).
  • Reproductions for the code findings below, with no model call (the command reads a file).
  • A mutation run over trace.py and commands/context.py: 151 mutants, 67 survive tests/test_context_trace.py (about 8 of them equivalent). The gaps that matter are in the comment on the tests.
  • The docs example rebuilt from a 2-call trace (3+21000+387, 3+283+21387): the command prints 21,390 | 21,673 | 11% | 283 as the page says.

Points I'd fix before merge: the repeat definition (trace.py:173), the repeated-id merge (trace.py:162) and the abort on one bad trace (commands/context.py:24). The rest can wait.

Nits that have no inline anchor:

  1. A trace name containing | breaks the table row. context trace 'a|b.jsonl' t.jsonl prints | a|b.jsonl | 2 | 21,390 | ..., one cell more than the 10-cell header, so every value shifts right (commands/context.py:65).
  2. The README tree lists context/ out of order. The list at dokimi/README.md:238-249 is alphabetical, and context/ sits between dashboard/ and eval/. It belongs after commands/.

Checked, no issues

  • Dedup and subagents: blocks of one id count once with the later block winning; a subagent's calls stay out of calls, and its ls is not a repeat of the main thread's. A subagent-only trace summarises with null prompt fields and no crash.
  • Odd shapes: message as a list or string, usage as a list, tools/mcp_servers as an int or dict, non-string type, bad tool_use_id and content, a float or Infinity token value all end as a warning or are ignored; none raises out of parse_trace.
  • Scale: a 61 MB trace with 12,000 records parses in 0.46 s at 191 MB peak RSS.
  • Repo rules: the page is in mkdocs.yml nav and docs/README.md; the command is registered in cli.py; SPDX headers are on every new .py file (no .md in the repo carries one); no schema is involved since the output is stdout only; test_cli_help.py, test_docs_links.py and test_docs_code_refs.py pass with the new page. A zero or negative --window is an argparse error (exit 2).

Comment thread dokimi/dokimi/context/trace.py Outdated
Comment on lines +160 to +162
key = message.get("id")
if key and key in by_message:
by_message[key].update(call) # a later block of the same call: same prompt, fuller output

@antonwolfy antonwolfy Oct 16, 2026 •

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.

A later block of the same message id with an empty or partial usage overwrites the real prompt size with 0. update(call) replaces every key, and _call turns a missing field into 0.

  • m1 with usage 3/100/5000, then m1 again with usage: {}: the call ends as prompt_tokens: 0, new_tokens: 0. Expected 5103. first, peak and final are then wrong with no warning.
  • Second block with only {"output_tokens": 90}: prompt_tokens: 0, output_tokens: 90.
  • Second block with a different parent_tool_use_id: agent is replaced too, and a main-thread call moves into the subagent count ('agent': 'toolu_x').
  • Second block with no usage key at all is fine (5103), so the failure is specific to a present-but-sparse dict.
  • An all-zero usage block with a new id is appended as a call of its own, so final becomes 0 and growth is measured against the wrong end: two calls at 5103 and 9103 plus one all-zero block report calls: 3, final_prompt_tokens: 0.

The docstring promises "same prompt, fuller output"; the code does not enforce it. Real streams repeat the cache fields on each block, so I did not see this on a genuine trace. This was raised in the earlier comment on this PR and is still open at this head.

Suggestion: merge per field with max() (prompt and new tokens) and keep the first agent; skip a call whose four usage fields are all zero.

Comment thread dokimi/dokimi/context/trace.py Outdated
Comment on lines +173 to +176
signature = (agent, name, json.dumps(block.get("input"), sort_keys=True, default=str))
if signature in seen:
out.repeated_calls += 1
seen.add(signature)

@antonwolfy antonwolfy Oct 16, 2026 •

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.

repeat counts any second call with the same name and input, so ordinary retry and verify loops are reported as an agent re-reading what it already has. The signature is (agent, name, input) and nothing resets it.

  • Bash {"command": "make test"} fails (is_error), the agent fixes the file and runs the same command again: repeated_tool_calls = 1.
  • Read /a.py, Edit /a.py, Read /a.py: repeated_tool_calls = 1, although the file changed in between.

In a with-skill versus without-skill comparison this column will blame the agent for the very loop that makes a task pass. docs/context-window.md:37-39 describes it as "an agent re-reading what it already has". @vchamarthi asked this on the PR; the four commits do not change it, so it is still open.

Suggestion: do not count a call whose earlier twin returned is_error, and drop the recorded signatures after a call that can change state (Edit, Write, Bash). If that is out of scope, rename the column and the docs to "identical calls" so the number is not read as waste.

Comment on lines +117 to +121
try:
rec = json.loads(line)
except json.JSONDecodeError:
out.errors.append(f"line {n}: not JSON")
continue

@antonwolfy antonwolfy Oct 16, 2026 •

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.

json.loads can raise something other than JSONDecodeError, and that escapes as a traceback. Only JSONDecodeError is caught here, and the commit that added OverflowError handling targets the same class of problem.

  • A line holding an integer of 5,000 digits raises ValueError: Exceeds the limit (4300 digits) for integer string conversion out of parse_trace, so context trace dies with a traceback and no table.
  • A line of 100,000 [ raises RecursionError.

Both lines are valid-ish junk that a corrupt or truncated trace can contain. One such line should be a line N: not JSON warning like the others.

Suggestion: except (json.JSONDecodeError, ValueError, RecursionError):. JSONDecodeError is a ValueError, so (ValueError, RecursionError) is enough.

Comment thread dokimi/dokimi/commands/context.py Outdated
Comment on lines +22 to +24
except TraceError as exc:
print(f"✗ {exc}", file=sys.stderr)
sys.exit(1)

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.

One unreadable trace among several discards every good result. sys.exit(1) fires inside the read loop, before the table or the JSON is printed.

context trace a.jsonl empty.jsonl b.jsonl prints only ✗ empty.jsonl: no assistant message and no init event; ... on stderr, exits 1, and writes nothing to stdout. The numbers for a.jsonl are lost, and its parse warnings were already printed. The page sells exactly this multi-file comparison, usually with four arms, so a run that crashed before its first call costs the whole table.

Suggestion: collect the error, keep reading, print the table for the good traces, and exit 1 at the end. If aborting is intended, say so in context-window.md.

Comment thread dokimi/dokimi/commands/context.py Outdated
rows.append(row)

if args.json:
print(json.dumps(rows if len(rows) > 1 else rows[0], indent=2))

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.

Nit: --json prints an object for one trace and a list for several, and the page never says so. A script over a variable number of files has to check the type. @vchamarthi raised this and it is unchanged at this head. Always emitting a list is the simpler contract; nothing in the repo reads the single-object form yet.

Comment thread dokimi/docs/context-window.md Outdated
Comment on lines +34 to +36
- **compact**: `compact_boundary` events. After one the prompt can drop; the
`--calls` series shows by how much, and `--json` gives each event's trigger,
`pre_tokens` and (on newer CLIs) `post_tokens`.

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.

Nit: plain --json does not give each compaction's trigger, pre_tokens and post_tokens. Those events are emitted as compaction_events only with --calls (commands/context.py:30). On a trace with one compaction (pre_tokens 150000, post_tokens 9000), context trace c.jsonl --json prints "compactions": 1 and no compaction_events key.

Suggestion: say "--json --calls gives each event's ...", or emit compaction_events always.

Comment thread dokimi/docs/context-window.md Outdated
Comment on lines +61 to +63
`surface` field of `--json` (tool, skill, agent and MCP server counts, and the
loader plugins) shows what start-up offered, so two runs that differ there are
not the same condition.

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.

Nit: the page says surface holds "tool, skill, agent and MCP server counts, and the loader plugins". Only tools, skills and agents are counts; mcp_servers and plugins are sorted lists of names (trace.py:140-146). For an init with mcp_servers: [{"name": "ctx"}] the output is "tools": 1, "mcp_servers": ["ctx"], "skills": 2, "agents": 0, "plugins": [].

assert s["repeated_tool_calls"] == 1
assert s["tool_result_chars"] == 300 + 300 + len("File does not exist.") + 50 + 4
assert s["largest_tool_results"][0] == {"tool": "Bash", "calls": 3, "chars": 650, "max_chars": 300}
assert trace.compactions == [{"trigger": "auto", "pre_tokens": 20815, "post_tokens": 9000, "after_call": 2}]

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 leave the merge, the repeat definition and the summary limits unpinned. I ran 151 mutants of trace.py and commands/context.py against this file; 67 pass it (about 8 are equivalent). The ones that matter:

  • No test repeats a message.id with sparse usage, so the finding at trace.py:162 is invisible, and so is the "same prompt, fuller output" contract the docstring states.
  • repeat is only tested for the agent part of its signature. Replacing the tool name in the signature with "" (so Read and Bash with equal arguments count as repeats), or ignoring the input, passes the suite. So does any definition of retry or read-edit-read.
  • RUN has three tools, so heavy = ...[:3] in commands/context.py:38 can become [:4] or no limit and every test still passes. The same holds for the if s.result_chars filter and the [:5] in summary, and nothing asserts the order of largest_tool_results beyond the first entry.
  • Failed calls are never different from repeats in the fixture (both are 1): counting distinct failing tools instead of failed calls, or swapping the failed and repeat columns, passes.
  • The camelCase compactMetadata/preTokens fallback and a bare compact_boundary are never fed in; removing either fallback passes.
  • An init-only trace, an assistant-only trace and the table without --window are untested (commands/context.py:60, trace.py:180 and :210 are the uncovered lines).

Suggestion: add one test per item. For the first, m1 with _usage(3, 100, 5000) then m1 with {}; assert prompt_tokens == 5103. For the limit, four tools with results and assert the line names three.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
- Merge repeated message-id blocks per field with max() and keep the
  first block's thread, so a sparse later usage cannot zero the prompt;
  skip all-zero usage blocks instead of counting them as calls.
- Count a repeat only against an earlier call that did not fail, and
  forget earlier calls after Bash/Edit/MultiEdit/NotebookEdit/Write, so
  retries and read-edit-read loops are not reported as waste.
- Treat any ValueError or RecursionError from json.loads as a
  "not JSON" line warning instead of a traceback.
- CLI: an unreadable trace among several no longer discards the rest;
  report it, print the others, exit 1 at the end. --json always prints
  a list. Escape `|` in trace names in the table.
- Docs: repeat definition, --json --calls for compaction_events, what
  surface holds, the list output; README tree order.
- Tests for each of the above, plus the summary limits/order, camelCase
  and bare compactions, init-only and assistant-only traces.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov
napetrov merged commit 1f46888 into intel:main Oct 16, 2026
23 checks passed
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.

5 participants