fix: stamp provider usage on chat() responses and converge text unwrapping - #1787
fix: stamp provider usage on chat() responses and converge text unwrapping#1787Q1hangL wants to merge 7 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors token usage extraction and introduces a safe text-extraction utility (unwrap_chat_text) to prevent dictionary representations of tool calls from leaking into compacted contexts or API responses. Model adapters (Claude, Gemini, OpenAI, Zhipu) have been updated to return structured text envelopes containing top-level usage stamps. The review feedback highlights that the new usage_payload in the Claude, Gemini, and Zhipu adapters omits cached input token metrics, which prevents prompt cache hits from being tracked. Additionally, the reviewer noted a discrepancy in unwrap_chat_text where empty string responses raise LLMNoTextContentError instead of LLMEmptyContentError as documented.
…rors Address PR xorbitsai#1787 review findings: - Claude/Gemini/Zhipu usage stamps now carry cached_input_tokens (and cache_write_input_tokens for Claude) when non-zero, so PatternRuntime._extract_cached_tokens sees prompt-cache hits on non-streaming calls; guarded so unexpected value types can never raise out of chat() - unwrap_chat_text raises LLMEmptyContentError for empty/whitespace envelope content, keeping LLMNoTextContentError for non-text shapes
|
Third-pass update pushed ( One real bug found and fixed. The envelope change widens a pre-existing repr leak of the same class as #1714: Coverage added (contract file 15 → 36 tests):
Every new/changed line is mutation-verified (revert → named test goes red → restore). Full local run: 775 passed, 0 failed; ruff check/format clean. |
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR standardizes non-streaming chat and vision observability by stamping provider usage onto adapter envelopes and making PatternRuntime resolve both top-level and one-level raw payloads, so compact calls can populate ExecutionContext and trace accounting. It also changes the Claude/Gemini/Zhipu text paths to return {type: "text", content, ...} envelopes, adds a strict text-only unwrapping helper, and repairs the normal text-envelope path in VisionCore to address the #520 usage gap and the #1714 tool-call repr() leak. The overall direction is useful, but two newly exposed accounting/lifecycle interactions still violate the compaction and retry observability contracts and should be fixed before merge.
Blocking: yes — recommended event: REQUEST_CHANGES
Update since 4334f40
d2f0f16 adds non-zero cache-read/cache-write fields to the Claude, Gemini, and Zhipu usage stamps and separates LLMEmptyContentError from LLMNoTextContentError for empty envelope content, with corresponding contract coverage. 7efc2a2 expands the contract tests across checkpoint, cache, vision, reasoning, and empty-content paths, and fixes VisionCore.understand_media and detect_objects so normal text envelopes are not stringified before use.
Prior findings checklist
| Canonical root | Prior occurrences and replies | Status | Current verification |
|---|---|---|---|
| H1-cache-stamps | Aggregate review 5033197773; inline roots 3865087974 (Claude), 3865087988 (Gemini), 3865087997 (Zhipu chat), 3865088002 (Zhipu vision); replies 3865211710, 3865212036, 3865212379, 3865212684 |
FIXED | d2f0f16 now carries the cache metrics on all four affected non-streaming paths; every occurrence is fixed. |
| H2-empty-unwrapper | Aggregate review 5033197773; inline root 3865088015; reply 3865212969 |
FIXED | d2f0f16 now raises LLMEmptyContentError for empty/whitespace envelope content while reserving LLMNoTextContentError for non-text shapes; the affected callers and coverage match the contract. |
These fixed roots are not re-reported as current findings.
Approach verdict
acceptable-with-reservations. Adapter-boundary normalization plus read-only runtime extraction is a reasonable fit for #520 and avoids unsafe before/after diffs of the shared task ledger. The shared text helper is also safer than silently converting a tool-call envelope into model text. The reservations are that the model-layer response contract was not cut over coherently, structural decoding remains split from the vision policy, and the new usage visibility exposes lifecycle cases that the current accounting model does not distinguish.
Verified current findings
Major findings — blocking
N1 — Stamped compaction usage can suppress the truncation fallback
src/xagent/core/agent/runtime.py:1072 — Severity: major. Blocking: yes.
- Trigger: The live context is above the configured compaction threshold, and a compact model returns normally with a stamped non-text or empty envelope (for example, a
tool_call) whoseprompt_tokensis below that threshold. - Affected contract:
compact_context_if_neededmust fall back to truncating the live context when summarization produces no usable text, while still retaining the compact call's usage exactly once inExecutionContextand the trace. - Impact:
on_llm_endrecords the compact prompt while the live messages are still unchanged.compact_with_llm_responsethen leaves those messages unchanged, but the fallback_get_total_tokenscan treat the just-recorded synthetic prompt as a fresh measurement of the live context and skip truncation. The original oversized history can therefore reach the next main-model request (including a possible context-limit failure), while the context-size gauge reports the compact prompt's smaller count. - Evidence: The ordering is
on_llm_endbeforecompact_with_llm_responseatsrc/xagent/core/agent/runtime.py:1188-1202, followed bycompact_if_neededatsrc/xagent/core/agent/runtime.py:1220-1223; the freshness fingerprint and reuse are insrc/xagent/core/agent/context/execution.py:716-742,1430-1449. The newly visible stamped tool-call path issrc/xagent/core/model/chat/basic/openai.py:476-520. The added success-path coverage rewrites messages, while the non-text test uses an unstamped fake and does not assert that fallback truncation actually shrinks an oversized history. - Specific fix: Keep the usage record for trace and total-token accounting, but mark it as synthetic
context_compaction(or otherwise exclude it from the live-context freshness baseline). Alternatively, carryrequest["original_tokens"]into the fallback or recompute the live estimate there. Add stamped tool-call and empty-text regressions withprompt_tokens <= thresholdandmax_messagesexceeded, asserting both real truncation and retained one-time usage accounting.
N2 — Response-local stamps lose billed retry attempts
src/xagent/core/model/chat/basic/openai.py:478 — Severity: major. Blocking: yes.
- Trigger: A supported provider consumes non-zero tokens on an empty/invalid attempt and then retries successfully through the generic retry wrapper, or OpenAI-compatible structured-output handling performs its second request after the first response fails JSON validation. The usage is written to the task-wide ledger before local validation, but
_process_responseand the outer wrapper expose only the final response. - Affected contract: The PR's response-stamp contract is intended to expose real provider usage through
ExecutionContext.llm_calls,action_end_llm, and the monitoring token totals, not only through the separate task billing ledger. Each billed attempt must be represented or explicitly aggregated at that boundary. - Impact: Earlier billed attempts disappear from
ExecutionContextandaction_end_llm; if all attempts fail, none reaches those surfaces. The task ledger can correctly contain both attempts whileExecutionContextand monitoring report only one (or zero), so user-visible/API observability diverges from actual provider usage and call count. - Evidence:
src/xagent/core/model/chat/basic/openai.py:476-489records the first response before content validation, and the structured-output retry replaces it atsrc/xagent/core/model/chat/basic/openai.py:623-666; the empty-content failure is atsrc/xagent/core/model/chat/basic/openai.py:576-579. The analogous pre-validation recording and retryable failures are present in the Claude and Gemini paths.PatternRuntimeemits one end record only after the returned call atsrc/xagent/core/agent/runtime.py:1021-1048, while the monitoring totals scan those end events atsrc/xagent/core/monitor.py:433-480. Existing retry fixtures useusage=Nonefor the discarded attempt, and the new compact contract path makes only one provider request. - Specific fix: Make attempts first-class at the usage boundary: carry an attempt collector/callback through both retry layers, or aggregate all attempt payloads into the final envelope and trace while preserving the final prompt measurement separately for context freshness. Do not infer the aggregate by diffing the shared mutable task ledger. Add non-zero first-attempt fixtures for outer retry and structured-output retry (including Claude/Gemini cases), and assert the documented aggregate/per-attempt behavior across the task ledger,
ExecutionContext, end/error traces, and monitor totals.
Minor finding — line-level
N3 — Invalid numeric usage metadata can break a successful answer or corrupt traces
src/xagent/core/agent/runtime.py:1075 — Severity: minor. Blocking: no.
The new top-level/raw resolver feeds provider counters into _first_int, which accepts bool as an int, truncates arbitrary floats, accepts negative values, and calls int() on NaN/infinity without catching ValueError/OverflowError. A malformed optional usage payload can therefore make on_llm_end raise after a usable answer (or make compaction take an unintended error path), while negative values are clamped in llm_calls but remain negative in action_end_llm fields. The fix is a single runtime count coercer that rejects booleans and non-finite/negative values, catches conversion errors, and skips invalid aliases/candidates so a valid later candidate can still be used. Current coverage exercises valid, zero, and ordinary nested shapes but does not cover NaN, infinities, booleans, negative/fractional counters, or invalid cached fields.
Design concerns — separate from line-level findings
N4 — BaseLLM's public response contract and default stream boundary are stale (body-only)
src/xagent/core/model/chat/basic/base.py:226-372 — Severity: minor. Blocking: no. This file is unchanged by the PR, so this is intentionally body-only rather than an inline comment.
The base documentation still says natural-language chat()/vision_chat() results are strings, even though the PR changes the normal text result of Claude, Gemini, and Zhipu to an envelope. More importantly, inherited BaseLLM.stream_chat() treats every non-string result as a tool call; a custom/direct implementation returning {type: "text", content: "answer"} through that default emits an empty tool-call chunk and loses the answer. Current built-in adapters override stream_chat, and PatternRuntime avoids this default, so the concrete impact is an extension/API compatibility gap rather than a current built-in runtime failure.
Update the model-layer types/docs and make the default stream implementation distinguish text, tool-call, legacy string, and unknown shapes (including usage where applicable), then add a focused inherited-default stream contract test.
N5 — Structural response decoding remains split across agent and vision layers
src/xagent/core/agent/utils/llm_utils.py:13 — Severity: minor. Blocking: no.
unwrap_chat_text() now provides one decoder for ContextBuilder and the web API, but VisionCore still hand-parses the same response union. understand_media accepts any dict with string content, turns tool calls into a successful explanatory answer, and stringifies unknown shapes; detect_objects unwraps only type == "text" and can turn tool-call, unknown, or content-only legacy envelopes into a repr-like parse input followed by success=True with zero detections. A supported non-text/custom response can therefore be reported as a successful answer or successful empty detection instead of an explicit tool-side failure; the normal typed-text path fixed in 7efc2a2 does not remove this residual drift.
Move only structural classification (text/empty/tool-call/unknown and its payload) to a dependency-neutral model/chat utility, keep truncation/HTTP/tool diagnostics in each caller, and route both vision methods through that classifier. Add a shape matrix for plain/typed/content-only text, empty, tool-call, unknown, None, and non-string content, asserting no repr leak and no successful zero-detection result. The current added vision tests cover successful text envelopes but not these negative shapes.
Blocking status & recommended decision
Blocking: yes. The two blocking issues are:
- N1 —
src/xagent/core/agent/runtime.py:1072, major: stamped compaction usage can suppress fallback truncation and send oversized history onward. [new] - N2 —
src/xagent/core/model/chat/basic/openai.py:478, major: billed retry attempts disappear fromExecutionContext/trace monitoring. [new]
N3, N4, and N5 are minor and non-blocking. Recommended event: REQUEST_CHANGES.
| usage_metadata, | ||
| ("candidates_token_count", "completion_tokens", "output_tokens"), | ||
| ) | ||
| def _extract_token_usage(self, response: Any) -> tuple[int, int] | None: |
There was a problem hiding this comment.
N1 — major / blocking. This new extractor makes the compact response's stamped prompt_tokens visible to on_llm_end; for a stamped tool-call or empty envelope, on_llm_end records it while live messages are unchanged, then compact_with_llm_response leaves them unchanged and fallback _get_total_tokens can reuse that synthetic fingerprint and skip truncation when the stamp is under threshold. Please keep the compaction usage record for trace totals but exclude/mark it for freshness, or carry request["original_tokens"] into fallback; add a stamped non-text regression that proves oversized history is truncated.
There was a problem hiding this comment.
FIXED in 59e9506. LLMCallRecord now carries synthetic_purpose (populated from the purpose metadata, currently only context_compaction), and _get_total_tokens picks its freshness baseline via _latest_freshness_baseline_call() — the newest non-synthetic record — so a stamped compact prompt can never impersonate the live context size, while the record still lands exactly once in llm_calls and the trace. The field round-trips through to_dict/from_dict with a backward-compatible default for older checkpoints. Regression coverage in TestSyntheticUsageFreshnessBaseline (7 tests), including your exact scenario: oversized history + stamped tool_call compact response with prompt_tokens < threshold now truncates for real (message count shrinks) and retains the one-time compact usage accounting; the empty-text variant is covered too.
|
|
||
| # Snapshot usage once; every result envelope below is stamped with | ||
| # it so downstream consumers never need to dig through ``raw``. | ||
| usage_payload = _response_usage_payload(resp) |
There was a problem hiding this comment.
N2 — major / blocking. usage_payload is snapshotted per _process_response, but the surrounding retry boundaries return only the final result: an empty/invalid billed attempt is recorded before validation, then generic retry or structured-output degrade replaces it with attempt 2. PatternRuntime therefore emits one llm_call_end/ExecutionContext record (or none when all attempts fail), while the task ledger contains both attempts. Please carry or aggregate attempt usage through both retry layers without diffing the shared ledger, and add non-zero failed-attempt fixtures that assert trace and ExecutionContext accounting.
There was a problem hiding this comment.
FIXED in 59e9506, making billed attempts first-class exactly as you suggested — no ledger diffing anywhere:
- Contract:
usagestays the final attempt (the context-freshness baseline);usage_attemptsis the ordered list of every billed attempt, final included, set only when more than one was billed. - Producers: OpenAI-family adapters attach the booked payload to retryable errors raised after pre-validation booking (
LLMRetryableError.usage_attempts), and collect superseded attempts across the two internal retries (response_format resend — unbilled, so not collected — and the thinking-disabled structured-output retry). Claude/Gemini attach at their post-booking raise sites; Gemini'sRuntimeErrorwrap forwards the attribute since the wrapper already classifies through__cause__. - Wrapper:
RetryWrapper.invoke/ainvokecollect exception-carried attempts per retry and merge them into the successful envelope; when every attempt fails, the full ordered list rides the final exception. - Runtime:
on_llm_endbooks each attempt in order (final last, sollm_calls[-1]remains the freshness baseline), and the end trace reports billing totals plusllm_attempt_countandfinal_prompt_tokens/final_output_tokens— matching the monitor's per-end-event summation.on_llm_errorbooks exception-carried attempts so the all-failed case reachesExecutionContexttoo.
Coverage: tests/core/agent/test_usage_attempts_contract.py (14 tests) — outer retry with non-zero first attempts for OpenAI/Claude/Gemini, the structured-output second request, the all-failed path, and runtime recording/trace shapes. Scope note: Zhipu's empty-content path raises a bare RuntimeError (non-retryable, the wrapper never retries it) — left untouched and tied to #1714 problem 2.
| def _extract_token_usage(self, response: Any) -> tuple[int, int] | None: | ||
| for key, usage in self._resolve_usage_payload(response): | ||
| if key == "usage": | ||
| input_tokens = self._first_int( |
There was a problem hiding this comment.
N3 — minor / non-blocking. The new resolver feeds _first_int provider values that may be NaN, infinity, boolean, negative, or fractional; int(float('nan'))/int(float('inf')) can raise from on_llm_end, while invalid negatives can leak into trace fields. Please use a finite, non-boolean, non-negative coercer that catches conversion errors and fails open, then add malformed top-level and raw usage/cache cases.
There was a problem hiding this comment.
FIXED in 59e9506. New _coerce_usage_int rejects bools (int subclass), non-finite floats (NaN/inf — no more uncaught int() crash), negatives, and non-integral floats, coercing only ints and integral floats; invalid aliases/candidates are skipped so a valid later candidate still wins. Wired through _first_int for both extractors. Coverage in TestStrictUsageIntCoercion: NaN, ±inf, True/False, -5, 10.5, "10", None, plus invalid-first-candidate/valid-second fall-through.
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def unwrap_chat_text(response: Any) -> str: |
There was a problem hiding this comment.
N5 — minor / non-blocking. This helper is used by agent/web callers, but VisionCore still has separate partial decoders: understand_media can stringify unknown/tool-call values into success=True, while detect_objects only unwraps type == "text" and can return success=True with zero detections for other envelopes. Please share a model-layer structural classifier and keep each caller's failure policy local; add negative shape-matrix tests so no repr or successful zero-detection result is possible.
There was a problem hiding this comment.
FIXED in 59e9506, then reconciled with #1721 in 94b3be9 — full picture:
- Structural classification now lives in
model/chat/response_shape.classify_chat_response(text/empty/tool_call/unknown + text payload, never raises, never reprs).unwrap_chat_textand the defaultstream_chatroute through it. - While this PR was in flight, fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721 landed
_normalize_vision_responsein the vision layer, which already covers most of N5 —detect_objectsfails explicitly on tool_call/unknown/empty with boundedraw_displaydiagnostics. On rebase I kept their stricter normalizer rather than churning freshly-merged code, and aligned the one remaining deviation with your requested policy:understand_mediano longer turns a tool_call envelope into a successful explanatory answer — it fails explicitly now. - One thing to flag: that tool_call message ("Model triggered tool call instead of answering: ...") was deliberately preserved by fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's tests, so your N5 and fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721 disagree there. I implemented your requested policy and flipped that test (the docstring says why). If you'd rather keep fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's behavior, say so and I'll revert that single hunk.
Shape-matrix coverage: tests/core/model/chat/test_response_shape.py plus the updated test_vision_tool.py — no repr leaks, no successful zero-result answers on any of plain/typed/content-only text, empty, tool_call, unknown, None, or non-string content.
…pping Fixes xorbitsai#520. Refs xorbitsai#1714. - OpenAI-compatible adapters stamp resp.usage as a top-level usage key on every chat/vision envelope (DeepSeek/DashScope/OpenRouter/Azure inherit it) - Zhipu/Gemini/Claude text paths return the {type, content, usage} envelope the streaming path already emits, instead of a bare string - PatternRuntime._extract_token_usage/_extract_cached_tokens share _resolve_usage_payload and also resolve usage one level under raw - New shared unwrap_chat_text() raises LLMNoTextContentError instead of repr()-ing tool_call envelopes; ContextBuilder._compact_* and optimize_instructions migrated onto it - Contract tests at the SDK transport boundary per adapter family, a no-double-counting guard, and a mutation-verified change list
…rors Address PR xorbitsai#1787 review findings: - Claude/Gemini/Zhipu usage stamps now carry cached_input_tokens (and cache_write_input_tokens for Claude) when non-zero, so PatternRuntime._extract_cached_tokens sees prompt-cache hits on non-streaming calls; guarded so unexpected value types can never raise out of chat() - unwrap_chat_text raises LLMEmptyContentError for empty/whitespace envelope content, keeping LLMNoTextContentError for non-text shapes
Third-pass hardening on the xorbitsai#520 fix: - End-to-end cached-token assertions through the compact path into the action_end_llm trace event; vision_chat stamp tests for OpenAI/Zhipu; reasoning-truncation branch stamp; extractor edge shapes (string raw, top-level usage_metadata, all-zero usage) - Pin the checkpoint round-trip of llm_calls and the record-before-rewrite ordering contract behind estimate_context_tokens - xorbitsai#1714: empty-content envelopes now exercise the LLMEmptyContentError path through the compaction fallback and the optimize_instructions 500 - Fix two pre-existing repr leaks of the same class in vision_tool.py (understand_media str(result), detect_objects str(raw_result)) that the envelope change widened; regression tests red before the fix
…unting N1 (blocking): usage records of internal calls are marked with synthetic_purpose and skipped as the context-freshness baseline, so a failed LLM compaction can no longer suppress the truncation fallback; the field round-trips through checkpoints with a backward-compatible default N2 (blocking): billed retry attempts are first-class. Adapters attach usage_attempts to retryable errors raised after booking tokens and to envelopes after internal retries (usage stays the final attempt); RetryWrapper merges them across outer retries; PatternRuntime books every attempt in order (final last, so the freshness baseline is unchanged), reports billing totals plus llm_attempt_count and final_prompt/final_output in end/error traces, and books attempts carried by a terminally failing call N3 (minor): strict usage-count coercion rejects bools, non-finite, negative, and non-integral values and falls through to later aliases/candidates instead of truncating or raising N4 (minor): BaseLLM docs describe the envelope contract; the default stream_chat discriminates text/tool_call/legacy-string/unknown shapes via the shared classifier instead of treating every non-string as a tool call N5 (minor): structural response classification moves to model/chat/response_shape.classify_chat_response; unwrap_chat_text and both VisionCore methods route through it, so tool_call/unknown/empty shapes fail explicitly instead of repr-leaking or reporting successful zero-result answers
…opes Rebase reconciliation: xorbitsai#1721 landed a vision-side response normalizer while this PR was in flight, covering most of review finding N5 (detect_objects already fails explicitly on every non-text shape). This commit aligns the one remaining deviation with the N5 contract: understand_media no longer reports a successful explanatory answer for a tool_call envelope and fails explicitly instead; the test xorbitsai#1721 deliberately wrote for the old message is flipped accordingly. classify_chat_response remains the shared structural classifier for the model/agent layers (unwrap_chat_text, the default stream_chat); the vision layer keeps xorbitsai#1721's stricter _normalize_vision_response.
7efc2a2 to
94b3be9
Compare
|
Round addressing the CHANGES_REQUESTED review is pushed — rebased onto current main ( Dispositions
Verification: local run — 1705 passed, 0 failed (2 pre-existing environment failures in |
rogercloud
left a comment
There was a problem hiding this comment.
This PR standardizes non-streaming chat responses around usage-bearing envelopes, stamps provider usage at adapter boundaries, and teaches PatternRuntime to resolve usage from normalized and raw response shapes. It also adds billed-attempt propagation, centralizes text unwrapping/response-shape classification, and updates compaction, optimization, vision, and default-stream consumers so real provider responses are observable without stringifying tool-call envelopes. The direction addresses #520 and #1714 problem 1, but several newly advertised attempt, cache, vision, and default-stream contracts remain incomplete.
Blocking: yes — recommended event: REQUEST_CHANGES
Approach verdict
acceptable-with-reservations. The core design is sound: adapters remain the single writer to the ContextVar ledger; runtime reads response/exception carriers rather than performing an unsafe concurrent ledger diff; final-attempt usage remains distinct from ordered billed-attempt history; synthetic compaction calls are isolated from conversational freshness; and the shared unwrapping helper removes the repr(dict) failure mode. The reservations are contract-closure concerns rather than a request for a wholesale envelope migration: billed-attempt transport is incomplete at generic exception, singleton-merge, and response-rebuild boundaries; aggregate token totals are paired with final-only cache metrics; the documented default stream is lossy for usage/metadata; VisionCore still has a second structural decoder; and the tool-call documentation contradicts the supported raw-less Gemini envelope.
Prior findings checklist
- H1 — non-streaming cache stamps: FIXED. Sources: review 5033197773, roots 3865087974, 3865087988, 3865087997, 3865088002, with replies 3865211710, 3865212036, 3865212379, and 3865212684. Current code verifies all four occurrence paths: Claude chat/vision (
claude.py:660-716,1248-1259), Gemini chat/vision (gemini.py:575-666,1024-1036), Zhipu chat (zhipu.py:251-379), and Zhipu vision (zhipu.py:911-1074) stamp cache usage;runtime.py:1016-1059,1172-1185consumes and emits it. - H2 — empty-envelope unwrapping: FIXED. Sources: review 5033197773, root 3865088015, and reply 3865212969. Current
llm_utils.py:37-50maps empty/whitespace text envelopes toLLMEmptyContentErrorand non-text/tool/unknown shapes toLLMNoTextContentError; both compaction callers fall back andoptimize_instructionssurfaces an explicit error, so the reported wrong exception/repr path is gone. - N2 — billed retry-attempt propagation: PARTIAL; residuals confirmed. Sources: review 5048241235, root 3878206294, reply 3879009534, and author context 5450122491. Ordinary
LLMRetryableErrorpropagation and final-usage success cases now work (openai.py:725-728,wrapper.py:22-49, runtime attempt consumers), but current occurrence evidence confirms R1-01 generic OpenAI exception exits (openai.py:730-753) losesuperseded_attempts, while R1-02 singleton collected history is suppressed by thelen > 1gates (wrapper.py:48,openai.py:720) when final usage is absent. The author FIXED claim was checked against current code and remains incomplete. - N3 — strict usage coercion: PARTIAL; split occurrence status. Sources: review 5048241235, root 3878206306, reply 3879009737, and author context 5450122491. The original direct-counter occurrence is FIXED:
_coerce_usage_int/_first_intand candidate fall-through inruntime.py:1063-1116,1187-1214reject malformed direct values without crashing. Two separately confirmed current residuals remain: nested raw cache aliases still use permissive coercion atruntime.py:1177-1184, and malformedusage_attemptsrows still make trace count andExecutionContext.llm_callsdiverge atruntime.py:1127-1139(also on error); the streaming_merge_usagemalformed-value path is pre-existing and out of scope. - N4 — default answer-stream shape: FIXED. Sources: review 5048241235 and author context 5450122491. Current
base.py:373-401uses the shared classifier, emits text envelopes as TOKEN content, preserves tool calls, and emits an explicit ERROR for unusable shapes; the prior text-as-empty-tool-call answer loss is not present. - N5 — VisionCore/shared structural decoder: PARTIAL; R0-D4 residual confirmed. Sources: review 5048241235, root 3878206311, reply 3879010019, and author context 5450122491. The repr/success-zero and tool-call policy paths are fixed, but
vision_tool.py:894,1127still calls its private normalizer: the shared classifier accepts content-only and unknown-tag string envelopes while VisionCore rejects them. The author FIXED claim was checked against this current divergence and remains incomplete. - H3 — vision envelope repr/detection leak: FIXED. Source: author context 5429182593. The typed-text and detection decoding already present in the review base is retained, and current vision paths no longer stringify typed envelopes as successful answer/detection data; this historical root is not a current finding.
Findings
Major findings
N2 / R1-01 + R1-02 — billed-attempt propagation remains incomplete (prior residual)
Locations: src/xagent/core/model/chat/basic/openai.py:730-753 (R1-01 generic internal exception exits), src/xagent/core/retry/wrapper.py:48, and src/xagent/core/model/chat/basic/openai.py:720 (R1-02 singleton merge gates). Severity: major. Blocking: yes.
Reachable trigger: In the supported structured-output path, the first response can contain reasoning, non-JSON content, and non-zero usage; the adapter books it and adds it to superseded_attempts, then the thinking-disabled resend can raise BadRequestError, APITimeoutError, RateLimitError, AuthenticationError, another APIError, or a generic exception. Separately, an outer retry can collect one billed failure and then receive a successful final envelope that legitimately omits optional usage.
Concrete impact: The generic catches wrap the second failure in a fresh RuntimeError without usage_attempts; RetryWrapper and PatternRuntime.on_llm_error only inspect the surfaced carrier, so the known first billed call is absent from ExecutionContext.llm_calls, error trace token totals, and attempt count. In the unmetered-success case, the collected singleton is discarded by len(merged) > 1, so the same known billed attempt is omitted from end-path context and trace accounting. The provider/task ledger and execution/trace views therefore under-report a real billed request.
Contract/invariant: usage_attempts is documented as every known billed attempt, while usage remains the final attempt for freshness. Optional final usage cannot be used to erase a known prior payload, and a cause-preserving wrapper does not make __cause__ an accounting carrier; the runtime intentionally has no concurrent ContextVar-diff fallback.
Specific fix: After constructing every surfaced OpenAI exception, copy non-empty superseded_attempts (and existing attempt payloads) onto it before raising, without changing retry classification. In both the generic wrapper and the OpenAI internal merge, preserve a non-empty known history even when it has one element; keep the final attempt unmeasured rather than promoting the prior payload into usage. Reuse one merge/carrier helper and cover generic second-request failures plus final-unmetered success.
I saw the author’s FIXED claim in reply 3879009534 and context 5450122491; current code confirms only the retryable/final-usage cases, not these residuals.
R1-03 — DeepSeek protocol-error rebuild drops billed-attempt metadata (new)
Location: src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:59. Severity: major. Blocking: yes.
Reachable trigger: A supported DeepSeek tool call goes through an internal retry and produces an envelope containing final usage plus ordered usage_attempts; the final response then violates the DSML/tool protocol, so normalize_deepseek_response rebuilds an error envelope.
Concrete impact: The rebuild copies only usage, not usage_attempts. PatternRuntime consequently sees the final attempt only, losing previously billed attempts from ExecutionContext.llm_calls, trace totals, and llm_attempt_count even though the original response already carried them.
Contract/invariant: This is a response transformation, not a new provider request. A rebuild must preserve the complete accounting metadata of the original envelope; the new contract explicitly distinguishes final usage from all billed usage_attempts.
Specific fix: Copy usage_attempts whenever present (preferably through a shared accounting-key/envelope-preservation helper) while rebuilding the protocol-error response, and add a violation case with two usage payloads that is checked through PatternRuntime.on_llm_end.
R1-05 — Zhipu booked usage is lost through a non-retryable blank-response error (new)
Locations: src/xagent/core/model/chat/basic/zhipu.py:398,417 (blank guard and surfaced wrapper); the new booked payload is created at src/xagent/core/model/chat/basic/zhipu.py:262. Severity: major. Blocking: no.
Reachable trigger: A supported Zhipu response has choices and non-zero usage, but its first message has no tool calls and content is None, empty, or whitespace-only. The adapter books usage before the blank-content guard, then raises RuntimeError and wraps it in another RuntimeError.
Concrete impact: The ContextVar provider ledger contains the billed call, but the surfaced error has no usage_attempts; on_llm_error therefore records no LLMCallRecord and emits no error token fields. Execution totals/call count and action_error_llm under-report a failed call that may already be billed.
Contract/invariant: Billing and retryability are independent. Keeping this path as a non-retryable RuntimeError is compatible with #1714 problem 2, but it cannot discard a payload already booked by the adapter; runtime’s error accounting only has the exception carrier, not the cause chain or a ledger diff.
Specific fix: Attach [usage_payload] as error.usage_attempts before the blank-response raise when present, and preserve that attribute when the outer catch creates the surfaced RuntimeError. Do not change this path to LLMEmptyContentError unless intentionally expanding #1714 problem 2.
The author’s discussion says this bare RuntimeError path was left untouched for retry-policy reasons in 5450122491 (and reply 3879009534); that explains the non-retry behavior, but it does not mitigate loss of already-booked usage.
R1-06 — default stream adaptation drops usage and provider metadata (new)
Locations: src/xagent/core/model/chat/basic/base.py:385 (text TOKEN branch) and :388-393 (tool branch). Severity: major. Blocking: no.
Reachable trigger: A supported custom/extension BaseLLM implements chat() but inherits the public default stream_chat(), returning a valid text or tool-call envelope with usage and/or raw/provider metadata. The minimal subclass used by the PR’s own default-stream contract test is such a boundary; built-in concrete adapters overriding stream_chat do not remove the inherited API contract.
Concrete impact: The text branch emits only content/delta, and the tool branch emits a TOOL_CALL without a USAGE chunk. PatternRuntime reads usage only from USAGE chunks and reconstructs only an allowlisted subset of raw metadata, so streaming through this documented default loses billed usage/cache metadata and provider state even though direct chat() returned it. Chat and stream behavior are inconsistent.
Contract/invariant: BaseLLM.chat() documents usage-bearing envelopes and optional raw, while the default stream is the chat-to-chunk adaptation for subclasses that do not override it. StreamChunk and runtime reconstruction define USAGE as a separate accounting chunk; there is no exception allowing a valid usage-bearing envelope to be projected as text/tool data only.
Specific fix: Preserve raw=result on the text TOKEN chunk, and emit a ChunkType.USAGE chunk carrying the normalized usage/raw for both text and tool envelopes when usage is present. Keep the classifier/tool semantics unchanged and extend the inherited-default contract test through runtime reconstruction. This is separate from the already-fixed N4 answer classification.
Minor findings
R1-04 — retry-attempt cache metrics use a different scope from token totals (new)
Locations: src/xagent/core/agent/runtime.py:1049,1174-1185 (success) and :1260 (all-failed error). Severity: minor. Blocking: no.
Reachable trigger: A provider bills an empty/invalid response with cache-hit usage and retries successfully, or all attempts fail; the ordered usage_attempts list carries cache fields for the attempts. This is the retry path explicitly introduced by the PR.
Concrete impact: input_tokens, output_tokens, and total_tokens are summed across attempts, but success cache extraction examines only top-level/fallback usage and never usage_attempts; all-failed error events do not extract cache at all. action_end_llm/action_error_llm therefore under-report cache reads or omit them entirely, yielding incorrect cache efficiency/cost diagnostics even though the task-wide ledger is correct.
Contract/invariant: cached_input_tokens is a subset of billed input tokens, and the new trace fields explicitly use aggregate billed-attempt scope. A final-only cache value cannot be paired with aggregate token totals without changing the event’s meaning.
Specific fix: Add an attempt-aware cache reducer using the same direct aliases and provider fallbacks, sum cache reads over ordered usage_attempts on success, and apply the same reducer to exception-carried attempts before emitting the error event. Keep any final-only metric separately named.
R1-07 — tool-call documentation incorrectly requires raw (new)
Locations: src/xagent/core/model/chat/basic/base.py:243 and supported provider evidence at src/xagent/core/model/chat/basic/gemini.py:660-666. Severity: minor. Blocking: no.
Reachable trigger: Gemini returns a supported function-call part. A caller following the rewritten BaseLLM.chat() tool-call field list reads response["raw"] or rejects the envelope as malformed, although Gemini’s valid envelope contains type, tool_calls, and optional usage but no raw.
Concrete impact: The public documentation/API contract can cause a KeyError or rejection for a valid provider response. No in-repository caller currently indexes raw unconditionally, so the blast radius is limited to direct public/extension consumers.
Contract/invariant: The same docstring correctly marks raw optional for text envelopes; BaseLLM remains str | dict[str, Any], the shared classifier accepts tool envelopes without raw, and Gemini is an exported supported tool-calling provider. The author’s statement that the raw-presence caveat remained in the contract is not borne out by current base.py:243.
Specific fix: Change the tool-call bullet to say that raw is provider-dependent/optional and callers must not require it; align the vision_chat() wording and optionally pin Gemini’s raw-less shape in its contract test.
N5 / R0-D4 — VisionCore still diverges from the shared response classifier (prior residual/new design occurrence)
Locations: src/xagent/core/model/chat/response_shape.py:10-15,39-61 and src/xagent/core/tools/core/vision_tool.py:894,1127. Severity: minor. Blocking: no.
Reachable trigger: An injected/custom BaseLLM vision adapter returns a valid content-bearing dict without type: "text", or with an unknown type tag and string content. The public BaseLLM union is untyped str | dict[str, Any], and the PR’s classifier explicitly accepts these duck-typed shapes.
Concrete impact: unwrap_chat_text and default stream_chat classify the response as text, while VisionCore’s private normalizer returns unknown; understand_media/detect_objects can reject usable text or fail to parse valid detection JSON. The same supported response union therefore behaves differently across chat and vision consumers.
Contract/invariant: response_shape.py documents itself as the single structural source, and caller-specific vision policy/diagnostic truncation can remain local. The current built-in adapters mostly emit type: "text", but that does not exclude the documented extension/legacy boundary or reconcile the two production classifiers.
Specific fix: Make _normalize_vision_response() delegate structural kind/text/tool-call decoding to classify_chat_response, retaining only VisionCore-specific tool extraction and bounded diagnostics; add production delegation coverage for content-only and unknown-tag string envelopes.
I saw the author’s FIXED claim in reply 3879010019 and context 5450122491; current code verifies the repr/tool-call changes but still has this classifier mismatch.
N3 residual — raw cache aliases bypass strict coercion (new split residual)
Location: src/xagent/core/agent/runtime.py:1182 (with raw-candidate traversal at :1077-1082). Severity: minor. Blocking: no.
Reachable trigger: A supported raw-only envelope (for example, Xinference) or custom untyped provider payload contains nested cache aliases such as prompt_cache_hit_tokens=True, 10.5, or "10"; the PR-added raw fallback now reaches that usage object.
Concrete impact: After strict direct aliases are rejected, extract_cached_input_tokens() uses a permissive helper: True becomes 1, 10.5 becomes 10, and numeric strings are accepted. A malformed positive first alias can also shadow a valid prompt_tokens_details.cached_tokens or later raw candidate, producing a wrong cached_input_tokens trace value (even one larger than strict input_tokens).
Contract/invariant: The PR’s runtime usage contract says malformed counters are rejected and later aliases/candidates must win; cached input is a subset of input. The nested helper is unchanged, but exposing it through the new raw fallback makes this a PR-caused residual, not a re-report of the fixed direct-counter occurrence.
Specific fix: Apply _coerce_usage_int to nested cache aliases at this boundary, reject bools/strings/non-finite/non-integral/negative values, and continue on invalid or zero values so details and later candidates remain eligible.
N3 residual — invalid usage_attempts rows desynchronize trace and context (new split residual)
Location: src/xagent/core/agent/runtime.py:1127-1139 (consumed on end at :1031-1058 and on error at :1240-1263). Severity: minor. Blocking: no.
Reachable trigger: The new untyped usage_attempts carrier contains a malformed billed row followed by a valid row, for example [{"prompt_tokens": True, "completion_tokens": NaN}, {"prompt_tokens": 10, "completion_tokens": 5}]; adapter retry/error paths can carry arbitrary provider payloads.
Concrete impact: The list comprehension materializes the malformed row as (0, 0). ExecutionContext.record_llm_usage drops that row, while _trace_token_fields still counts it, so the trace reports two attempts while llm_calls contains one. A nonempty all-invalid list can also take precedence over a valid top-level usage and suppress it entirely.
Contract/invariant: The new contract promises every billed attempt and says trace totals/counts must match the per-attempt ledger. Invalid numeric data is not proof that the provider call was not billed, and the existing (0, 0) record guard is not a validity marker.
Specific fix: Preserve validity separately from numeric pairs and filter/mark wholly unusable rows before both recording and trace counting; use the same representation on end and error paths, with a valid top-level fallback when no attempt row is usable.
Review limitations
The Simplification Lens was unavailable after usage_limit_reached; no simplification finding is claimed and no Simplification opportunities section is included. The history extractor also failed, so the complete raw exports were manually reconciled instead: 11 review records, 2 PR conversation comments, 18 inline records (9 roots plus 9 replies), and the supplied linked-issue exports. This consolidation used static current/base-code verification and the recorded Round 0–2 evidence; no tests, build, lint, formatter, dependency installation, or project-wide command was run here.
Blocking status & recommended decision
Blocking: yes. The following confirmed roots independently meet the blocker standard because they create a concrete mismatch between provider-billed usage and execution/trace accounting:
src/xagent/core/model/chat/basic/openai.py:730-753andsrc/xagent/core/retry/wrapper.py:48— major — generic structured-retry failures and final-unmetered success can drop known billed attempts fromExecutionContextand trace totals; [prior] N2 (R1-01/R1-02 residuals).src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:59— major — protocol-error response rebuilding dropsusage_attemptsand undercounts billed attempts; [new] R1-03.
H1, H2, N4, and H3 are fixed and do not count as current findings. N3’s direct coercion occurrence is fixed; its two split residuals above are new current minor findings and do not block. Recommended event: REQUEST_CHANGES.
|
|
||
| except LLMRetryableError: | ||
| except LLMRetryableError as e: | ||
| if superseded_attempts: |
There was a problem hiding this comment.
N2 / R1-01 (prior residual). This carrier is attached only inside the LLMRetryableError branch. In the supported structured-output resend path, the first non-JSON response has already been booked and added to superseded_attempts, but a second BadRequestError, timeout, rate-limit, authentication, API, or generic failure is wrapped below as a fresh RuntimeError without that list. RetryWrapper and on_llm_error inspect only the surfaced attribute, so the known billed attempt disappears from ExecutionContext and the error trace. Please attach superseded_attempts to every surfaced exception after wrapping, without changing retry classification. I re-checked the author’s FIXED claim in reply 3879009534 and context 5450122491; it covers only the retryable branch.
There was a problem hiding this comment.
FIXED in 8963525. Every surfaced exception from chat() — the five generic SDK wraps and the catch-all — now carries superseded_attempts via a shared attach_usage_attempts helper (exceptions.py), and the retryable passthrough still prepends them ahead of the error's own payload. Verified end-to-end: test_openai_generic_error_after_internal_retry_carries_attempts drives the structured-output path (billed non-JSON first attempt, then a BadRequestError on the resend) and asserts the surfaced RuntimeError carries the attempt, and that on_llm_error books it into ExecutionContext and the error trace. Mutation check: removing the attach turns this test red.
| final_usage = result.get("usage") | ||
| final_attempts = [final_usage] if final_usage is not None else [] | ||
| merged = collected + list(final_attempts) | ||
| if len(merged) > 1: |
There was a problem hiding this comment.
N2 / R1-02 (prior residual). When a retryable failure contributes one known usage payload and the final success legitimately has no usage, merged contains that single known billed attempt. The len(merged) > 1 gate drops it, leaving both response accounting keys absent and causing execution/trace totals to under-report the provider charge. Keep usage unset because the final attempt is unmeasured, but preserve a non-empty usage_attempts list even when it has one element; use a separate request count if needed.
There was a problem hiding this comment.
FIXED in 8963525. The merge (now the shared merge_usage_attempts_into_result) writes usage_attempts whenever the collected history is non-empty — a one-element known history survives an unmetered final success, and nothing is promoted into usage. The same helper is used by the OpenAI internal merge, so both gates you cited behave identically. test_retry_wrapper_preserves_singleton_history_when_final_unmetered pins it; restoring the len > 1 gate turns it red.
| error_response = tool_protocol_error_response(violation, raw=raw) | ||
| # Preserve the top-level usage stamp the adapter put on the original | ||
| # envelope so token accounting survives the error rebuild. | ||
| if isinstance(response, dict) and response.get("usage") is not None: |
There was a problem hiding this comment.
R1-03 (new). This protocol-error rebuild copies the original top-level usage but drops usage_attempts. A supported DeepSeek tool response can already contain an ordered list of multiple billed attempts when the DSML violation is detected; after this rebuild, PatternRuntime sees only the final attempt and undercounts ExecutionContext and trace billing. Preserve response.get("usage_attempts") alongside usage (ideally through a shared accounting-key copy helper) before returning the rebuilt envelope, and cover a two-attempt violation case.
There was a problem hiding this comment.
FIXED in 8963525. The rebuild now copies usage_attempts alongside usage, with a comment noting it is a response transformation, not a new request. Two tests: test_deepseek_violation_rebuild_preserves_usage_attempts (both keys survive) and test_deepseek_rebuild_attempts_booked_through_on_llm_end (both attempts land in llm_calls, trace reports llm_attempt_count == 2). Mutation check: dropping the copy turns both red.
| output_tokens = getattr(usage, "completion_tokens", 0) or getattr( | ||
| usage, "output_tokens", 0 | ||
| ) | ||
| cached_tokens = extract_cached_input_tokens(usage) |
There was a problem hiding this comment.
R1-05 (new). This newly created usage_payload is booked before the blank-content guard at :398. A supported Zhipu response with usage plus None/empty/whitespace content and no tool calls raises a bare RuntimeError, and the outer wrapper at :417 creates another one without usage_attempts; on_llm_error therefore cannot record the billed call in ExecutionContext or action_error_llm. Attach [usage_payload] to the blank-response exception and preserve it through the wrapper, while keeping the existing non-retryable RuntimeError policy. The author’s explanation in 5450122491 addresses retryability, not this accounting carrier.
There was a problem hiding this comment.
FIXED in 8963525. The blank-response raise attaches [usage_payload] when present, and the outer Zhipu API error wrap now forwards usage_attempts from the cause — retry classification untouched, so #1714 problem 2 stays exactly as scoped. test_zhipu_blank_response_error_carries_booked_usage asserts the surfaced RuntimeError carries the booked payload, the task ledger equals it, and on_llm_error books it. Mutation check: removing the attach turns it red.
| if direct > 0: | ||
| return direct | ||
| return extract_cached_input_tokens(usage) | ||
| for key, usage in self._resolve_usage_payload(response): |
There was a problem hiding this comment.
R1-04 (new, success path). on_llm_end aggregates token totals from usage_attempts, but this cache loop examines only each response-level usage candidate and never the attempt list. If an earlier billed retry has cached input and the final attempt has zero/absent cache, the event reports aggregate input/output with final-only cache telemetry, undercounting cache efficiency. Reduce cache reads over the same ordered usage_attempts scope (and retain the existing single-response behavior) before emitting the event.
There was a problem hiding this comment.
FIXED in 8963525. On a multi-attempt success, cached_input_tokens is now summed over every billed attempt via the strict _cached_from_payload reducer — same aggregate scope as the token totals. TestAttemptCacheScope::test_end_trace_sums_cache_over_attempts (6 + nested 2 across two attempts -> 8 on the end event).
| one logical call, final attempt last). Each payload is extracted | ||
| with the same alias table as a top-level ``usage`` stamp. | ||
| """ | ||
| attempts = self._get_value(response, "usage_attempts") |
There was a problem hiding this comment.
N3 residual (new attempt-row occurrence). This extractor turns every usage_attempts item into a numeric pair without preserving validity. A malformed billed row such as {prompt_tokens: True, completion_tokens: NaN} becomes (0, 0); record_llm_usage drops it, while _trace_token_fields still counts it, so llm_attempt_count and context.llm_calls diverge (the same happens on errors). Filter/mark unusable rows and use the same representation for recording and trace counting, with a valid top-level fallback when no row is usable.
There was a problem hiding this comment.
FIXED in 8963525. Rows whose counters are all unusable are dropped in _extract_attempt_rows before both booking and trace counting, so llm_calls and llm_attempt_count stay in lockstep; when no row is usable the extractor returns empty and the caller falls back to the valid top-level usage (restoring the single-attempt trace shape). TestMalformedAttemptRows pins both halves, including the [{prompt_tokens: True, completion_tokens: NaN}, valid] case from your example. Mutation check: removing the filter turns it red.
| top-level ``usage`` stamp and a ``raw`` provider payload), | ||
| - a tool-call envelope ``{"type": "tool_call", "tool_calls": [...]}``. | ||
|
|
||
| This module is the single, dependency-neutral source of truth for telling |
There was a problem hiding this comment.
N5 / R0-D4 (prior residual/new design occurrence). This new module says it is the single structural source and accepts content-only or unknown-tag dicts with string content as text, but VisionCore still uses its private normalizer at vision_tool.py:894,1127, which rejects those same shapes. A supported custom BaseLLM can therefore produce text for unwrap_chat_text/default streaming while understand_media or detect_objects reports an unsupported shape. Make the VisionCore normalizer delegate structural decoding to classify_chat_response while retaining only caller-specific tool extraction and bounded diagnostics. I re-checked the author’s FIXED claim in reply 3879010019 and context 5450122491; the decoder remains divergent.
There was a problem hiding this comment.
FIXED in 8963525. _normalize_vision_response now delegates structural kind/text decoding to classify_chat_response and keeps only vision-side policy: tool_calls payload extraction, empty-text recovery for its NamedTuple contract, and the bounded raw_display diagnostics. Content-only and unknown-tag string envelopes classify as text everywhere now — two rows added to the vision shape matrix (content_only_dict, unknown_tag_str_content) proving the delegation in production. The existing matrix (12 rows) passes unchanged.
| type=ChunkType.TOKEN, | ||
| content=result, | ||
| delta=result, | ||
| content=shape.text, |
There was a problem hiding this comment.
R1-06 (new, text envelope). The default stream projects a valid usage-bearing text envelope into a TOKEN containing only content/delta; raw and usage are discarded. PatternRuntime accepts usage only from a USAGE chunk, so a supported minimal/custom BaseLLM that inherits this default loses billed token/cache metadata when streamed, despite direct chat() returning it. Preserve raw=result and emit a ChunkType.USAGE chunk carrying the envelope usage so runtime reconstruction remains lossless.
There was a problem hiding this comment.
FIXED in 8963525. The text TOKEN chunk now carries raw=result, and a usage-bearing envelope additionally emits a ChunkType.USAGE chunk with the payload. test_usage_bearing_text_envelope_emits_usage_chunk pins chunk order and payloads, and test_usage_chunk_feeds_runtime_extraction feeds the chunks through PatternRuntime._chunk_usage to prove the runtime reads them. Mutation check: suppressing the USAGE chunk turns the first red.
| ) | ||
| else: | ||
| # tool_call format | ||
| elif shape.kind == "tool_call": |
There was a problem hiding this comment.
R1-06 (new, tool envelope). The tool-call branch preserves raw for direct consumers but emits no USAGE chunk. Runtime ignores usage attached to TOOL_CALL chunks, so a valid tool-call envelope with billed usage is reconstructed without token totals or the full provider payload. Emit the same normalized USAGE chunk for tool envelopes when usage is present, while keeping the tool-call chunk for calls themselves.
There was a problem hiding this comment.
FIXED in 8963525. The tool-call branch (already raw-preserving) now also emits the USAGE chunk when the envelope carries usage — test_usage_bearing_tool_call_envelope_emits_usage_chunk.
| -> dict envelope with fields: | ||
| - "type": "tool_call" | ||
| - "tool_calls": list of tool call objects | ||
| - "raw": the full response JSON |
There was a problem hiding this comment.
R1-07 (new). This tool-call field list presents raw as required, but the supported Gemini function-call envelope at gemini.py:660-666 contains type, tool_calls, and optional usage without raw. A caller following this public contract can KeyError or reject a valid Gemini response. Mark raw provider-dependent/optional (and align the vision_chat() wording); callers must not require it.
There was a problem hiding this comment.
FIXED in 8963525. The tool-call bullet now says raw is optional/provider-dependent (Gemini's envelope omits it) and callers must not require it — same caveat as the text branch, and the vision_chat wording was already aligned.
…cope R1-01/R1-02 (blocking): every surfaced OpenAI exception now carries the superseded billed attempts (shared attach_usage_attempts helper), and the envelope merge no longer suppresses a one-element known history when the final success is unmetered -- the final attempt stays unmeasured rather than promoting a prior payload into usage R1-03 (blocking): normalize_deepseek_response preserves usage_attempts across the protocol-error rebuild, verified through on_llm_end R1-05: Zhipu's non-retryable blank-response RuntimeError carries the booked usage payload and the outer wrap preserves it (retry policy unchanged, xorbitsai#1714 problem 2 untouched) R1-06: the default stream_chat keeps raw on the content chunk and emits a USAGE chunk for usage-bearing envelopes, so the documented chat-to- stream adaptation stays billing-complete R1-04: cache metrics share the aggregate scope of token totals -- summed over every billed attempt on end events and extracted on error events R1-07: tool_call envelope docs mark raw as optional/provider-dependent R0-D4: _normalize_vision_response delegates structural kind/text decoding to the shared classify_chat_response, keeping only vision-side tool_calls extraction and bounded raw_display diagnostics N3 residuals: nested cache aliases now go through the strict _coerce_usage_int (invalid/zero never shadows later fallbacks), and wholly unusable usage_attempts rows are dropped before both booking and trace counting so the two views stay in lockstep, with a valid top-level fallback when no attempt row is usable Also fixes the mypy regressions from the previous round (setattr carriers, isinstance narrowing, exception variable typing) and the retry->model import cycle introduced by the shared helpers (lazy import with a comment)
|
Round-2 findings addressed, head Blocking
Non-blocking
Also fixed: the mypy regressions from the previous round that broke pre-commit CI (5 errors: Verification: every finding above has a named regression test that is red without the fix (mutation-checked by actually reverting each line and watching the test fail — six mutations verified this round). Local: 1723 passed, 0 failed (same 2 pre-existing environment failures as before, red on clean main); mypy clean on all touched files; |
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR fixes two real bugs sharing a root cause: BaseLLM.chat() has a documented-but-unenforced str | dict[str, Any] return contract that every consumer re-derives independently. #520's token-usage extractor only read top-level usage, missing adapters that nest it under raw or return bare strings; #1714's problem 1 had three callers turning tool-call envelopes into repr() via response.get("content", str(response)). The PR adds shared classify_chat_response/unwrap_chat_text helpers, stamps usage at the adapter boundary across OpenAI/Claude/Gemini/Zhipu/Xinference, and (scope-expanded mid-review) adds usage_attempts tracking to preserve billed-but-superseded usage across retries.
Round 0 approach verdict: acceptable-with-reservations. Right direction, but executed at roughly 4x the necessary surface area for the two source issues, and stops one step short of the change that would actually make the contract hold — the str | dict union is still open after this PR (see D1), so every consumer still needs to know how to unwrap both shapes; the new helpers are additive, not a replacement for ad hoc branching.
Blocking: yes — recommended event: REQUEST_CHANGES
Correctness findings
Blocking
C1 — RetryWrapper drops billed usage_attempts on a non-retryable error following a billed retryable one.
src/xagent/core/retry/wrapper.py:65-68 (sync) and :101-105 (async): except Exception as e: if not self.retry_on(e): raise short-circuits before _collect_usage_attempts/attach_usage_attempts run. If attempt 1 raises a billed retryable error (e.g. LLMEmptyContentError carrying usage_attempts=[...]) and attempt 2 raises a non-retryable error, the final exception loses attempt 1's already-billed tokens — under-reporting real billed usage in ExecutionContext.llm_calls/the error trace. This is new code introduced by this PR. See inline comment.
C2 — OpenRouter's internal retry loops discard usage_attempts from caught exceptions.
src/xagent/core/model/chat/basic/openrouter.py:607-633 (_chat_with_prefix_retry) and :741-766 (_run_chat_with_compat_retry) both catch RuntimeError/BadRequestError — which openai.py now attaches usage_attempts to via attach_usage_attempts — and retry/replace the response without ever reading exc.usage_attempts. The compat loop can issue up to 4 iterations / 8 upstream requests per logical call per its own docstring; every superseded billed request's usage is silently lost. See inline comment.
C3 — ReAct's dict-envelope fallback can leak an internal repr into the user-visible chat transcript, and this PR widens the trigger surface.
src/xagent/core/agent/pattern/react/react.py:949: response=assistant_content or normalized.get("raw"), where normalized["raw"] is the whole envelope dict when assistant_content is falsy. This flows unmodified into src/xagent/web/services/execution_result_projection.py:41 and src/xagent/web/api/websocket.py:3642, both of which stringify it directly into what the user sees as the assistant's answer. Concretely reachable trigger: unwrap_final_answer_content (src/xagent/core/agent/pattern/react/result.py:88-105, called at react.py:1832) — a model emitting the legacy JSON final-answer format with an empty action_input has non-empty raw text (passes adapter empty-checks) but unwraps to "". Pre-PR this fell back to the original raw string; this PR converts Zhipu/Gemini/Claude's text-path returns from str to {"type": "text", "content": ...} envelope dicts, so the fallback now resurfaces str({"type": "text", "content": "..."}) — a dict-repr leak into the user's answer. Pre-existing OpenAI-only latent bug, genuinely widened by this PR to Zhipu/Gemini/Claude's primary chat path. See inline comment.
Non-blocking (follow-up recommended)
C4 — default stream_chat's tool_protocol_error handling drops raw.
src/xagent/core/model/chat/basic/base.py:394-408: a tool_protocol_error_response() envelope (empty content) classifies as kind=="empty" and yields an ERROR chunk with no raw, losing the structured payload react.py:1293/:1446's get_tool_protocol_error(normalized.get("raw")) expects. Not reachable today — every adapter that can produce this envelope overrides stream_chat and never falls through to this default. Latent risk for a future/custom BaseLLM subclass.
C5 — _extract_attempt_rows drops all-zero-token attempt rows, undercounting llm_attempt_count.
src/xagent/core/agent/runtime.py (_extract_attempt_rows) drops rows where both prompt/completion tokens coerce to 0, so llm_attempt_count can under-report real upstream request counts for a genuinely-billed all-zero-token attempt (rare, provider-bug-adjacent). Already a documented, intentional tradeoff; observability-only, no billing impact.
C6 — the async-generator retry path (_retry_generator) has zero usage_attempts accounting.
src/xagent/core/retry/wrapper.py's generator-based retry path (used for retried stream_chat, live via adapter.py:168's retry_methods={"chat","vision_chat","stream_chat"}) has no _collect_usage_attempts/attach_usage_attempts anywhere. Currently inert (no adapter yet attaches usage_attempts to an exception raised mid-stream), so no live data loss today — but it's the third of three retry surfaces and the only one left completely unaddressed by this PR's stated "billed attempts across retries" feature.
D7 — error handling: new exceptions surface as generic 500s; Gemini loses exception type fidelity on retryable errors.
src/xagent/web/api/agents.py:582,596-598: both new exception types (including the transient/retryable LLMEmptyContentError) become an HTTP 500 with str(e) echoed to the client, rather than a more semantically-correct 502/503. Separately, src/xagent/core/model/chat/basic/gemini.py:685-719 lacks the isinstance(e, LLMRetryableError): raise short-circuit present at claude.py:783, so LLMEmptyContentError/LLMInvalidResponseError get silently re-wrapped into a plain RuntimeError. Retry behavior is unaffected (via __cause__), and no current caller does except LLMEmptyContentError, so practical impact is low today — flagging as a latent inconsistency.
Design-level discussion (non-blocking, please consider before merge)
D1 — the str | dict[str, Any] contract is still open after this PR. src/xagent/core/model/chat/basic/base.py:207 still returns str | dict[str, Any], and the docstring still notes legacy adapters may return a plain string. No consumer's code got narrower. Also, classify_chat_response's type discriminator is not load-bearing on the text-classification branch (src/xagent/core/model/chat/response_shape.py:44-47): any dict with string content classifies as text regardless of its type tag. Several pre-existing consumers (runtime.py:544, runtime.py:1635, react.py:1810, dag.py:1677) remain untouched, still doing their own raw isinstance branching outside the new shared classifier.
D2 — the new adapter usage-stamping contract is convention-only and already violated in-tree. src/xagent/core/model/chat/basic/xinference.py (_process_chat_response) never stamps a top-level usage key; it only "works" because runtime.py's _resolve_usage_payload falls back to reading raw. Nothing enforces the "stamp at the adapter boundary" pattern — no type, no base-class template method, no cross-adapter contract test (the repo has this exact pattern for a different contract already: tests/core/model/chat/basic/test_provider_state_stripping.py's BaseLLM.__subclasses__() sweep, not reused here).
D3 — usage-payload construction is duplicated across 7 sites (~70 lines), with real internal divergence. Independently hand-built usage dicts in claude.py, gemini.py (non-stream and stream paths, which disagree with each other on the cache-value type guard), zhipu.py (chat/vision_chat, byte-for-byte identical ~16-line blocks — confirmed copy-paste), plus openai.py's stream-fallback literal sites. Only Claude stamps cache_write_input_tokens; Zhipu routes cache extraction through a shared extract_cached_input_tokens helper that Claude/Gemini bypass; OpenAI's envelope never gets a normalized cached_input_tokens key at all. This is already-manifesting drift, not hypothetical duplication — worth consolidating into one shared builder.
D4 — two different integer-coercion policies apply to the same provider-reported fields and can diverge on the same call. token_context._coerce_int (lenient) vs. the new runtime._coerce_usage_int (strict, returns None on malformed input instead of coercing). Confirmed applied to the same raw payload for the same call (openai.py:504-515). A malformed-but-plausible value like prompt_tokens: 10.7 yields different accounting conclusions in the two ledgers. Please reconcile or document why divergence is acceptable.
D5 — usage_attempts is significant scope creep introduced mid-review, not requested by either source issue. Neither #520 nor #1714 asked for tracking billed attempts across retries; the dedicated test file's own docstring says this was added in response to review feedback during this PR's cycle. Footprint: ~260-300 lines of production code (~8% of the 3462-line diff) plus a 771-line test file (~30% of total diff with tests counted). It has clean removable seams. Recommend splitting into a follow-up PR — this also makes C1/C2/C6 (all within this specific feature) independently fixable without blocking the core #520/#1714 correctness fixes.
D6 — some hand-written unwrap call sites remain unmigrated, but the #520 fix itself is not undermined. src/xagent/core/agent/context/execution.py:1420-1432 (_compact_response_text) and runtime.py:543-555/:1634-1643 (_response_content, _short_response) remain hand-rolled with str(response) fallbacks. The #520 usage-tracking gap is already fixed on this path — runtime.py:1339-1344 calls on_llm_end(...) before compact_with_llm_response runs. What remains is a narrower, PR-disclosed text-repr hygiene gap: _compact_response_text's fallback is untested against a tool_call/non-dict envelope, and _response_content is live in stream_final_answer (reachable from react.py:709, auto.py:208), carrying the same repr-leak risk class as C3 via a different unwrap site.
D8 — Zhipu's new contract tests never exercise their own production model_dump() branch. tests/core/agent/test_compact_llm_usage_contract.py uses SimpleNamespace stand-ins for Zhipu; zhipu.py:373-375/:1045-1046 branch on response.model_dump() if hasattr(...) else str(response), and SimpleNamespace has no model_dump, so every Zhipu test in this file takes the str(response) fallback — confirmed dead in production, since the real zai-sdk Completion is a pydantic model that always has model_dump().
Simplification opportunities
Lean already — the candidate over-engineering findings (e.g. the _resolve_usage_payload raw-fallback, the generic usage_attempts carrier functions) were checked and dropped: each rested on a "single caller/one adapter" premise that turned out false on verification (the raw-fallback is load-bearing for Xinference; the carrier functions are used generically across all adapters via RetryWrapper, not just one).
Blocking status & recommended decision
Blocking: yes — recommended event: REQUEST_CHANGES
src/xagent/core/retry/wrapper.py:67, Major, non-retryable error following a billed retryable one silently drops the first attempt's billedusage_attempts, under-reporting real billed usage, [new]src/xagent/core/model/chat/basic/openrouter.py:607, Major, internal retry/compat loops catch exceptions carryingusage_attemptswithout reading them, silently dropping up to several superseded billed requests' usage per call, [new]src/xagent/core/agent/pattern/react/react.py:949, Major, dict-envelope fallback can leak a Python dict repr into the user-visible chat transcript; this PR widens the trigger from OpenAI-only to Zhipu/Gemini/Claude's primary chat path, [new]
| return self.target.invoke(*args, **kwargs) | ||
| result = self.target.invoke(*args, **kwargs) | ||
| except Exception as e: | ||
| if not self.retry_on(e): |
There was a problem hiding this comment.
Major — drops billed usage_attempts on a non-retryable error following a billed retryable one.
if not self.retry_on(e): raise short-circuits before _collect_usage_attempts/attach_usage_attempts run (same issue in the async variant around line 103). Scenario: attempt 1 raises a billed retryable error (e.g. LLMEmptyContentError carrying usage_attempts=[...]), attempt 2 raises a non-retryable error. The final exception loses attempt 1's already-billed tokens, so ExecutionContext.llm_calls/the error trace under-report real billed usage.
This is new code introduced by this PR (the base commit's wrapper had no usage_attempts handling at all). Please collect/attach accumulated usage attempts before re-raising on the non-retryable branch, mirroring what happens on final-attempt exhaustion.
There was a problem hiding this comment.
FIXED in b56de52, on both the sync and async paths: the not retry_on(e) short-circuit now attaches collected_attempts + the terminal error's own payload to the terminal exception before raising, so a billed retryable attempt followed by a non-retryable failure is never dropped. Regression: test_retry_wrapper_non_retryable_terminal_carries_collected_attempts (flaky inner LLM — first call raises billed LLMEmptyContentError, second raises a plain RuntimeError — asserts the surfaced error carries the first attempt and that on_llm_error books it). Mutation check: reverting both branches turns it red.
|
Thanks for the careful work here — the investigation, follow-through on review feedback, and regression coverage are genuinely valuable. At this point, though, the remaining merge blockers appear to come mostly from the expanded retry-attempt accounting and the broader response-contract cleanup rather than from #520 itself. The current diff now spans several independently reviewable concerns, and fixes in one area keep exposing new interaction boundaries in another. Would you be open to splitting this into a focused PR for #520 first? I would suggest keeping the smallest coherent set that fully fixes that issue: provider usage stamping / raw usage extraction, the directly required compact-path and freshness handling, and representative end-to-end regression coverage. If changing a provider response shape requires a direct consumer fix, that should of course stay with the focused PR. The following could then move to separate follow-ups:
This is not a request to discard the additional work. The broader improvements are useful and much of the implementation and test work can be preserved. The goal is to get the core #520 fix merged with a smaller correctness surface, while allowing the retry-accounting and response-contract changes to be reviewed on their own invariants. A fresh focused branch from current |
C1 (blocking): RetryWrapper's non-retryable short-circuit now carries the collected billed-attempt history onto the terminal exception, on both sync and async paths C2 (blocking): OpenRouter's internal retry surfaces preserve billed attempts -- the DeepSeek prefix retry folds the rejected attempt into the eventual envelope or the terminal error, and the provider-compat loop collects per-iteration payloads, merges them into the success envelope, and carries them on every re-raise path C3 (blocking): the ReAct finalize fallback no longer passes the raw response envelope downstream -- it surfaces the envelope's usable text via the shared classifier, so an empty-unwrapping final answer cannot leak an internal dict repr into the user-visible transcript (a latent OpenAI-family bug this PR had widened to Zhipu/Gemini/Claude) D2: Xinference's _process_chat_response now stamps usage on every envelope branch instead of relying on the raw fallback D8: Zhipu contract coverage now exercises the production model_dump() branch for tool_call envelopes (the stand-in is pydantic-shaped, as the real zai-sdk response is) C4: the default stream_chat's ERROR chunk keeps raw=result so a tool-protocol-error envelope's structured payload is not dropped D7 (gemini half): retryable errors re-raise as-is instead of being re-wrapped into a plain RuntimeError, matching the claude.py contract Every fix has a named regression test verified red by reverting the fix (six mutations checked this round)
|
Round-3 findings addressed, head Blocking
Non-blocking
Design discussions
Local: 1733 passed, 0 failed (same 2 pre-existing env failures, red on clean main); mypy clean on touched files; ruff clean. cc @rogercloud |
|
Yes — done. The focused #520 PR is #2138, cut fresh from current Everything else stays in #1787, which I'm converting to draft as the reference implementation for the follow-ups (usage_attempts / retry accounting, remaining #1714 cleanup, vision + default-stream convergence). Thanks for the patient reviews — the split is much better this way. |
Fixes #520. Refs #1714 (problem 1 only).
Summary
chat()has no normalized response contract, and every consumer hand-parses its own subset. This PR stamps provider usage at the adapter boundary soPatternRuntime's token extraction works for real adapter shapes, teaches the usage extractors to fall back one level underraw, and converges the three hand-written text-unwrapping call sites onto one helper that neverrepr()s a tool_call envelope.Root cause
on_llm_end→_extract_token_usage→record_llm_usageis the only path populatingExecutionContext.llm_calls, and the extractor only recognized top-levelusage/usage_metadata. Today that shape is produced solely by the streaming reconstruction inrun_streaming_llm_call— and by the FakeLLM test double, which is why the gap stayed invisible. Real non-streaming responses differ: OpenAI/DeepSeek nest usage underraw; Zhipu/Gemini/Claude text paths return bare strings whose usage only reaches the contextvar ledger. The same missing contract causes #1714 problem 1 from the consumer side: callers keying oncontentturn a tool_call envelope into itsrepr(), and the compaction path reports that as a successful compaction.What changed
_response_usage_payload()snapshot inOpenAICompatibleLLMstamps top-levelusageon all six chat/vision return sites (inherited by DeepSeek/DashScope/OpenRouter/Azure); Zhipu/Gemini/Claude text paths return the{type, content, usage}envelope the streaming path already emits; tool_call envelopes are stamped too (including Gemini's, which carries noraw);normalize_deepseek_response's rebuild path preserves the stamp.add_token_usagecall sites are untouched — the contextvar ledger is still written exactly once per call, by the adapter; the runtime stays read-only against it._resolve_usage_payload()shared by_extract_token_usageand_extract_cached_tokens(same top-level-only gap) — top level first, then one level underraw, fail-open on unknown shapes.unwrap_chat_text()(agent/utils/llm_utils.py) raises instead ofstr(response):LLMNoTextContentError(new,model/chat/exceptions.py) for non-text shapes,LLMEmptyContentErrorfor empty/whitespace envelope content — matching the adapters' own empty-generation class.ContextBuilder._compact_individual_dependency/_compact_dependency_messagesandoptimize_instructionsmigrated onto it. Compaction now falls back to truncation explicitly; the endpoint returns an error instead of 200 + repr.cached_input_tokens(andcache_write_input_tokensfor Claude) when non-zero, so_extract_cached_tokenssees prompt-cache hits on non-streaming calls; comparisons are type-guarded so an unexpected provider value can never raise out ofchat(). (Review findings, fixed in d2f0f16.)understand_mediadidstr(result)on non-str results anddetect_objectsstringified the envelope before parsing, dropping the detections. The envelope change widened this existing landmine (Zhipu vision now returns envelopes too), so both are fixed here with red-first regression tests. (7efc2a2)synthetic_purposeand are excluded from the context-freshness baseline (_latest_freshness_baseline_call), so a failed LLM compaction can no longer suppress the truncation fallback; the field round-trips through checkpoints.usagestays the final attempt (freshness baseline);usage_attemptslists every billed attempt of a logical call, set only when >1. Adapters attach booked payloads to retryable errors and collect superseded internal retries;RetryWrappermerges across outer retries;on_llm_endbooks each attempt in order (final last) and traces billing totals +llm_attempt_count+ final-attempt numbers;on_llm_errorcovers the all-failed path._coerce_usage_intrejects bools, non-finite/negative/non-integral values and falls through to later aliases/candidates.stream_chatdiscriminates text/tool_call/legacy-string/unknown via the shared classifier (unknown → explicit ERROR chunk).model/chat/response_shape.classify_chat_responseis the single shape source forunwrap_chat_textand the defaultstream_chat. Reconciled with fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's vision normalizer on rebase:detect_objectsalready failed explicitly there;understand_medianow also fails explicitly on tool_call envelopes (94b3be9; flips a test fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721 deliberately preserved — flagged in the review thread).Intentional contract change
Zhipu/Gemini/Claude
chat()text paths now return envelope dicts instead of bare strings. Consumer audit (grep -rn "\.chat(" src/):_normalize_llm_response,_response_content,_short_response,_compact_response_text, and DAG/Auto via ReAct normalization all already duck-typestr | dict. The affected adapter tests were updated to the new contract.Test plan
tests/core/agent/test_compact_llm_usage_contract.py(36 tests): per adapter family the response is built with real SDK types (e.g.ChatCompletionwith a populatedCompletionUsage), only the SDK client is patched, and the real adapter is driven through the real compact path — asserting theaction_end_llm(purpose=context_compaction) token fields,get_total_token_usage(), and the tracecached_input_tokensend to end. Includes a no-double-counting guard (contextvar ledger andllm_callseach record exactly once), vision/reasoning-branch stamp coverage, extractor edge shapes (stringraw, top-levelusage_metadata, all-zero usage), the checkpoint round-trip ofllm_calls, the record-before-rewrite ordering contract behindestimate_context_tokens(), and Callers of chat() stringify tool_call envelopes, and providers disagree on whitespace-only content #1714 regressions (tool_call or empty-content envelope fed to compaction → explicit truncation fallback, no repr anywhere).d1e2ca1): 1705 passed, 0 failed across agent / model.chat / retry / vision / web suites (2 pre-existing environment failures intest_executor.py/test_browser_tools.py, red on a clean main checkout too — they need browser/workspace env).ruff check/ruff format --checkclean on touched files.rawfallback, each adapter stamp incl. vision/reasoning branches, cache-metric stamping, both exception branches ofunwrap_chat_text, the record/rewrite ordering — swappingon_llm_endaftercompact_with_llm_responsereds the estimate test — and bothvision_toolfixes, which were red before the fix by construction).Known boundaries / out of scope
BaseLLM's defaultstream_chattreats any dict as a tool_call envelope; no current adapter triggers it. Worth its own issue — happy to file.vision_chat's empty-content early return is now an empty-text envelope — behavior-parity with the old""for its consumers, and raising would lose the retryable classification inside that method'sexcept Exception → RuntimeErrorwrapper.cc @rogercloud