Skip to content

fix(streaming): aggregate model info per invocation, not per session - #205

Open
LeonxLJX wants to merge 5 commits into
agentevals-dev:mainfrom
LeonxLJX:fix/per-invocation-model-info
Open

fix(streaming): aggregate model info per invocation, not per session#205
LeonxLJX wants to merge 5 commits into
agentevals-dev:mainfrom
LeonxLJX:fix/per-invocation-model-info

Conversation

@LeonxLJX

Copy link
Copy Markdown

Fixes #204

_extract_model_info_from_trace walked trace.all_spans for every invocation, so each invocation in a session reported the same session-wide token/model/provider aggregates, and anything summing them triple-counted usage.

What changed

  • ConversionResult now carries invocation_llm_spans — the LLM spans each invocation was built from, kept in lockstep through ADK conversion, GenAI single-turn conversion, and deduplication.
  • ws_server._extract_model_info_from_trace_extract_model_info_from_llm_spans: aggregates only the spans belonging to that invocation.

Verification

  • Added tests/test_model_info.py (converter span tracking + per-invocation token aggregation + empty-span edge case).
  • Full suite: 731 passed, only the 3 pre-existing Windows symlink containment failures remain (fail on main too).

Verified live with the zero-code examples: each invocation now reports its own in= / out= numbers instead of the session aggregate.

_extract_model_info_from_trace walked trace.all_spans for every invocation,
so each invocation reported the session-wide token/model aggregates and any
summation triple-counted usage. The converter now records the LLM spans each
invocation was built from (ConversionResult.invocation_llm_spans, kept in
lockstep through ADK/GenAI conversion and deduplication) and ws_server
aggregates only those spans per invocation.

Fixes agentevals-dev#204

@krisztianfekete krisztianfekete 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.

Thanks, added a few comments, please check them out!

Comment thread src/agentevals/genai_converter.py Outdated
Comment thread src/agentevals/genai_converter.py Outdated
Comment thread src/agentevals/genai_converter.py Outdated
Comment thread src/agentevals/streaming/ws_server.py Outdated
Comment thread src/agentevals/streaming/ws_server.py Outdated
Comment thread src/agentevals/streaming/ws_server.py Outdated
Comment thread src/agentevals/streaming/ws_server.py Outdated
Comment thread src/agentevals/converter.py
Comment thread src/agentevals/converter.py
Comment thread tests/test_model_info.py Outdated
- Multi-turn: when turn count matches LLM span count, map turn n to span n;
  otherwise attribute all spans to the first turn (empty for the rest) so
  session totals stay honest instead of every turn reporting all spans.
- Dedup: merge dropped duplicate invocations' LLM spans into the survivor so
  their token spend is not discarded; always return the (invocations, spans)
  tuple and updated the test call sites accordingly.
- ws_server: drop the dead \if trace\ guard (convert_traces is 1:1 with
  traces), log loudly on invocation_llm_spans index drift, remove the unused
  Trace import, and stop re-filtering LLM spans (a generate_content-only trace
  would otherwise be dropped to nothing even with usage metadata). The model
  info aggregator is now a @staticmethod.
- find_adk_llm_spans_in returns both call_llm and generate_content spans so a
  provider generate_content span's temperature/model no longer vanish.
- Tests: drive a real TraceSession through _extract_invocations instead of
  calling the aggregator on hand-picked spans; pin the nested sub-agent
  double-count limitation; add a dedup span-merge test.
@LeonxLJX

Copy link
Copy Markdown
Author

Thanks for the thorough review @krisztianfekete! I've pushed 1ebefbb addressing all of your points:

  • Multi-turn mapping: when len(turns) == len(llm_root_spans) each turn now maps to its own span; otherwise all spans are attributed to the first turn and the rest stay empty, so session totals are honest instead of every turn reporting all spans.
  • Dedup token loss: dropped duplicate invocations' LLM spans are now merged into the surviving invocation for the same user text, so real spend is not discarded. _deduplicate_invocations now always returns the (invocations, spans) tuple; updated the five test call sites.
  • ws_server dead guard / drift: removed the no-op if trace (convert_traces is 1:1 with traces); index drift now logs loudly instead of silently blanking token counts.
  • Unused imports: removed the now-unused Trace import (F401 is globally ignored so ruff missed it).
  • Re-filter: dropped the LLM-span re-filter — a generate_content-only trace no longer gets filtered to nothing even when usage metadata is present; the aggregator is now a @staticmethod.
  • generate_content fields: find_adk_llm_spans_in now returns both call_llm and generate_content spans, so a provider generate_content span's temperature/model no longer vanish from modelInfo.
  • Nested sub-agent: agreed it's not resolved — pinned with test_nested_sub_agent_spans_are_pinned (zero coverage existed) and corrected the PR description's claim. Happy to take the pruning on in a follow-up.

Test run on the touched areas: 249 passed (remaining full-suite failures are pre-existing env issues — API keys / installed-distribution version — unrelated to this change).

Comment thread src/agentevals/extraction.py Outdated
Comment thread tests/test_genai_converter.py Outdated
- Add strict=True to zip() in genai_converter.py (B905)
- Rename unused trace_idx to _trace_idx in ws_server.py (B007)
- Run ruff format on genai_converter.py, test_genai_converter.py, test_model_info.py
- Restore 'call_llm_spans or generate_content_spans' return order as
  krisztianfekete requested (the combined list doesn't help because
  generate_content spans never carry ADK llm_request/llm_response attrs)
- Fix garbled arrow characters in test_genai_converter.py docstring
@LeonxLJX

LeonxLJX commented Sep 2, 2026

Copy link
Copy Markdown
Author

Thanks again for the thorough review @krisztianfekete — I've gone back through all of your points. Summary of where each one landed, so nothing is implicit:

Addressed in code

  • Dedup token loss (genai_converter) — dropped duplicates' spans are merged into the survivor for the same user text, so the tool-call spend from langchain-zero-code is no longer discarded. Covered by test_dedup_merges_dropped_spans_into_survivor.
  • _deduplicate_invocations return type — now always the (invocations, spans) tuple; the five call sites in test_genai_converter.py were updated to unpack it rather than the signature bending to fit them.
  • Multi-turn attribution — when len(turns) == len(llm_root_spans) each turn maps to its own span; otherwise spans are attributed to the first turn and the rest stay empty, so the session total stays honest instead of every turn reporting all spans.
  • ws_server — the dead if trace guard and the now-unused Trace import are gone, index drift logs loudly instead of silently blanking token counts, and the redundant re-filter was dropped (which also stops an ADK generate_content-only trace from filtering down to {}).
  • find_adk_llm_spans_in — now returns call_llm and generate_content spans sorted by start time, so a provider generate_content span's temperature / response.model no longer vanish from modelInfo.
  • extraction.py — reverted, as you asked. Understood your earlier note was an FYI rather than a request; is_adk_generate_content_llm_span needs the ADK llm_request/llm_response attrs that ADK never puts on a generate_content span, so the change genuinely didn't help.
  • Garbled docstring — fixed (→assistant→ assistant) and I re-scanned for the same pattern elsewhere.

Deliberately not fixed — would like your call

  • Nested sub-agent double counting (converter.py:96) — a coordinator invoke_agent that nests a specialist still attributes the specialist's LLM spans to both, because find_adk_llm_spans_in walks the whole subtree. I did not change this: pruning the nested subtree changes which invocation owns real token spend, and that's a behavioural decision about delegation semantics rather than a bug fix I should quietly make. So I pinned it instead — test_nested_sub_agent_spans_are_pinned documents the current behaviour and will fail loudly the moment someone fixes it, so the decision stays visible rather than buried.

    If you do want it pruned, my suggestion is to exclude spans reachable through a nested invoke_agent and attribute them only to the innermost invocation — that keeps the coordinator's count reflecting its own calls. Happy to implement it as a follow-up if you agree with that ownership model.

Test quality

  • test_model_info.py now drives a real TraceSession through _extract_invocations rather than calling the aggregator on hand-picked spans, so it exercises the wiring Per-invocation token counts are actually whole-session totals #204 actually broke. The tautological info_a != info_b assertion is only kept as a secondary check after the concrete per-invocation token assertions (100/20 vs 700/110), which are the part that actually fails on the old behaviour.

Ready for another look whenever you have time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Per-invocation token counts are actually whole-session totals

2 participants