Skip to content

fix: stamp provider usage on chat() responses and converge text unwrapping - #1787

Draft
Q1hangL wants to merge 7 commits into
xorbitsai:mainfrom
Q1hangL:fix/chat-response-usage-contract
Draft

fix: stamp provider usage on chat() responses and converge text unwrapping#1787
Q1hangL wants to merge 7 commits into
xorbitsai:mainfrom
Q1hangL:fix/chat-response-usage-contract

Conversation

@Q1hangL

@Q1hangL Q1hangL commented Aug 26, 2026

Copy link
Copy Markdown

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 so PatternRuntime's token extraction works for real adapter shapes, teaches the usage extractors to fall back one level under raw, and converges the three hand-written text-unwrapping call sites onto one helper that never repr()s a tool_call envelope.

Root cause

on_llm_end_extract_token_usagerecord_llm_usage is the only path populating ExecutionContext.llm_calls, and the extractor only recognized top-level usage/usage_metadata. Today that shape is produced solely by the streaming reconstruction in run_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 under raw; 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 on content turn a tool_call envelope into its repr(), and the compaction path reports that as a successful compaction.

What changed

  • Adapters: a _response_usage_payload() snapshot in OpenAICompatibleLLM stamps top-level usage on 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 no raw); normalize_deepseek_response's rebuild path preserves the stamp. add_token_usage call sites are untouched — the contextvar ledger is still written exactly once per call, by the adapter; the runtime stays read-only against it.
  • Runtime: _resolve_usage_payload() shared by _extract_token_usage and _extract_cached_tokens (same top-level-only gap) — top level first, then one level under raw, fail-open on unknown shapes.
  • Unwrap: new unwrap_chat_text() (agent/utils/llm_utils.py) raises instead of str(response): LLMNoTextContentError (new, model/chat/exceptions.py) for non-text shapes, LLMEmptyContentError for empty/whitespace envelope content — matching the adapters' own empty-generation class. ContextBuilder._compact_individual_dependency / _compact_dependency_messages and optimize_instructions migrated onto it. Compaction now falls back to truncation explicitly; the endpoint returns an error instead of 200 + repr.
  • Cache metrics: the Claude/Gemini/Zhipu stamps carry cached_input_tokens (and cache_write_input_tokens for Claude) when non-zero, so _extract_cached_tokens sees prompt-cache hits on non-streaming calls; comparisons are type-guarded so an unexpected provider value can never raise out of chat(). (Review findings, fixed in d2f0f16.)
  • vision_tool repr leaks (pre-existing, same class as Callers of chat() stringify tool_call envelopes, and providers disagree on whitespace-only content #1714): understand_media did str(result) on non-str results and detect_objects stringified 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)
  • Compaction freshness isolation (review N1): usage records of internal calls carry synthetic_purpose and 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.
  • First-class billed attempts (review N2): usage stays the final attempt (freshness baseline); usage_attempts lists every billed attempt of a logical call, set only when >1. Adapters attach booked payloads to retryable errors and collect superseded internal retries; RetryWrapper merges across outer retries; on_llm_end books each attempt in order (final last) and traces billing totals + llm_attempt_count + final-attempt numbers; on_llm_error covers the all-failed path.
  • Strict usage coercion (review N3): _coerce_usage_int rejects bools, non-finite/negative/non-integral values and falls through to later aliases/candidates.
  • BaseLLM contract + default stream boundary (review N4): docs describe the envelope contract; the default stream_chat discriminates text/tool_call/legacy-string/unknown via the shared classifier (unknown → explicit ERROR chunk).
  • Shared structural classifier (review N5): model/chat/response_shape.classify_chat_response is the single shape source for unwrap_chat_text and the default stream_chat. Reconciled with fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's vision normalizer on rebase: detect_objects already failed explicitly there; understand_media now 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-type str | 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. ChatCompletion with a populated CompletionUsage), only the SDK client is patched, and the real adapter is driven through the real compact path — asserting the action_end_llm (purpose=context_compaction) token fields, get_total_token_usage(), and the trace cached_input_tokens end to end. Includes a no-double-counting guard (contextvar ledger and llm_calls each record exactly once), vision/reasoning-branch stamp coverage, extractor edge shapes (string raw, top-level usage_metadata, all-zero usage), the checkpoint round-trip of llm_calls, the record-before-rewrite ordering contract behind estimate_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).
  • Local runs (after rebase onto d1e2ca1): 1705 passed, 0 failed across agent / model.chat / retry / vision / web suites (2 pre-existing environment failures in test_executor.py / test_browser_tools.py, red on a clean main checkout too — they need browser/workspace env). ruff check / ruff format --check clean on touched files.
  • Mutation-verified: every change was reverted line-by-line and the expected test went red (extractor raw fallback, each adapter stamp incl. vision/reasoning branches, cache-metric stamping, both exception branches of unwrap_chat_text, the record/rewrite ordering — swapping on_llm_end after compact_with_llm_response reds the estimate test — and both vision_tool fixes, which were red before the fix by construction).

Known boundaries / out of scope

  • Pre-existing, deliberately not fixed here: BaseLLM's default stream_chat treats any dict as a tool_call envelope; no current adapter triggers it. Worth its own issue — happy to file.
  • Zhipu/Gemini contract fixtures use field-accurate stand-ins rather than real SDK types (offline construction of zai-sdk / google-genai responses is unreliable); noted in the test module docstring.
  • Callers of chat() stringify tool_call envelopes, and providers disagree on whitespace-only content #1714 problem 2 (whitespace-only content / exception-type divergence across providers) is not addressed here. Evaluated and deliberately left alone: Zhipu 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's except Exception → RuntimeError wrapper.
  • Considered and set aside: a full response-envelope migration (disproportionate); measuring usage via a contextvar-ledger diff (a naive before/after misattributes tokens under concurrent DAG steps; the scoped-rebind variant adds more mechanism than this needs).
  • Happy to split the unwrap-helper commits into a stacked PR if you'd rather keep this one observability-only.

cc @rogercloud

@XprobeBot XprobeBot added the bug Something isn't working label Aug 26, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/xagent/core/model/chat/basic/claude.py
Comment thread src/xagent/core/model/chat/basic/gemini.py
Comment thread src/xagent/core/model/chat/basic/zhipu.py
Comment thread src/xagent/core/model/chat/basic/zhipu.py
Comment thread src/xagent/core/agent/utils/llm_utils.py
Q1hangL added a commit to Q1hangL/xagent that referenced this pull request Aug 26, 2026
…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
@Q1hangL

Q1hangL commented Aug 26, 2026

Copy link
Copy Markdown
Author

Third-pass update pushed (7efc2a2) — a self-review round in the spirit of "what would a behavior-level reviewer catch here", plus the expanded coverage it produced:

One real bug found and fixed. The envelope change widens a pre-existing repr leak of the same class as #1714: vision_tool.understand_media did str(result) on any non-str result, and detect_objects stringified the envelope before parsing (dropping the detections). Both paths predate this PR (OpenAI-family vision_chat already returned dicts); the contract change makes Zhipu vision hit them too. Fixed with red-first regression tests in test_vision_tool.py.

Coverage added (contract file 15 → 36 tests):

  • Cached tokens asserted end to end: a real ChatCompletion with PromptTokensDetails(cached_tokens=6) through the compact path lands cached_input_tokens == 6 on the action_end_llm trace event — not just on the stamp.
  • vision_chat stamp tests for OpenAI and Zhipu; the reasoning-truncation early-return branch in OpenAICompatibleLLM is stamped too.
  • Extractor edge shapes: raw as a plain string (Zhipu's tool_call fallback shape), top-level usage_metadata, all-zero usage — all fail open.
  • Two invariants pinned: llm_calls survives the checkpoint to_dict/from_dict round-trip; and estimate_context_tokens() keeps working after compaction — including the implicit ordering contract that on_llm_end records before compact_with_llm_response rewrites messages. Swapping that order turns the test red (verified, M15b).
  • Empty-content envelopes now exercise the LLMEmptyContentError branch through both the ContextBuilder truncation fallback and the optimize_instructions 500.

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 rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:1072Severity: 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) whose prompt_tokens is below that threshold.
  • Affected contract: compact_context_if_needed must fall back to truncating the live context when summarization produces no usable text, while still retaining the compact call's usage exactly once in ExecutionContext and the trace.
  • Impact: on_llm_end records the compact prompt while the live messages are still unchanged. compact_with_llm_response then leaves those messages unchanged, but the fallback _get_total_tokens can 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_end before compact_with_llm_response at src/xagent/core/agent/runtime.py:1188-1202, followed by compact_if_needed at src/xagent/core/agent/runtime.py:1220-1223; the freshness fingerprint and reuse are in src/xagent/core/agent/context/execution.py:716-742,1430-1449. The newly visible stamped tool-call path is src/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, carry request["original_tokens"] into the fallback or recompute the live estimate there. Add stamped tool-call and empty-text regressions with prompt_tokens <= threshold and max_messages exceeded, 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:478Severity: 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_response and 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 ExecutionContext and action_end_llm; if all attempts fail, none reaches those surfaces. The task ledger can correctly contain both attempts while ExecutionContext and 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-489 records the first response before content validation, and the structured-output retry replaces it at src/xagent/core/model/chat/basic/openai.py:623-666; the empty-content failure is at src/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. PatternRuntime emits one end record only after the returned call at src/xagent/core/agent/runtime.py:1021-1048, while the monitoring totals scan those end events at src/xagent/core/monitor.py:433-480. Existing retry fixtures use usage=None for 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:1075Severity: 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-372Severity: 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:13Severity: 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:

  • N1src/xagent/core/agent/runtime.py:1072, major: stamped compaction usage can suppress fallback truncation and send oversized history onward. [new]
  • N2src/xagent/core/model/chat/basic/openai.py:478, major: billed retry attempts disappear from ExecutionContext/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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in 59e9506, making billed attempts first-class exactly as you suggested — no ledger diffing anywhere:

  • Contract: usage stays the final attempt (the context-freshness baseline); usage_attempts is 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's RuntimeError wrap forwards the attribute since the wrapper already classifies through __cause__.
  • Wrapper: RetryWrapper.invoke/ainvoke collect 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_end books each attempt in order (final last, so llm_calls[-1] remains the freshness baseline), and the end trace reports billing totals plus llm_attempt_count and final_prompt_tokens/final_output_tokens — matching the monitor's per-end-event summation. on_llm_error books exception-carried attempts so the all-failed case reaches ExecutionContext too.

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.

Comment thread src/xagent/core/agent/runtime.py Outdated
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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FIXED in 59e9506, then reconciled with #1721 in 94b3be9 — full picture:

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.
@Q1hangL
Q1hangL force-pushed the fix/chat-response-usage-contract branch from 7efc2a2 to 94b3be9 Compare August 28, 2026 08:11
@Q1hangL

Q1hangL commented Aug 28, 2026

Copy link
Copy Markdown
Author

Round addressing the CHANGES_REQUESTED review is pushed — rebased onto current main (d1e2ca1), head 94b3be9.

Dispositions

  • N1 (major) — FIXED in 59e9506: synthetic usage records (synthetic_purpose) are excluded from the context-freshness baseline; a failed LLM compaction can no longer suppress truncation. Your exact scenario (oversized history + stamped tool_call compact response below threshold) is a passing regression test.
  • N2 (major) — FIXED in 59e9506: billed attempts are first-class via exception-carried and envelope-carried usage_attempts (final attempt keeps the freshness role; the key exists only when >1 attempt was billed); the retry wrapper merges across outer retries; on_llm_end books every attempt in order and traces billing totals + llm_attempt_count + final-attempt numbers; on_llm_error covers the all-failed path. No ledger diffing.
  • N3 (minor) — FIXED in 59e9506: strict count coercion (no bools, non-finite, negatives, non-integral floats; invalid candidates fall through).
  • N4 (minor, body-only) — FIXED in 59e9506: BaseLLM docs now describe the envelope contract (including the raw-presence caveat from fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's wording), and the default stream_chat discriminates text/tool_call/legacy-string/unknown via the shared classifier — unknown shapes yield an explicit ERROR chunk, not a silent empty tool-call chunk. Contract test added (test_base_stream_contract.py, minimal subclass inheriting the default).
  • N5 (minor) — FIXED in 59e9506 + reconciled with fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721 in 94b3be9: shared classifier in model/chat/response_shape; fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721's vision normalizer kept (it already fails explicitly in detect_objects); understand_media aligned to explicit failure on tool_call. Note the flagged contradiction: fix(vision-tool): parse text-envelope responses instead of reporting empty success #1721 had deliberately preserved the tool_call answer message, and N5 asks for failure — I implemented N5 and flipped that test; easy to revert if you disagree.

Verification: local run — 1705 passed, 0 failed (2 pre-existing environment failures in test_executor.py/test_browser_tools.py, confirmed red on a clean checkout of main as well; they need browser/workspace env). ruff check / ruff format --check clean. CI will need a fresh workflow approval after the force-push. cc @rogercloud

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-1185 consumes and emits it.
  • H2 — empty-envelope unwrapping: FIXED. Sources: review 5033197773, root 3865088015, and reply 3865212969. Current llm_utils.py:37-50 maps empty/whitespace text envelopes to LLMEmptyContentError and non-text/tool/unknown shapes to LLMNoTextContentError; both compaction callers fall back and optimize_instructions surfaces 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 LLMRetryableError propagation 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) lose superseded_attempts, while R1-02 singleton collected history is suppressed by the len > 1 gates (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_int and candidate fall-through in runtime.py:1063-1116,1187-1214 reject malformed direct values without crashing. Two separately confirmed current residuals remain: nested raw cache aliases still use permissive coercion at runtime.py:1177-1184, and malformed usage_attempts rows still make trace count and ExecutionContext.llm_calls diverge at runtime.py:1127-1139 (also on error); the streaming _merge_usage malformed-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-401 uses 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,1127 still 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-753 and src/xagent/core/retry/wrapper.py:48major — generic structured-retry failures and final-unmetered success can drop known billed attempts from ExecutionContext and trace totals; [prior] N2 (R1-01/R1-02 residuals).
  • src/xagent/core/model/chat/basic/deepseek_tool_protocol.py:59major — protocol-error response rebuilding drops usage_attempts and 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/xagent/core/retry/wrapper.py Outdated
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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/xagent/core/agent/runtime.py Outdated
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")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
@Q1hangL

Q1hangL commented Aug 31, 2026

Copy link
Copy Markdown
Author

Round-2 findings addressed, head 8963525 (on top of the rebase).

Blocking

  • N2 residuals (R1-01/R1-02) — FIXED: every surfaced OpenAI exception (the five SDK wraps + catch-all) carries superseded_attempts via a new shared attach_usage_attempts helper; the envelope merge (now the shared merge_usage_attempts_into_result, used by both the retry wrapper and the OpenAI internal merge) keeps a non-empty known history even when it has one element, and never promotes a prior payload into usage.
  • R1-03 — FIXED: the DeepSeek protocol-error rebuild preserves usage_attempts, verified through on_llm_end.

Non-blocking

  • R1-05 — FIXED: Zhipu's non-retryable blank-response error carries the booked payload, and the outer wrap forwards it. Retry policy unchanged (Callers of chat() stringify tool_call envelopes, and providers disagree on whitespace-only content #1714 problem 2 untouched).
  • R1-06 — FIXED: the default stream_chat keeps raw on the content chunk and emits a ChunkType.USAGE chunk for usage-bearing envelopes (text and tool_call), verified through PatternRuntime._chunk_usage. Note: PatternRuntime still bypasses the inherited default via _has_native_stream_chat; this fix is for the documented extension boundary.
  • R1-04 — FIXED: cache metrics use the same aggregate scope as token totals on both end and error events.
  • R1-07 — FIXED: tool-call docs mark raw as optional/provider-dependent.
  • N5 / R0-D4 — FIXED: _normalize_vision_response delegates structural decoding to classify_chat_response, keeping only tool_calls extraction and bounded diagnostics; content-only / unknown-tag string envelopes are now text everywhere, with two new matrix rows proving the delegation.
  • N3 residuals — FIXED: nested cache aliases go through strict coercion (invalid/zero never shadows fallbacks), and unusable usage_attempts rows are dropped before booking and trace counting so the two views stay in lockstep, with a valid top-level fallback.

Also fixed: the mypy regressions from the previous round that broke pre-commit CI (5 errors: setattr carriers, isinstance narrowing, exception variable typing), plus a retry -> model import cycle introduced by the shared helpers (now lazily imported with an explanatory comment).

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; ruff check / ruff format clean. cc @rogercloud

@rogercloud rogercloud left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 billed usage_attempts, under-reporting real billed usage, [new]
  • src/xagent/core/model/chat/basic/openrouter.py:607, Major, internal retry/compat loops catch exceptions carrying usage_attempts without 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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rogercloud

Copy link
Copy Markdown
Collaborator

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 main, with this PR retained as a reference or converted to draft, may be the cleanest path.

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)
@Q1hangL

Q1hangL commented Aug 31, 2026

Copy link
Copy Markdown
Author

Round-3 findings addressed, head b56de52. (Only one inline thread came through, so the rest are answered here; each fix has a named regression test that is red with the fix reverted — six mutations verified this round.)

Blocking

  • C1 — FIXED: RetryWrapper's non-retryable short-circuit now carries the collected history onto the terminal exception (sync + async). See the inline reply for details.
  • C2 — FIXED: OpenRouter's internal retry surfaces now preserve billed attempts. The DeepSeek prefix retry folds the rejected attempt's payload into the eventual envelope (or onto a terminal error); the provider-compat loop collects per-iteration payloads, merges them into the success envelope, and carries collected + own on every re-raise path (LLMRetryableError, retry_on, no-adjustment). Tests: test_openrouter_prefix_retry_preserves_billed_attempts, test_openrouter_compat_loop_preserves_billed_attempts, test_openrouter_compat_loop_terminal_error_carries_billed_attempts.
  • C3 — FIXED: the ReAct finalize fallback surfaces the raw envelope's usable text via classify_chat_response instead of passing the envelope itself downstream — your exact trigger (legacy JSON final-answer with empty action_input) now produces the same plain text it did before envelopes, never a dict repr. Test: test_react_finalize_fallback_never_leaks_envelope_repr (asserts the finalized output is the raw JSON text and contains no 'type': 'text').

Non-blocking

  • C4 — FIXED: the default stream_chat's ERROR chunk now keeps raw=result, so a tool-protocol-error envelope's structured payload survives.
  • C5 — acknowledged, intentional and documented in _extract_attempt_rows's docstring (trace count must stay in lockstep with booked records); left as-is.
  • C6 — documented boundary: the async-generator retry path has no attempt accounting because no adapter raises mid-stream with billed usage today; noted as a follow-up invariant to add when a mid-stream billing adapter appears, rather than building speculative plumbing now.
  • D7 — half fixed: Gemini now re-raises LLMRetryableError as-is (same contract as claude.py), with a fidelity regression test. The HTTP status mapping in optimize_instructions (500 vs 502/503) I deliberately left unchanged — it alters the public API's error contract and deserves its own PR/issue.
  • D2 — FIXED: Xinference stamps usage on all four envelope branches (tests for text/tool_call/no-usage).
  • D8 — FIXED: the Zhipu contract stand-in is now pydantic-shaped (model_dump()), so the production raw branch is exercised instead of the str() fallback.

Design discussions

  • D5 (split usage_attempts) — fair point, and noted: it grew from N2 in round 1 rather than from the source issues. The seam is clean (exceptions.py carrier attr/helpers, wrapper.py merge, adapter attach sites, runtime.py attempt rows + trace fields, test_usage_attempts_contract.py). Since it has now been through two review rounds and every path is tested, my slight preference is to keep it in this PR — but if you'd rather merge the core first, I'll split it into a stacked follow-up within a day. Your call.
  • D1 — agreed the union stays open; this PR deliberately converges consumers onto one classifier before any contract tightening. Closing the union (legacy str removal) would be the natural follow-up once classify_chat_response is the only consumer-side decoder.
  • D3 — agreed; consolidating the per-adapter payload builders into one shared helper is the right next step, left out of this PR to avoid another expansion.
  • D4 — the divergence is intentional: token_context._coerce_int feeds billing (never lose a plausible number), _coerce_usage_int feeds observability traces (never show a malformed number as truth). Added to the PR description; happy to codify it as a comment pair instead.
  • D6 — noted; _compact_response_text/_response_content migration to the shared classifier is a small follow-up, kept out to stop this PR growing further.

Local: 1733 passed, 0 failed (same 2 pre-existing env failures, red on clean main); mypy clean on touched files; ruff clean. cc @rogercloud

@Q1hangL

Q1hangL commented Sep 5, 2026

Copy link
Copy Markdown
Author

Yes — done. The focused #520 PR is #2138, cut fresh from current main with exactly the set you outlined: provider usage stamping / raw usage extraction, the compact-path freshness handling (synthetic_purpose on compaction records so a failed LLM compaction can't suppress the truncation fallback), the consumer fixes the envelope change requires, and the representative end-to-end regression coverage. 1707 passed locally, key fixes mutation-verified on the new branch.

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.

@Q1hangL
Q1hangL marked this pull request as draft September 5, 2026 07:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: compact LLM token usage for real provider responses

3 participants