Usage parser¶
Pure-Python helpers that decode the JSONL stdout agents.models.AgentResult carries into a UsageMetrics dataclass —
backend, distinct model(s), turn count, input / output / cached / cache-read / cache-write token totals, cost_usd, and
a cost_source tag of reported / estimated / unknown-price / no-usage. No external dependency: the parser is
jq-free.
The pass runs once the audit agent_exit event has already fired, and record_agent_exit writes what it returns as
the token / cost detail on the analytics agent_exit record and the
run_usage summary on the trajectory record. Two siblings ride
the same stream under the same resilience contract: the opt-in skill-trigger extractor and the trajectory classifier
below. None of the three reads or writes a file — redaction, truncation, and the append itself belong to the writers
downstream of them.
Where the parser sits in the package tree, and what it is responsible for beside the surfaces around it, is in
architecture/observability-modules.md; the knobs are in
configuration/observability.md.
Module layout. Callers import the parsers directly from metrics.py, skills.py, and trajectory.py.
Each dispatches across Codex, Claude, and Antigravity. The six result types come from their defining modules:
UsageMetrics and SkillTriggers from the first two, and AgentTrajectory / TrajectoryStep /
SourceItem / TurnUsage from trajectory_models.py. Provider payload handling is split behind them: protocol.py
holds the JSONL vocabulary and event_stream.py the resilient line decoder, prices.py the first-party price tables
and model_names.py the nested model-name lookup, claude_rows.py / claude_summary.py and codex_rows.py /
codex_summary.py the per-provider frame decoding and run summary, with claude_settlement.py settling each Claude
message's final output count between the two, shell_segments.py / skill_commands.py /
skills_claude.py / skills_codex.py the skill-evidence classification, and trajectory_claude_blocks.py /
trajectory_claude_stream.py / trajectory_claude_turns.py plus trajectory_codex.py and the per-item-type
trajectory_codex_items.py under it the timeline reconstruction. Antigravity's envelopes, tool lifecycles, and
completed-step usage live in agy_events.py / agy_summary.py, with its text and tool timeline in
trajectory_agy.py. The
trajectory classifier reuses the same event decoder, pricing path, and skill evidence owners, so the resilience and
cost-precedence contracts are defined once. Tests intercept parsers on the module their caller imports. The package
initializer re-exports the public parsers and result types; callers name the defining owner so patches reach it.
Provider parsing. parse_claude_usage(stdout) consumes claude --output-format stream-json events, reads one
usage row per API message by grouping its assistant frames on message.id, and sums per-model. Every one of those
frames repeats the usage the message's message_start event opened with, so the output count it carries is the one at
the start of the message. The final count, thinking included, is only on the message_delta stream event that closes
the message, whose stream_event wrapper names it through api_message_id; that event settles the message's output
count. Input, cache-read, and cache-write counts are the same on every frame and stay the ones the assistant frames
carry. A subagent prints no stream events. The message that closes it prints no frame at all: its usage rides on the
lead thread's tool_result for the call that launched the subagent, as tool_use_result.usage, and that hand-back
adds a row carrying the message's output count alone, since the run's input and cache counts are its frames'. A
hand-back counts once per call it answers — the tool_use_id of its tool_result — since a resumed subagent returns
under the agent id it had before. The final count of any earlier subagent message is printed nowhere: the result
frame's modelUsage reports only the model's total, and what it holds beyond every output count the stream printed
settles that message only when it is the model's sole message no message_delta closed, and only when the model's
modelUsage input and cache counts equal the ones the stream printed, hand-backs included. Two or more such messages
share a total no frame divides, so each keeps its start count, as a subagent's message does whenever modelUsage
also covers usage no frame of the stream printed. A stream with no message_delta at all — printed without
--include-partial-messages, which the Claude backend always passes — settles nothing: every message keeps the count
its last frame carries, and neither hand-backs nor modelUsage are read. On the captures the run's output equals the
CLI's modelUsage total: 358 on the main run, and 450 on the subagent run, whose lead thread alone reports 314. A
stream with no assistant usage falls back to the result frame's usage. The sanitized Claude Code 2.1.295
captures this accounting is pinned to, and the frame-by-frame evidence behind it, are in
usage_claude_stream_capture.py.
parse_codex_usage(stdout, fallback_model=None) consumes codex --json events and treats
usage as cumulative across the session: the last non-zero usage record is the authoritative total.
parse_agent_usage(backend, stdout, fallback_model=None) dispatches by backend string the same way
agents.runner.run_agent does.
Claude output counts recorded before the correction. The parser kept the last assistant frame's output count,
the one at the start of each message: 31 output tokens on the main capture, where the CLI reports 358, and 92 on the
subagent capture against 450. The correction is unreleased: v0.13.0 and every earlier release record the start
counts. It lands with pull request #2199, and a
source checkout is corrected exactly when its history contains that change; the version string is no boundary, since
a checkout taken after the v0.13.0 tag, corrected or not, still reports 0.13.0. Nor is a record, which carries no
orchestrator version: records are corrected from when a host started running a corrected checkout. Records already
written keep their values: output_tokens on agent_exit and agent_trajectory
lines, the analytics database rows synced from them, and the pinned issue_total_tokens an issue accumulated, along
with issue_total_cost_usd where a run's cost was estimated from the undercount. Nothing backfills them, and an
issue in flight across the upgrade mixes both in its counters. The correction leaves input and cache counts, and a
CLI-reported cost_usd, unchanged.
For agy, token totals come from the completed step_update events in this invocation, deduplicated by step index.
The terminal result's usage and turn counts include earlier conversation turns on a resume, so they are not added
to the per-run counters. user_input steps count this invocation's turns; init.model supplies the model, falling
back to the configured --model. Output tokens already include thinking tokens. The CLI supplies no USD cost;
usage has unknown-price, with no estimated amount. No completed usage frames means no-usage.
Antigravity trajectories join each response step's text deltas, retain tool parameters and outcomes, read offered
tools from init, and take the final answer only from a successful terminal result. Interrupted response text is
kept in the timeline without becoming a final answer. Skill evidence remains empty because this adapter does not
infer skill loads from Antigravity's tools; no per-turn pricing or source-item accounting is emitted.
Tool lifecycles fold repeated and sparse step_update frames by step_index, retaining tool identity across partial
updates and recording each step's latest state. The lifecycle reducer reports every indexed tool step whose latest
state is not DONE, ensuring earlier background commands remain authoritative even when later status checks finish
and emit terminal output.
Cost precedence. A total_cost_usd reported by the CLI itself always wins (cost_source="reported"); otherwise the
parser walks first-party Anthropic / OpenAI price tables baked into the module and produces an estimate ("estimated").
When usage is present but the model SKU does not match any priced family, the parser returns
cost_source="unknown-price" and cost_usd=None rather than guess at zero or bill cached tokens at the input rate. An
empty stream — or one with no usage frames at all — yields "no-usage".
Resilience. Malformed JSON lines (banner text, truncated frames, partial flushes) are silently skipped so a single
bad line never invalidates the rest of the stream. _run_agent_tracked (in workflow/engine/usage.py) calls
parse_agent_usage after every tracked agent run and appends the parsed counts to the
analytics sink under event="agent_exit"; a
parser exception is caught and downgraded to a log.exception.
Terminal verdict surface. Beyond the analytics sink, _accumulate_issue_usage (in workflow/engine/usage.py,
beside _run_agent_tracked) folds each run's
UsageMetrics into per-issue counters on the pinned state (issue_agent_runs / issue_total_tokens /
issue_total_cost_usd / issue_cost_sources; see state-machine/labels-and-state.md). When an
issue reaches a terminal, _format_issue_usage_verdict renders those counters into one visible receipt line
posted on the issue thread — :receipt: this issue: N agent runs · T tokens · $X.XX, with (est.) appended when any
run's cost was estimated and the figure collapsed to unknown when an unknown-price run leaves the total
incomplete. The PR merged / rejected finalizers and the closed-question terminal post it as a standalone tracked
comment; the umbrella close comment appends it. It is a read-only summary — nothing gates on the figure — and it is
skipped when no run was ever counted.
issue_agent_runs has one reader outside that receipt: the lifetime agent-run ledger on
workflow/engine/run_ledger.py seeds a missing agent_runs_used from it and never reads below it, so an issue
already in flight keeps what it has already spent. The two count the same unit but not the same set — the ledger
charges the launch and so also holds the runs whose usage never parsed, while this meter counts the parsed exits —
which is why the ledger's total is the larger of the pair. Nothing turns a run away against THIS meter — the ledger
is the one that is judged, at the tracked spawn boundary (workflow/engine/run_circuit.py), and where an issue that
has spent it all STOPS is the durable agent_run_limit park on workflow/engine/run_limit.py and the dispatcher
hold over it. What that ledger DOES, as opposed to what it currently holds, is its own record:
workflow/engine/run_budget.py writes one
agent_run_budget event to both sinks per durable
transition — a run charged, the spawn it paid for, the ceiling that refused one, and the ceiling an operator widened
— each carrying the configured limit, the allowance in force, the spend, and what is left. That is the stream to
count charges on; agent_exit records, and this meter behind them, count the runs whose usage parsed.
See configuration.md for MAX_AGENT_RUNS_PER_ISSUE and
state-machine/labels-and-state.md for the ledger's and the park's own keys.
Skill-trigger extractor (opt-in). A sibling trio mirrors the usage parsers' two-parsers-one-dispatcher shape and
resilience contract to record which agent skills a run loaded, gated behind TRACK_SKILL_TRIGGERS (default off;
see agent_exit records). The result is a names-only
evidence model on SkillTriggers: triggered / trigger_counts are the loaded skills, evidence maps each to its
tier (confirmed / inferred), and incidental / incidental_counts are path-only references that never become
loads. The two buckets are independent —
a skill both read and inspected is recorded in both — the only exclusion is structural: an incidental reference never
enters triggered / trigger_counts or the skill_triggered audit events. parse_claude_skills(stdout) reads the
firm confirmed signal — tool_use
content blocks named Skill in the assistant stream — and returns input.skill in first-seen order, de-duplicating
per invocation by the block id. (A captured real stream showed that under --include-partial-messages each completed
content block lands in its own assistant frame — the content array is partitioned across frames, not a cumulative
snapshot that repeats earlier blocks the way the usage sub-object does — so the parser walks every frame and de-dups
by id rather than keeping the last frame per message id.) Claude has no dedicated file mechanism, so it produces no
incidental references. parse_codex_skills(stdout) recognizes the codex shape: codex has no dedicated Skill tool, so
its file-based skill mechanism surfaces only as a command_execution item whose shell command opens a skill's
skills/<name>/SKILL.md file (codex's own "open its SKILL.md" instruction). A captured reviewer run pinned this — codex
both registered-under-$CODEX_HOME/skills/ and project-local .agents/skills/ reads match. The command is first
unwrapped (peeling the bash -lc "…" shell) and split into sub-commands on unquoted, unescaped operators —
quote- and backslash-aware, so a metacharacter inside a quoted argument (rg 'foo|bar' path) or a backslash-escaped one
(rg foo\|cat path) does not fabricate a spurious segment — then each SKILL.md match is routed by its sub-command's
leading verb (skipping any NAME=value env prefix). Only a verb
established as a direct reader (cat / sed / head / …) makes the reference an inferred load; every other
verb — an inspection / search (git diff / git status / rg), an env-prefixed inspection (GIT_PAGER=cat git
diff …), or a generic path-only command (echo …) — makes it an incidental reference. Even a reader verb is
demoted to incidental when the SKILL.md is written rather than read: an output-redirect target
(cat t > .agents/skills/x/SKILL.md) or a non-reading mode (sed -i / --in-place) is an incidental reference, so a
skill file a run only writes is never miscounted as a load. So a read chained after an inspection still counts, and a
bystander git diff over a changed SKILL.md does not fabricate a load. Started/completed
echo the same command, so the parser dedups by the shared item.id (last-frame-wins, as for usage) — for inferred
loads and incidental references alike. It reads only the <name> path segment and the routing verb — never the command
text or its aggregated_output (the file's contents), both of which can echo user content (names-only Privacy). The
inferred signal stays heuristic within the reader allowlist: a reader that opens a SKILL.md for an unrelated reason
would still register as an inferred load, while defaulting non-readers to incidental keeps an unrecognized command from
fabricating a trigger. parse_agent_skills(backend, stdout) dispatches by backend exactly as parse_agent_usage does.
The offered-skills set (SkillTriggers.available) is
confirmed on claude — read from the dedicated skills array in the system/init frame, captured against a real
stream — and
stays empty on codex at the parser layer: a captured codex exec --json stream (v0.142.5) carries no offered-skills
frame at all, so record_agent_exit backfills the codex offered set out-of-band from the filesystem via
skills.discovery.discover_local_skill_sources(cwd) instead — a scan that also reports the source level each name was
defined at, which the agent_exit record keeps beside the names as skill_levels. The triggered set does not
depend on it either way. As with the usage parsers, malformed JSONL lines are skipped and a missing / renamed field
yields an empty result rather than an exception. Only the skill name is ever read — never the Skill tool's args
(Privacy).
Trajectory classifier. A third sibling trio mirrors the same two-parsers-one-dispatcher shape and resilience
contract to reconstruct a run's trajectory — the ordered timeline of tool calls / results interleaved with the
assistant / user text turns — into an AgentTrajectory dataclass: backend, a best-effort system_prompt / tools,
the names-only skills (SkillTriggers), an ordered steps tuple of TrajectoryStep (kind is "tool_call" /
"tool_result" / "assistant_message" / "user_message", with name / tool_id / raw content — name /
tool_id empty on the text turns — plus a turn index tying each billed step back to the assistant turn that
produced it), a source_items tuple of SourceItem accounting for every item the provider stream identified
(codex-only today), final_output, and a best-effort turns tuple of per-turn token usage (TurnUsage, parallel to
tools / skills and claude-only today). parse_claude_trajectory(stdout) reads the offered tools from the
system/init frame's tools array, then the ordered timeline: assistant text blocks become assistant_message
turns and tool_use blocks tool_call steps; user text blocks become user_message turns and tool_result blocks
tool_result steps — calls / results joined by tool_use_id and de-duplicated per id the same way
parse_claude_skills is, while id-less text blocks ride claude's per-completed-block framing — and the final answer
from the result frame's result string. It also groups those assistant frames by message.id in first-seen order to
assign each a 0-based turn index — stamped onto the assistant_message / tool_call steps that frame produced (a
tool_result / user_message step is a turn input, not billed output, so its turn stays None) — and emits one
TurnUsage per turn: model, input_tokens / output_tokens, cache_read_tokens / cache_write_tokens (the 5m + 1h
cache-creation buckets summed), and an always-estimated cost_usd / cost_source ("estimated", or
"unknown-price" with cost_usd=None for an unpriced SKU — a reported total_cost_usd is a run-level figure and
never reaches a turn). A turn's output_tokens is settled exactly as in the run parser above — its message's final
count where the stream shows one, the start count its frames carry where it does not — while its input and cache
counts are the frames'. Where hand-backs are read, a subagent's closing message, which printed no frame and so stamped
no step, becomes a turn of its own numbered after every turn the steps carry, holding the hand-back's output count
alone; the turns the frames gave keep their indices. Both builders settle their rows through
claude_settlement.ClaudeSettlement, and the per-turn estimate reuses the same claude_estimate_cost price path on
observability/usage/prices.py as the run aggregate. Unless the run totals fall back to the result frame, the
per-turn token counts therefore sum to parse_claude_usage's run totals and the per-turn estimates to its estimated
cost. parse_codex_trajectory(stdout) normalizes each
operational item codex reports into one tool_call and, when the stream actually carried an outcome, one
tool_result beneath it, and each agent_message item into one assistant_message turn (its text). Codex emits
several frames per item — item.started, any item.updated, then item.completed — so every item is correlated by
its item.id and a frame contributes only the fields it carried, which is what collapses a started/completed pair
into a single pair of steps and lets the completing frame name what the started one had not resolved yet — except
where the item type says otherwise: an item codex republishes whole on every frame is invoked once, by the frame that
opened it, so a revision moves its result rather than its call. Every item also keeps the position its first frame
took, so folding a later frame into it fills that pair in place rather than moving it behind the operations that
started after it, and the timeline reads in the order the run started its operations. A frame the stream left without
an item.id correlates with nothing, so each one stands on its own and its steps trail the ordered items rather than
claiming a position between two of them. The supported item types and the fields each pair is
built from are: command_execution (its command, then the
aggregated_output of the frame that completed it — a running command already reports that field, empty, so
presence alone would credit a command still running with a result it never produced), mcp_tool_call (its
arguments, then its result — or its error when the server filled that instead — under a <server>.<tool> step
name), web_search (its query, then the action it resolved to — a search announces itself with an empty query
and names itself only on the frame that completes it, so that frame is both what the query is read from and the only
one that reports an outcome, and that action is the whole of the outcome the stream carries: the pages a search read
and the answers it got are absent from the stream and are never reconstructed for the record), file_change (its
changes, kept as the structured one-entry-per-path list codex reports them as, then the status once it is terminal
— completed or failed, on whichever frame carries it, since a patch reports in_progress until it has ended, so a
patch that failed stays distinguishable from one the run left half applied, and a patch still in_progress when the
stream stopped keeps its call with no result under it), and todo_list (the plan items of the
frame that opened it, then the plan items of the frame that completed it — codex republishes the whole plan on every
revision, so one plan is one pair however often it is rewritten: every item.updated between the two folds into that
same pair instead of adding a step of its own, and a plan the stream never completed keeps its call with no result
under it, since where a plan stood when the stream stopped is not where it ended). file_change is codex's
custom (freeform) tool surface: the model calls apply_patch, and the stream reports the call and its outcome as
a file_change item, so a custom tool call normalizes to a pair
like any other. codex exec --json publishes no separate custom / dynamic tool item type — its whole item vocabulary
is agent_message, reasoning, command_execution, file_change, mcp_tool_call, collab_tool_call,
web_search, todo_list, and error (verified against codex-cli 0.148.0), and custom_tool_call is a raw
model-response / rollout spelling that never reaches this stream. A web_search item serializes id twice, the
provider's own exec-… call id last, so that call id is what the decoder hands the parser and what a search is
correlated and recorded under; every other item is correlated under the synthetic item_N the exec stream numbers
its items with. The id is also what keeps one operation one item: a wrapper frame reporting the id of the call
nested inside it merges into that call rather than doubling it, and a wrapper of a type nothing normalizes neither
replaces the normalized pair with a placeholder nor adds one beside it — nor survives inside one: a placeholder is
named after the wrapper's own item type and payloaded with the wrapper's own status, so the normalized frame
replaces it whole rather than merging into it, and an item that frame reported no invocation for is left with none
instead of reading back as the wrapper's in_progress. The final answer is read from the last
agent_message text; turns stays empty with every step.turn None, since codex usage frames are cumulative
across the session rather than per-turn. Both reuse the
matching skill extractor for the skills field. parse_agent_trajectory(backend, stdout) dispatches by backend exactly
as the usage / skill dispatchers do. system_prompt stays None and tools stays empty in the classifier whenever a
backend's stream does not expose them (codex exposes neither); the analytics writer backfills codex tools out-of-band
from skills.discovery.discover_codex_tools(). Malformed JSONL lines are skipped and a missing / renamed field
yields an empty section rather than an exception. Unlike the skill extractor, this classifier records the raw stream
payload — tool inputs, tool outputs, and the final text — verbatim: it deliberately does not redact, truncate,
or write any file. Those concerns belong to its downstream writer,
observability/analytics/trajectories/'s persistence.maybe_record_trajectory
(called from record_agent_exit), which redacts every free-text field, applies the head/tail and total-record
truncation caps, and appends the agent_trajectory record to the
trajectory sink — only when
TRAJECTORY_LOG_PATH is enabled and always behind its own fail-open guard.
Codex item types beyond those five. Nothing is dropped silently. An item type the classifier does not normalize —
codex's non-fatal error items and its collab_tool_call today, whatever a later release adds tomorrow — becomes one
bounded unsupported_item placeholder step naming the item type, its id, and the status it reported (null for a
type that reports none, error among them), deduplicated by
item.id the same way a tool pair is and carrying no payload of its own. reasoning is the one deliberate
exclusion: its text is hidden model content that never enters a record, and a placeholder per reasoning item would be
noise rather than a diagnostic — but the exclusion is named rather than silent, in the per-item accounting below.
Nothing fabricates an outcome either — a call that failed, or one the stream never
completed, keeps its tool_call with no tool_result under it unless a frame actually reported one.
Every identified codex item is accounted for. Beside the steps, parse_codex_trajectory returns one SourceItem
per item.id the stream carried, in first-seen order across every identified frame — the ones that contribute no
step at all included — and deduplicated across the started / updated / completed frames the same way a tool pair is.
Each names the item_id, the item_type the classification settled on, and a
disposition: stored (the item's own steps are in steps), unsupported (nothing normalized its type, so the
placeholder step above is what names it), excluded (the reasoning the classifier keeps out whole — the accounting
records the id and the type and nothing else, never the hidden text or any other field of the item), or empty (its
frames carried no payload to store — an agent_message with no text, a call whose fields never arrived, or an item
too malformed to name a type at all, whose item_type is then the empty string). A normalized frame outranks an
unsupported wrapper sharing its id whichever of the two arrives first — and renames it in place, since an id holds the
position its first frame took either way — so the wrapper that adds no step renames nothing either and leaves no
payload of its own behind. The accounting is also what carries an agent_message item's
id: a text turn is not a tool call, so its step's tool_id stays empty and the provider id lands here rather than in
the field a downstream reader joins a result to its invocation by. A frame the stream left without an id is the one
thing not accounted — there is no name to account it under, and every such frame would otherwise share one — so it
contributes its steps and nothing else. AgentTrajectory.to_dict() carries the tuple as source_items, and the
agent_trajectory sink record serializes it under that same key — bounded, counted, and flagged when truncated, as
trajectories.md describes.