fix(actors): llm token consumption undercounted #66
No reviewers
Labels
No labels
auto/blocked-by-deps
auto/ci-timeout
auto/claimed-implementer
auto/claimed-merge
auto/claimed-reviewer
auto/driver-down
auto/invariant-violation
auto/last-attempt-tier-0
auto/last-attempt-tier-1
auto/last-attempt-tier-2
auto/last-attempt-tier-min
Automation Tracking
auto/needs-conflict-resolution
auto/needs-implementer
auto/postmortem
auto/ready-to-merge
auto/restart-throttled
auto/revert
auto/sentinel
auto/stale-inactivity
auto/unstable
Blocked
Bounty
$100
Bounty
$1000
Bounty
$10000
Bounty
$20
Bounty
$2000
Bounty
$250
Bounty
$50
Bounty
$500
Bounty
$5000
Bounty
$750
MoSCoW
Could have
MoSCoW
Must have
MoSCoW
Should have
Needs Feedback
Points
1
Points
13
Points
2
Points
21
Points
3
Points
34
Points
5
Points
55
Points
8
Points
88
Priority
Backlog
Priority
CI Blocker
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Signed-off: Owner
Signed-off: Scrum Master
Signed-off: Tech Lead
Spike
State
Completed
State
Duplicate
State
In Progress
State
In Review
State
Paused
State
Unverified
State
Verified
State
Wont Do
Type
Automation
Type
Bug
Type
Discussion
Type
Documentation
Type
Epic
Type
Feature
Type
Legendary
Type
Refactor
Type
Support
Type
Task
Type
Testing
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Blocks
#65 bug(agents): LLM token consumption undercounted — pruning model calls untracked, earlier loop rounds overwritten
cleveragents/cleveractors-core
Reference
cleveragents/cleveractors-core!66
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "bugfix/m2-lllm-token-consumption-undercounted"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Three defects in LLMAgent.process_message() caused token consumption to be undercounted in NodeUsage/ActorResult:
_run_pruning_pass()now returns usage metadata alongside parsed content_estimate_token_count()blends actuals with heuristic for tool-call JSON overheadTemporary Limitations (deviation from ADR-2031)
pruning_modelmust matchmodel—ConfigurationErrorraised at agent creation if they differ, because cross-model token tracking is not yet plumbed throughNodeUsage.usage_metadata, aRuntimeErroris raised immediately (no silent fallback to(0, 0)).Closes #65.
Dependencies
None.
432b161ae0cf2a32ce92b6cbc5790880a5d80e06305bf2503ee88332ea3ae88332ea3a59d6931e0759d6931e076059287fbeCode Review — PR #66
Solid fix for all three gaps from issue #65.
_extract_token_counts() helper
Clean extraction of the three-tier fallback logic that was duplicated. Consistent with the existing _safe_int / _log_no_usage_metadata patterns.
_run_pruning_pass return-type change (str -> tuple[str, int, int])
Split into two phases (LLM call vs. parse + validate) is the right structure. Hard RuntimeError on missing usage metadata is correct — pruning is a billing-visible call and silent (0, 0) would defeat the fix.
One concern: Phase 2 (after the try/except block) has no exception handler. _parse_pruning_response is probably pure string ops and won't raise, but the old code silently caught all Phase 2 errors and fell back to raw output. The new code lets a _parse_pruning_response exception propagate to the caller. Worth confirming that method is truly exception-safe, or wrapping Phase 2 with a narrower except that does NOT catch RuntimeError.
Minor nit:
if _pp == 0 and _pc == 0is used as the proxy for "no metadata present". Correct for every real LLM, but technically ambiguous — a provider could return genuine zero-token metadata. Low-risk in practice; could use a sentinel flag from _extract_token_counts instead of relying on zero as the signal.Tool-loop accumulation
All six ainvoke() sites are now covered: budget-exhaustion synthesis, main tool-call loop rounds, stuck-model synthesis, synthesis final tool-call round, and both pruning-pass sites (via returned counts). Comprehensive.
Design tradeoff: On round 1, _accumulated_prompt + _accumulated_completion == 0, so the budget guard never triggers on the first call (was a heuristic estimate before). This is actually better — the heuristic gave false precision. Noting it so reviewers don't mistake it for a regression.
ConfigurationError for pruning_model != model
Correct temporary guard. Error message references issue #65 and explains the limitation clearly.
_estimate_token_count removal
Right call — with actuals available from round 2 onward, the character-count heuristic has no remaining justification.
BDD tests
New scenarios cover: mismatch ConfigurationError, matching model success, token counts > 0 from pruning pass, hard failure on missing metadata, token_budget_percent type validation, missing end tag fallback, invalid tool_max_rounds, and direct-format tool calls. Good coverage.
Benchmark mock update
usage_metadata added to pruning mock — required by the new return signature.
Verdict: Logic correct, all gaps addressed, good test coverage. The Phase 2 exception safety question is worth a quick look before merge, but not a blocker if _parse_pruning_response is confirmed safe. Ready otherwise.
PR Review: cleveragents/cleveractors-core!66 (Ticket #65)
Verdict: Request Changes
The fix correctly addresses the three token-undercounting gaps called out in issue #65 — pruning-pass usage is now captured, tool-loop rounds are accumulated instead of overwritten, and the
_extract_token_countshelper extracts the existing three-tier fallback logic into a reusable method. However, the PR removes_estimate_token_countwithout updating its remaining callers inbenchmarks/token_budget_benchmark.py, which will breaknox -e benchmark/nox -e benchmark_regression. There is also a billing-accuracy regression in the new RuntimeError failure path: tokens already consumed by previous loop rounds are silently zeroed out.Critical Issues
_estimate_token_countremoved but callers not updated (benchmarks/token_budget_benchmark.py, lines 60–78)The
TokenBudgetBenchmarkclass still callsself.agent._estimate_token_count(msgs)in four benchmark methods (time_estimate_small_messages,time_estimate_medium_messages,time_estimate_large_messages,time_estimate_very_large_messages). The method was removed in this PR (CHANGELOG says "Estimate removed"), so all four will raiseAttributeError: 'LLMAgent' object has no attribute '_estimate_token_count'as soon asnox -e benchmarkornox -e benchmark_regressionruns. Per the project's "every stage passes" rule this is a build-integrity blocker.Recommendation: Either delete the four
time_estimate_*methods and theTokenBudgetBenchmarkclass entirely, or replace them with benchmarks against the new_extract_token_countshelper / the per-round accumulation path.Major Issues
Failure-case billing data is wiped to
(0, 0)(src/cleveractors/agents/llm.py,_run_pruning_passat ~lines 583–589, in conjunction with theexcept Exceptionblock at lines 1344–1357 ofprocess_message())When the pruning pass raises
RuntimeErrorbecause the response lacksusage_metadata, the local accumulators_accumulated_prompt/_accumulated_completion(lines 815–816) still hold the real token counts from prior loop rounds — those tokens were charged by the provider. But theexcept Exceptionhandler inprocess_message()checks_captured_prompt is not None(line 1352), and the sentinel is only assigned AFTER the tool loop completes (lines 1293–1296). At the point the RuntimeError propagates,_captured_promptis stillNone, so the handler resets_last_token_usage = (0, 0)andlast_token_usage_var.set((0, 0)), discarding the real costs. The PR's stated goal is billing accuracy; this regresses it precisely in the new failure mode the PR introduces.Recommendation: Set
_captured_prompt = _accumulated_promptand_captured_completion = _accumulated_completionimmediately before the Phase-2 RuntimeError raise (or right after the Phase-1 try/except), so the existing except handler preserves the counts.if _pp == 0 and _pc == 0is ambiguous as a "missing metadata" signal (src/cleveractors/agents/llm.py, line 583)_safe_intalready returns 0 for missing keys, malformed values, booleans, and negative integers (with a warning). A provider that legitimately returnsusage_metadata = {"input_tokens": 0, "output_tokens": 0}for an empty generation, or a buggy provider returning negative counts, will trigger the RuntimeError with a misleading "did not include token usage metadata" message. Graa's existing review notes this; the PR author has not responded. Relying on the zero-tuple as a sentinel conflates "no metadata" with "metadata that resolved to zero/zero".Recommendation: Change
_extract_token_countsto returntuple[int, int] | None(or(int, int, bool)with a "metadata present" flag) so callers can distinguish "metadata was absent" from "metadata said zero". The RuntimeError message can then be precise.Phase-1 description doesn't match implementation —
build_chat_modelerrors no longer fall back (src/cleveractors/agents/llm.py, lines 522–531 vs. 568–577)The PR description says "Phase 1: Make the pruning LLM call. Failures here fall back to raw output (non-functional model, network error, etc.)." The implementation only wraps
await prune_model.ainvoke(prune_messages)in the try/except —build_chat_model(...)and message construction are now OUTSIDE the try block. The original code wrapped the whole phase, so a missing API key, invalid model name, or template-rendering exception would have fallen back to(raw_output, 0, 0)with a warning. Now those errors propagate asExecutionError("LLM processing failed: …")and abort the agent call. This is a behavioral regression vs. the pre-PR semantics that contradicts the PR description.Recommendation: Either widen the try/except to include
build_chat_modeland message construction (matching the description), or update the PR description to clarify that model-construction errors now fail loud. The current state is internally inconsistent.Minor Issues
RuntimeErroris too generic for a domain-specific failure (src/cleveractors/agents/llm.py, line 584)Callers cannot distinguish "pruning model returned no token usage metadata" from any other RuntimeError. A dedicated
TokenTrackingError/MissingUsageMetadataErrorincleveractors/core/exceptions.pywould let callers (and tests) catch this specifically. The_extract_token_countshelper is already structured around typed exceptions (ConfigurationError,ExecutionError); this one outlier is inconsistent._safe_intswallows negative token counts into the same "missing metadata" bucket (src/cleveractors/agents/llm.py, lines 1768+ in_safe_int, used by_extract_token_counts)A malicious or buggy provider returning negative counts will be reported as a
RuntimeError("did not include token usage metadata"). The warning emitted by_safe_intis observable in logs but the exception message points the operator at the wrong root cause. Consider including a hint in the RuntimeError when the underlying cause was_safe_intwarnings.Nits
Redundant fallback
self._pruning_model or self.model(src/cleveractors/agents/llm.py, line 522)Given the new
ConfigurationErrorraised at construction (lines 228–233) when_pruning_model != self.model,_pruning_modelis now guaranteed to beNoneor equal toself.model. Theor self.modelfallback is dead code. Not worth a separate commit, but worth cleaning up next time this block is touched.Scope creep in tests (
features/llm_agent_tool_calling.feature)The PR also adds scenarios that are unrelated to issue #65:
LLMAgent validates token_budget_percent must be numeric,Parse pruning response with missing end tag falls back to full text,LLMAgent rejects invalid tool_max_rounds on process_message,LLMAgent handles tool_calls in direct format without function key. These are coverage wins for pre-existing code paths, but they broaden the PR's scope beyond the stated "fix token undercounting" goal. Fine to keep; flagged only because ticket-driven commits are expected to be atomic to the ticket.Existing reviewer note on Phase-2 exception safety still unanswered (Graa's review, 2026-06-30)
Graa explicitly asked whether
_parse_pruning_responseis truly exception-safe now that it's no longer wrapped in try/except, and noted that a narrowerexcept(catching everything exceptRuntimeError) would be safer if there's any doubt. The PR author has not responded on the PR. Looking at the code,_parse_pruning_responseis pure string ops (lines 470–502) andstr(prune_response.content)should always succeed, so Graa's concern is theoretical — but the lack of acknowledgment is worth noting.Summary
The fix correctly closes all three gaps from issue #65 and matches the spec/ADR's intent for actual-usage tracking. The
_extract_token_countsextraction is a clean refactor. But the PR is not ready to merge:_pp == 0 and _pc == 0ambiguity and the Phase-1/Phase-2 description mismatch are real but smaller; addressing them now keeps the fix internally consistent.Once the critical benchmark breakage and the failure-case billing preservation are addressed, this is a solid fix. Recommend re-review at that point.
6059287fbec8c7e9b8ddReply to Review — PR #66 (force-pushed to
c8c7e9b8)Thank you both for the thorough reviews. The branch was force-pushed to incorporate all substantive feedback. Below is the disposition of each item.
What was done
hurui200320 — Critical: Broken ASV benchmarks
[92mDone[0m:
TokenBudgetBenchmark(which called the removed_estimate_token_count) has been replaced withTokenExtractionBenchmarkthat benchmarks_extract_token_countsacross the three metadata paths. The_messages_with_sizehelper was removed along with the fourtime_estimate_*methods.hurui200320 — Major: Billing data wiped to (0,0) on MissingUsageMetadataError
[92mDone[0m: Both pruning call sites (budget-exhaustion path and main tool-loop path) now capture
_accumulated_prompt/_accumulated_completioninto_captured_prompt/_captured_completionimmediately before raisingMissingUsageMetadataError. The existingexcept Exceptionhandler preserves these counts.hurui200320 — Major:
_pp == 0 and _pc == 0ambiguity[92mDone[0m:
_extract_token_countsnow returns(int, int, bool)with a_metadata_presentflag. The Phase-2 guard checksnot _metadata_presentinstead of the zero-tuple.hurui200320 — Major: Phase-1/Phase-2 description mismatch
[92mDone[0m:
build_chat_model, message construction, andainvokeare all inside thetry:block. A missing API key, invalid model name, or template-rendering exception falls back to(raw_output, 0, 0)with a warning, matching the PR description.Graa — Minor nit: zero-tuple as missing-metadata proxy
[92mDone[0m: Same fix as above — the
_metadata_presentboolean resolves this.hurui200320 — Minor #1:
RuntimeErrortoo generic[92mDone[0m:
MissingUsageMetadataError(CleverAgentsException)added tocleveractors/core/exceptions.py. The phase-2 guard raises this instead ofRuntimeError.What was NOT done (with justification)
hurui200320 — Minor #2:
_safe_intswallows negative tokens[93mNot changed[0m: With the
_metadata_presentflag the paths are now cleanly separated: metadata present (including negatives) ⇒(0, 0)accumulated (undercount, but the provider returning negatives is a separate defect); metadata absent ⇒MissingUsageMetadataErrorraised. The_safe_intwarnings remain in logs to detect buggy providers.hurui200320 — Nit #1: Redundant
self._pruning_model or self.model[93mAcknowledged[0m: The construction guard ensures
_pruning_modelisNoneor equal toself.model, makingor self.modeldead code. Noted for cleanup in the next patch touching this area.hurui200320 — Nit #2: Scope creep in tests
[93mAcknowledged[0m: The additional coverage scenarios exercise pre-existing code paths. Keeping them improves overall coverage density.
Graa — Phase-2 exception safety of
_parse_pruning_response[92mConfirmed safe[0m: Pure string ops only (
str.find,str.strip, concatenation).str(prune_response.content)is safe for all standard LangChain message types. Phase 2 will not raise unexpectedly.Summary
All review feedback has been addressed. The PR is ready for re-review.
c8c7e9b8dd7e427de2b1PR Review: cleveragents/cleveractors-core!66 (Ticket #65)
Verdict: Approve
The fix correctly addresses all three token-undercounting gaps from issue #65. The previous review's critical and major issues have been resolved: the ASV benchmarks were updated, the failure-case billing-data wipe is now prevented by the
_captured_prompt = _accumulated_promptpattern in the pruning call sites, the(0, 0)ambiguity is resolved via the_metadata_presentbool flag, thebuild_chat_modelis properly inside Phase 1's try block, and a dedicatedMissingUsageMetadataErrorreplaces the genericRuntimeError. The PR author has not posted any comments responding to the prior reviews, so nothing has been deferred or marked out-of-scope.Critical Issues
None
Major Issues
None
Minor Issues
or self.modelfallback forprune_model_name—src/cleveractors/agents/llm.py, line 534 Given the newConfigurationErrorraised at construction (lines 229–234) when_pruning_model != self.model,_pruning_modelis now guaranteed to beNoneor equal toself.model. Theor self.modelfallback is dead code. Cleaning this up toprune_model_name = self.modelwould be tidier but is not a correctness issue.Nits
_log_no_usage_metadatais emitted even whenMissingUsageMetadataErrorwill be raised —src/cleveractors/agents/llm.py, line 591. The helper logs a generic warning before_run_pruning_passraises. Operators will see both the warning and the exception. Acceptable for debuggability._log_no_usage_metadatafires for every main-modelainvoke()that lacks metadata —src/cleveractors/agents/llm.py, lines 877, 1017, 1025, 1182, 1278. The five main-model call sites discard_metadata_presentwith_, so any model lackingusage_metadata(some open-source providers, test mocks) will produce one warning per loop round. Noise concern, not a correctness one.Spec deviation note could be more explicit in the changelog —
CHANGELOG.md, line 18. The changelog does not explicitly note thatpruning_modelhas been effectively disabled (must equalmodel). The constructor's error message and PR description cover this at runtime, but a single changelog line would help downstream consumers.Summary
All three gaps from issue #65 are correctly addressed:
_run_pruning_pass()now returns(parsed, prompt_tokens, completion_tokens), and the caller accumulates these counts. The previous concern about Phase-2 exception safety is moot in practice —_parse_pruning_responseis pure string operations andstr(prune_response.content)is unlikely to raise.ainvoke()call sites inprocess_message()plus two pruning call sites all feed into_accumulated_prompt/_accumulated_completion, which are then written to_last_token_usage/last_token_usage_var. The failure-path billing wipe concern is fully addressed: the innertry/except MissingUsageMetadataErrorblocks at lines 976–981 and 1126–1129 set_captured_promptand_captured_completionbefore re-raising, so the outerexcept Exceptionhandler preserves the counts._estimate_token_countheuristic is removed. The budget check at line 846 now uses actual provider-reported consumption. The broken benchmarks concern is resolved:TokenExtractionBenchmarkreplacesTokenBudgetBenchmarkand exercises the new helper.The temporary limitation (
pruning_modelmust equalmodel) is well-documented and the path forward (per-model token tracking inNodeUsage) is identified in issue #65.MissingUsageMetadataErrorpropagation throughprocess_message()is correctly wrapped asExecutionError, while direct calls to_run_pruning_pass()still surface the typed exception. Type annotations, docstrings, and CHANGELOG are all updated.Recommendation: Approve and merge. The minor issues are stylistic and do not affect correctness or spec compliance.
7e427de2b1d7e951402ad7e951402a8a7e353767