fix(actors): llm token consumption undercounted #66

Merged
CoreRasurae merged 1 commit from bugfix/m2-lllm-token-consumption-undercounted into master 2026-07-01 14:44:39 +00:00
Member

Summary

Three defects in LLMAgent.process_message() caused token consumption to be undercounted in NodeUsage/ActorResult:

  1. Pruning model calls untracked_run_pruning_pass() now returns usage metadata alongside parsed content
  2. Tool-loop round accumulation — earlier rounds were overwritten; now accumulated per round
  3. Estimate blending_estimate_token_count() blends actuals with heuristic for tool-call JSON overhead

Temporary Limitations (deviation from ADR-2031)

  • pruning_model must match modelConfigurationError raised at agent creation if they differ, because cross-model token tracking is not yet plumbed through NodeUsage.
  • Token-usage metadata enforced — if the pruning model's response lacks usage_metadata, a RuntimeError is raised immediately (no silent fallback to (0, 0)).

Closes #65.

Dependencies

None.

## Summary Three defects in LLMAgent.process_message() caused token consumption to be undercounted in NodeUsage/ActorResult: 1. **Pruning model calls untracked** — `_run_pruning_pass()` now returns usage metadata alongside parsed content 2. **Tool-loop round accumulation** — earlier rounds were overwritten; now accumulated per round 3. **Estimate blending** — `_estimate_token_count()` blends actuals with heuristic for tool-call JSON overhead ### Temporary Limitations (deviation from ADR-2031) - **`pruning_model` must match `model`** — `ConfigurationError` raised at agent creation if they differ, because cross-model token tracking is not yet plumbed through `NodeUsage`. - **Token-usage metadata enforced** — if the pruning model's response lacks `usage_metadata`, a `RuntimeError` is raised immediately (no silent fallback to `(0, 0)`). Closes #65. ## Dependencies None.
fix(actors): llm token consumption undercounted
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / typecheck (pull_request) Successful in 57s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 36s
CI / unit_tests (pull_request) Successful in 3m5s
CI / integration_tests (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 34s
CI / coverage (pull_request) Successful in 3m7s
CI / status-check (pull_request) Successful in 6s
ea17bfb34f
ISSUES CLOSED: #65
fix(actors): enforce pruning-model match and token-tracking metadata
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
432b161ae0
ISSUES CLOSED: #65
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from 432b161ae0
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
to cf2a32ce92
Some checks failed
CI / lint (pull_request) Successful in 45s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 33s
CI / integration_tests (pull_request) Successful in 1m11s
CI / unit_tests (pull_request) Failing after 16m56s
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
2026-06-30 20:25:44 +00:00
Compare
test(actors): add BDD scenarios for pruning-model enforcement and token-tracking
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
b6cbc57908
ISSUES CLOSED: #65
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from b6cbc57908
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
to 80a5d80e06
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
2026-06-30 21:02:41 +00:00
Compare
refactor(actors): remove heuristic token estimation, use actuals only
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
305bf2503e
ISSUES CLOSED: #65
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from 305bf2503e
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
to e88332ea3a
Some checks failed
CI / lint (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m5s
CI / integration_tests (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 33s
CI / coverage (pull_request) Failing after 3m36s
CI / status-check (pull_request) Failing after 3s
2026-06-30 21:23:02 +00:00
Compare
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from e88332ea3a
Some checks failed
CI / lint (pull_request) Successful in 48s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m5s
CI / integration_tests (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 33s
CI / coverage (pull_request) Failing after 3m36s
CI / status-check (pull_request) Failing after 3s
to 59d6931e07
Some checks failed
CI / lint (pull_request) Failing after 51s
CI / typecheck (pull_request) Successful in 58s
CI / security (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 41s
CI / unit_tests (pull_request) Successful in 3m13s
CI / coverage (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 39s
CI / status-check (pull_request) Failing after 3s
2026-06-30 22:15:07 +00:00
Compare
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from 59d6931e07
Some checks failed
CI / lint (pull_request) Failing after 51s
CI / typecheck (pull_request) Successful in 58s
CI / security (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 41s
CI / unit_tests (pull_request) Successful in 3m13s
CI / coverage (pull_request) Has been skipped
CI / integration_tests (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 39s
CI / status-check (pull_request) Failing after 3s
to 6059287fbe
All checks were successful
CI / lint (pull_request) Successful in 39s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 37s
CI / unit_tests (pull_request) Successful in 3m13s
CI / integration_tests (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 3m14s
CI / status-check (pull_request) Successful in 3s
2026-06-30 22:32:56 +00:00
Compare
Graa left a comment

Code 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 == 0 is 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.

## Code 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 == 0` is 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.
hurui200320 requested changes 2026-07-01 05:20:39 +00:00
Dismissed
hurui200320 left a comment

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_counts helper extracts the existing three-tier fallback logic into a reusable method. However, the PR removes _estimate_token_count without updating its remaining callers in benchmarks/token_budget_benchmark.py, which will break nox -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.

Note on test coverage: contrary to the prompt's claim, this PR does add tests (new BDD scenarios in features/llm_agent_tool_calling.feature plus matching step definitions). They look reasonable but I deliberately held off on a deep test-quality review per the request to "focus on the fix itself".

Critical Issues

  1. Broken ASV benchmarks — _estimate_token_count removed but callers not updated (benchmarks/token_budget_benchmark.py, lines 60–78)
    The TokenBudgetBenchmark class still calls self.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 raise AttributeError: 'LLMAgent' object has no attribute '_estimate_token_count' as soon as nox -e benchmark or nox -e benchmark_regression runs. Per the project's "every stage passes" rule this is a build-integrity blocker.
    Recommendation: Either delete the four time_estimate_* methods and the TokenBudgetBenchmark class entirely, or replace them with benchmarks against the new _extract_token_counts helper / the per-round accumulation path.

Major Issues

  1. Failure-case billing data is wiped to (0, 0) (src/cleveractors/agents/llm.py, _run_pruning_pass at ~lines 583–589, in conjunction with the except Exception block at lines 1344–1357 of process_message())
    When the pruning pass raises RuntimeError because the response lacks usage_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 the except Exception handler in process_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_prompt is still None, so the handler resets _last_token_usage = (0, 0) and last_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_prompt and _captured_completion = _accumulated_completion immediately before the Phase-2 RuntimeError raise (or right after the Phase-1 try/except), so the existing except handler preserves the counts.

  2. if _pp == 0 and _pc == 0 is ambiguous as a "missing metadata" signal (src/cleveractors/agents/llm.py, line 583)
    _safe_int already returns 0 for missing keys, malformed values, booleans, and negative integers (with a warning). A provider that legitimately returns usage_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_counts to return tuple[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.

  3. Phase-1 description doesn't match implementation — build_chat_model errors 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 as ExecutionError("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_model and 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

  1. RuntimeError is 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 / MissingUsageMetadataError in cleveractors/core/exceptions.py would let callers (and tests) catch this specifically. The _extract_token_counts helper is already structured around typed exceptions (ConfigurationError, ExecutionError); this one outlier is inconsistent.

  2. _safe_int swallows 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_int is 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_int warnings.

Nits

  1. Redundant fallback self._pruning_model or self.model (src/cleveractors/agents/llm.py, line 522)
    Given the new ConfigurationError raised at construction (lines 228–233) when _pruning_model != self.model, _pruning_model is now guaranteed to be None or equal to self.model. The or self.model fallback is dead code. Not worth a separate commit, but worth cleaning up next time this block is touched.

  2. 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.

  3. Existing reviewer note on Phase-2 exception safety still unanswered (Graa's review, 2026-06-30)
    Graa explicitly asked whether _parse_pruning_response is truly exception-safe now that it's no longer wrapped in try/except, and noted that a narrower except (catching everything except RuntimeError) would be safer if there's any doubt. The PR author has not responded on the PR. Looking at the code, _parse_pruning_response is pure string ops (lines 470–502) and str(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_counts extraction is a clean refactor. But the PR is not ready to merge:

  • The broken benchmarks are a real build-integrity problem and must be addressed before any commit.
  • The billing-data loss on the new RuntimeError is exactly the kind of regression the PR exists to prevent, and it should be fixed before the failure-mode semantics are locked in.
  • The _pp == 0 and _pc == 0 ambiguity 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.

## 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_counts` helper extracts the existing three-tier fallback logic into a reusable method. However, the PR removes `_estimate_token_count` without updating its remaining callers in `benchmarks/token_budget_benchmark.py`, which will break `nox -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. > Note on test coverage: contrary to the prompt's claim, this PR *does* add tests (new BDD scenarios in `features/llm_agent_tool_calling.feature` plus matching step definitions). They look reasonable but I deliberately held off on a deep test-quality review per the request to "focus on the fix itself". ### Critical Issues 1. **Broken ASV benchmarks — `_estimate_token_count` removed but callers not updated** (`benchmarks/token_budget_benchmark.py`, lines 60–78) The `TokenBudgetBenchmark` class still calls `self.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 raise `AttributeError: 'LLMAgent' object has no attribute '_estimate_token_count'` as soon as `nox -e benchmark` or `nox -e benchmark_regression` runs. Per the project's "every stage passes" rule this is a build-integrity blocker. **Recommendation:** Either delete the four `time_estimate_*` methods and the `TokenBudgetBenchmark` class entirely, or replace them with benchmarks against the new `_extract_token_counts` helper / the per-round accumulation path. ### Major Issues 1. **Failure-case billing data is wiped to `(0, 0)`** (`src/cleveractors/agents/llm.py`, `_run_pruning_pass` at ~lines 583–589, in conjunction with the `except Exception` block at lines 1344–1357 of `process_message()`) When the pruning pass raises `RuntimeError` because the response lacks `usage_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 the `except Exception` handler in `process_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_prompt` is still `None`, so the handler resets `_last_token_usage = (0, 0)` and `last_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_prompt` and `_captured_completion = _accumulated_completion` immediately before the Phase-2 RuntimeError raise (or right after the Phase-1 try/except), so the existing except handler preserves the counts. 2. **`if _pp == 0 and _pc == 0` is ambiguous as a "missing metadata" signal** (`src/cleveractors/agents/llm.py`, line 583) `_safe_int` already returns 0 for missing keys, malformed values, booleans, and **negative** integers (with a warning). A provider that legitimately returns `usage_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_counts` to return `tuple[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. 3. **Phase-1 description doesn't match implementation — `build_chat_model` errors 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 as `ExecutionError("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_model` and 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 1. **`RuntimeError` is 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` / `MissingUsageMetadataError` in `cleveractors/core/exceptions.py` would let callers (and tests) catch this specifically. The `_extract_token_counts` helper is already structured around typed exceptions (`ConfigurationError`, `ExecutionError`); this one outlier is inconsistent. 2. **`_safe_int` swallows 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_int` is 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_int` warnings. ### Nits 1. **Redundant fallback `self._pruning_model or self.model`** (`src/cleveractors/agents/llm.py`, line 522) Given the new `ConfigurationError` raised at construction (lines 228–233) when `_pruning_model != self.model`, `_pruning_model` is now guaranteed to be `None` or equal to `self.model`. The `or self.model` fallback is dead code. Not worth a separate commit, but worth cleaning up next time this block is touched. 2. **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. 3. **Existing reviewer note on Phase-2 exception safety still unanswered** (Graa's review, 2026-06-30) Graa explicitly asked whether `_parse_pruning_response` is truly exception-safe now that it's no longer wrapped in try/except, and noted that a narrower `except` (catching everything except `RuntimeError`) would be safer if there's any doubt. The PR author has not responded on the PR. Looking at the code, `_parse_pruning_response` is pure string ops (lines 470–502) and `str(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_counts` extraction is a clean refactor. **But the PR is not ready to merge:** - The **broken benchmarks** are a real build-integrity problem and must be addressed before any commit. - The **billing-data loss on the new RuntimeError** is exactly the kind of regression the PR exists to prevent, and it should be fixed before the failure-mode semantics are locked in. - The **`_pp == 0 and _pc == 0` ambiguity** 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.
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from 6059287fbe
All checks were successful
CI / lint (pull_request) Successful in 39s
CI / typecheck (pull_request) Successful in 56s
CI / security (pull_request) Successful in 54s
CI / quality (pull_request) Successful in 37s
CI / unit_tests (pull_request) Successful in 3m13s
CI / integration_tests (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 38s
CI / coverage (pull_request) Successful in 3m14s
CI / status-check (pull_request) Successful in 3s
to c8c7e9b8dd
Some checks failed
CI / lint (pull_request) Successful in 47s
CI / typecheck (pull_request) Successful in 49s
CI / security (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 32s
CI / unit_tests (pull_request) Successful in 3m3s
CI / integration_tests (pull_request) Successful in 59s
CI / build (pull_request) Successful in 33s
CI / coverage (pull_request) Failing after 12m21s
CI / status-check (pull_request) Has been cancelled
2026-07-01 11:27:37 +00:00
Compare
Author
Member

Reply 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
Done: TokenBudgetBenchmark (which called the removed _estimate_token_count) has been replaced with TokenExtractionBenchmark that benchmarks _extract_token_counts across the three metadata paths. The _messages_with_size helper was removed along with the four time_estimate_* methods.

hurui200320 — Major: Billing data wiped to (0,0) on MissingUsageMetadataError
Done: Both pruning call sites (budget-exhaustion path and main tool-loop path) now capture _accumulated_prompt / _accumulated_completion into _captured_prompt / _captured_completion immediately before raising MissingUsageMetadataError. The existing except Exception handler preserves these counts.

hurui200320 — Major: _pp == 0 and _pc == 0 ambiguity
Done: _extract_token_counts now returns (int, int, bool) with a _metadata_present flag. The Phase-2 guard checks not _metadata_present instead of the zero-tuple.

hurui200320 — Major: Phase-1/Phase-2 description mismatch
Done: build_chat_model, message construction, and ainvoke are all inside the try: 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
Done: Same fix as above — the _metadata_present boolean resolves this.

hurui200320 — Minor #1: RuntimeError too generic
Done: MissingUsageMetadataError(CleverAgentsException) added to cleveractors/core/exceptions.py. The phase-2 guard raises this instead of RuntimeError.

What was NOT done (with justification)

hurui200320 — Minor #2: _safe_int swallows negative tokens
Not changed: With the _metadata_present flag 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 ⇒ MissingUsageMetadataError raised. The _safe_int warnings remain in logs to detect buggy providers.

hurui200320 — Nit #1: Redundant self._pruning_model or self.model
Acknowledged: The construction guard ensures _pruning_model is None or equal to self.model, making or self.model dead code. Noted for cleanup in the next patch touching this area.

hurui200320 — Nit #2: Scope creep in tests
Acknowledged: The additional coverage scenarios exercise pre-existing code paths. Keeping them improves overall coverage density.

Graa — Phase-2 exception safety of _parse_pruning_response
Confirmed safe: 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.

## Reply 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** Done: `TokenBudgetBenchmark` (which called the removed `_estimate_token_count`) has been replaced with `TokenExtractionBenchmark` that benchmarks `_extract_token_counts` across the three metadata paths. The `_messages_with_size` helper was removed along with the four `time_estimate_*` methods. **hurui200320 — Major: Billing data wiped to (0,0) on MissingUsageMetadataError** Done: Both pruning call sites (budget-exhaustion path and main tool-loop path) now capture `_accumulated_prompt` / `_accumulated_completion` into `_captured_prompt` / `_captured_completion` immediately before raising `MissingUsageMetadataError`. The existing `except Exception` handler preserves these counts. **hurui200320 — Major: `_pp == 0 and _pc == 0` ambiguity** Done: `_extract_token_counts` now returns `(int, int, bool)` with a `_metadata_present` flag. The Phase-2 guard checks `not _metadata_present` instead of the zero-tuple. **hurui200320 — Major: Phase-1/Phase-2 description mismatch** Done: `build_chat_model`, message construction, and `ainvoke` are all inside the `try:` 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** Done: Same fix as above — the `_metadata_present` boolean resolves this. **hurui200320 — Minor #1: `RuntimeError` too generic** Done: `MissingUsageMetadataError(CleverAgentsException)` added to `cleveractors/core/exceptions.py`. The phase-2 guard raises this instead of `RuntimeError`. ### What was NOT done (with justification) **hurui200320 — Minor #2: `_safe_int` swallows negative tokens** Not changed: With the `_metadata_present` flag 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 ⇒ `MissingUsageMetadataError` raised. The `_safe_int` warnings remain in logs to detect buggy providers. **hurui200320 — Nit #1: Redundant `self._pruning_model or self.model`** Acknowledged: The construction guard ensures `_pruning_model` is `None` or equal to `self.model`, making `or self.model` dead code. Noted for cleanup in the next patch touching this area. **hurui200320 — Nit #2: Scope creep in tests** Acknowledged: The additional coverage scenarios exercise pre-existing code paths. Keeping them improves overall coverage density. **Graa — Phase-2 exception safety of `_parse_pruning_response`** Confirmed safe: 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.
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from c8c7e9b8dd
Some checks failed
CI / lint (pull_request) Successful in 47s
CI / typecheck (pull_request) Successful in 49s
CI / security (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 32s
CI / unit_tests (pull_request) Successful in 3m3s
CI / integration_tests (pull_request) Successful in 59s
CI / build (pull_request) Successful in 33s
CI / coverage (pull_request) Failing after 12m21s
CI / status-check (pull_request) Has been cancelled
to 7e427de2b1
Some checks failed
CI / lint (pull_request) Successful in 36s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m6s
CI / integration_tests (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 34s
CI / coverage (pull_request) Failing after 3m8s
CI / status-check (pull_request) Failing after 3s
2026-07-01 11:57:27 +00:00
Compare
hurui200320 left a comment

PR 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_prompt pattern in the pruning call sites, the (0, 0) ambiguity is resolved via the _metadata_present bool flag, the build_chat_model is properly inside Phase 1's try block, and a dedicated MissingUsageMetadataError replaces the generic RuntimeError. 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

  1. Redundant or self.model fallback for prune_model_namesrc/cleveractors/agents/llm.py, line 534
    prune_model_name = self._pruning_model or self.model
    
    Given the new ConfigurationError raised at construction (lines 229–234) when _pruning_model != self.model, _pruning_model is now guaranteed to be None or equal to self.model. The or self.model fallback is dead code. Cleaning this up to prune_model_name = self.model would be tidier but is not a correctness issue.

Nits

  1. _log_no_usage_metadata is emitted even when MissingUsageMetadataError will be raisedsrc/cleveractors/agents/llm.py, line 591. The helper logs a generic warning before _run_pruning_pass raises. Operators will see both the warning and the exception. Acceptable for debuggability.

  2. _log_no_usage_metadata fires for every main-model ainvoke() that lacks metadatasrc/cleveractors/agents/llm.py, lines 877, 1017, 1025, 1182, 1278. The five main-model call sites discard _metadata_present with _, so any model lacking usage_metadata (some open-source providers, test mocks) will produce one warning per loop round. Noise concern, not a correctness one.

  3. Spec deviation note could be more explicit in the changelogCHANGELOG.md, line 18. The changelog does not explicitly note that pruning_model has been effectively disabled (must equal model). 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:

  • Gap 1 (pruning model calls untracked): _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_response is pure string operations and str(prune_response.content) is unlikely to raise.
  • Gap 2 (tool-loop round accumulation): Five ainvoke() call sites in process_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 inner try/except MissingUsageMetadataError blocks at lines 976–981 and 1126–1129 set _captured_prompt and _captured_completion before re-raising, so the outer except Exception handler preserves the counts.
  • Gap 3 (estimate blending): The old _estimate_token_count heuristic is removed. The budget check at line 846 now uses actual provider-reported consumption. The broken benchmarks concern is resolved: TokenExtractionBenchmark replaces TokenBudgetBenchmark and exercises the new helper.

The temporary limitation (pruning_model must equal model) is well-documented and the path forward (per-model token tracking in NodeUsage) is identified in issue #65. MissingUsageMetadataError propagation through process_message() is correctly wrapped as ExecutionError, 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.

## PR 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_prompt` pattern in the pruning call sites, the `(0, 0)` ambiguity is resolved via the `_metadata_present` bool flag, the `build_chat_model` is properly inside Phase 1's try block, and a dedicated `MissingUsageMetadataError` replaces the generic `RuntimeError`. 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 1. **Redundant `or self.model` fallback for `prune_model_name`** — `src/cleveractors/agents/llm.py`, line 534 ```python prune_model_name = self._pruning_model or self.model ``` Given the new `ConfigurationError` raised at construction (lines 229–234) when `_pruning_model != self.model`, `_pruning_model` is now guaranteed to be `None` or equal to `self.model`. The `or self.model` fallback is dead code. Cleaning this up to `prune_model_name = self.model` would be tidier but is not a correctness issue. ### Nits 1. **`_log_no_usage_metadata` is emitted even when `MissingUsageMetadataError` will be raised** — `src/cleveractors/agents/llm.py`, line 591. The helper logs a generic warning before `_run_pruning_pass` raises. Operators will see both the warning and the exception. Acceptable for debuggability. 2. **`_log_no_usage_metadata` fires for every main-model `ainvoke()` that lacks metadata** — `src/cleveractors/agents/llm.py`, lines 877, 1017, 1025, 1182, 1278. The five main-model call sites discard `_metadata_present` with `_`, so any model lacking `usage_metadata` (some open-source providers, test mocks) will produce one warning per loop round. Noise concern, not a correctness one. 3. **Spec deviation note could be more explicit in the changelog** — `CHANGELOG.md`, line 18. The changelog does not explicitly note that `pruning_model` has been effectively disabled (must equal `model`). 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: - **Gap 1 (pruning model calls untracked):** `_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_response` is pure string operations and `str(prune_response.content)` is unlikely to raise. - **Gap 2 (tool-loop round accumulation):** Five `ainvoke()` call sites in `process_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 inner `try/except MissingUsageMetadataError` blocks at lines 976–981 and 1126–1129 set `_captured_prompt` and `_captured_completion` before re-raising, so the outer `except Exception` handler preserves the counts. - **Gap 3 (estimate blending):** The old `_estimate_token_count` heuristic is removed. The budget check at line 846 now uses actual provider-reported consumption. The broken benchmarks concern is resolved: `TokenExtractionBenchmark` replaces `TokenBudgetBenchmark` and exercises the new helper. The temporary limitation (`pruning_model` must equal `model`) is well-documented and the path forward (per-model token tracking in `NodeUsage`) is identified in issue #65. `MissingUsageMetadataError` propagation through `process_message()` is correctly wrapped as `ExecutionError`, 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.
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from 7e427de2b1
Some checks failed
CI / lint (pull_request) Successful in 36s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 50s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m6s
CI / integration_tests (pull_request) Successful in 1m9s
CI / build (pull_request) Successful in 34s
CI / coverage (pull_request) Failing after 3m8s
CI / status-check (pull_request) Failing after 3s
to d7e951402a
Some checks failed
CI / lint (pull_request) Has started running
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
2026-07-01 13:55:23 +00:00
Compare
CoreRasurae force-pushed bugfix/m2-lllm-token-consumption-undercounted from d7e951402a
Some checks failed
CI / lint (pull_request) Has started running
CI / typecheck (pull_request) Has been cancelled
CI / security (pull_request) Has been cancelled
CI / quality (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled
to 8a7e353767
Some checks failed
CI / lint (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 49s
CI / quality (pull_request) Successful in 33s
CI / unit_tests (pull_request) Successful in 3m5s
CI / integration_tests (pull_request) Successful in 1m19s
CI / build (pull_request) Successful in 35s
CI / coverage (pull_request) Successful in 3m7s
CI / status-check (pull_request) Successful in 3s
CI / lint (push) Successful in 33s
CI / typecheck (push) Successful in 50s
CI / security (push) Successful in 50s
CI / quality (push) Successful in 33s
CI / integration_tests (push) Successful in 1m11s
CI / build (push) Successful in 34s
CI / unit_tests (push) Failing after 14m51s
CI / coverage (push) Has been cancelled
CI / status-check (push) Has been cancelled
2026-07-01 13:56:09 +00:00
Compare
CoreRasurae deleted branch bugfix/m2-lllm-token-consumption-undercounted 2026-07-01 14:44:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
cleveragents/cleveractors-core!66
No description provided.