Repository navigation
feat(agent): record where each step's time went in the trace - #4067
Conversation
A benchmark run took 2,916 s against a reference agent's 557 s, and the trace could not say why: each step's stats record carried token counts but no timing, so the breakdown had to be rebuilt from Lemonade's server log. Each step's stats record now also carries step_seconds, llm_seconds, tool_seconds and overhead_seconds (the remainder, so the three always add up to the step), ttft_seconds for streamed calls, finish_reason, reasoning_tokens, output_tok_per_s, the number of model calls the step made (retries and continuations), and per-tool seconds. process_query's result gains timing_summary: run totals plus the three slowest steps and why. Times come from the agent's own clock and tokens from each response's own usage, never /stats, so the figures hold for cloud models. A cloud stream now asks for usage, so a streamed cloud step gets a stats record at all.
Approve with suggestionsThis adds a per-step time breakdown to the agent trace — how long the model took, how long the tools took, and what's left over — plus a run summary naming the three slowest steps. The design is sound: overhead is computed as the remainder so the parts always reconcile against the step's wall clock, and tokens come from each response's own usage rather than Lemonade's server-global The one thing worth settling before merge: to get token counts out of a streamed cloud call, the PR starts sending a new field on the outgoing request to Lemonade. That's a contract with a service outside this repo, and it's currently only verified against a mocked SDK — the test proves we send the field, not that Lemonade accepts it. The real trace output in the description is from the non-streamed path. Streaming is the default path for chat and the UI, so a rejected request there would break cloud chat outright rather than just lose a metric. One live run against a cloud model with streaming on, pasted into the description, closes it. This is the same shape as the #1655 regression the project docs call out, which is why it's worth the extra minute rather than a shrug. Everything else is small: one timing call sits outside the error guard its neighbour uses, and the eval's per-step duration still reports zero even though the real number is now sitting on the same record. Real-world evidenceNo The description does carry genuine evidence for part of the change: a real step-1 stats record captured against a live cloud model ( 🔍 Technical details🟡 ImportantNew outgoing request field verified only against a mock (
Blast radius if the field is rejected: Ask: one live streamed turn against a cloud model, with the resulting stats record pasted into the description alongside the non-streamed one already there. (Local streaming is unaffected — the field is gated on Worth noting the downstream shape change is handled correctly: the usage-bearing final chunk arrives with 🟢 Minor1. Step timing call sits outside the "never displace a tool error" guard ( The 2. The eval's per-step duration is still hardcoded to zero (
3. "always add up" is a rounding-strength claim ( Each of the four values is independently Strengths
|
…real step durations Three loose ends in the step-timing trace. A failure inside the new timing call could replace a tool's own result or error, because it sat outside the try/except its neighbour uses for exactly that reason. Eval scorecards kept reporting every step as 0ms even though the agent now measures the real wall time on the same record. And both the spec and the module docstring promised the three parts "always" sum to the step total, which independent rounding of each value cannot guarantee.
The trace's three parts were each rounded independently, so reading them off a step could leave the reader to explain a ~1e-4 discrepancy against the step total. Deriving overhead from the already-rounded parts makes the reported numbers reconcile exactly, which is what the spec claimed in the first place; the claim is restored rather than softened, and the loop test asserts equality instead of a 1e-3 tolerance. The run summary is built the same way and gets the same treatment, so a reader is not told one rule that holds per step and another per run.
|
Two of the four findings were already fixed on this branch before I got here — the timer no longer displaces a tool's result, and eval scorecards now carry the real step duration instead of reporting every step as 0ms. I verified both against the current head rather than re-applying them. The rounding finding I took further than the suggestion. The branch had softened the docs to "give or take rounding"; instead I made the claim true. Overhead is now the remainder of the already-rounded parts, so the three numbers a reader sees add back to the step total exactly, and the test asserts equality rather than a tolerance. Same treatment for the run summary, so there aren't two different rules to remember. Still outstanding: the live streamed turn against a cloud model. No Lemonade server is reachable from here, so I did not run it and have not faked evidence for it. What I can say without the live run is that the new field is standard and correctly placed. 🔍 Technical detailsAlready fixed on head (
Rounding — made exact instead of reworded. In r_wall = round(wall, _ROUND)
r_llm = round(llm, _ROUND)
r_tools = round(tools, _ROUND)
...
"overhead_seconds": round(r_wall - r_llm - r_tools, _ROUND),The module docstring and On The reason a live turn still matters: acceptance is not uniform across OpenAI-compatible backends. vLLM, llama.cpp's Tests run: |
# Conflicts: # src/gaia/agents/base/agent.py
|
Merged 🔍 Technical details
|
# Conflicts: # src/gaia/llm/providers/lemonade.py
|
Merged
I checked the result against both parents rather than trusting the auto-merge, and it comes out exact in both directions: the branch still adds precisely its 11 files at +1069/-49, and 🔍 Technical detailsThe conflict: Auto-merged files verified by hand, both directions:
rerere is enabled locally but recorded a preimage rather than replaying a cached resolution, so nothing was resolved without review. Tests — 396 passed across Four |
# Conflicts: # src/gaia/llm/providers/claude.py
main shipped this branch's one contested change while it was open. amd#3739 added `stream_options: {"include_usage": true}` to every stream, not just cloud ones, which retires the review's blocking ask: the field is already in production and already exercised against a live local backend. Two things the auto-merge got wrong without conflicting: - **`_capture_usage` was defined twice in `LemonadeProvider`.** main's `(usage, timings)` version silently shadowed the branch's `(response)` one, so the branch's two call sites were passing the wrong shape. main's is the richer of the two (it adds `cached_tokens`), so it stays — but it read reasoning tokens only from `completion_tokens_details`, dropping the `output_tokens_details` fallback the branch's `_reasoning_tokens` helper handled. The helper is wired back in, so neither shape is lost. - **`stream_options` was set twice** on the streaming request — main's unconditional one, then the branch's cloud-only one overwriting it with the same value. The dead cloud gate is gone. `test_local_stream_request_is_unchanged` asserted local streams never carry the field. amd#3739 deliberately decided otherwise, so the test now pins that contract instead of the branch's older cloud-only one. `step_timer.finish()` moved below main's new step-cap completion call, so that call's seconds are counted rather than falling outside the run summary.
|
The blocking ask on this PR — "prove Lemonade accepts Merging main in was not clean, though, and neither problem raised a conflict marker. 🔍 Technical detailsThe shadowed method. main's Found by AST-walking every class in the tree for duplicate method names, not by reading the diff. The duplicate field. main sets
Tests: |
On a coding benchmark GAIA took 2,916 s against Claude Code's 557 s, and GAIA's own trace couldn't say why. Each step recorded token counts but no timing, so the answer (long reasoning bursts at ~50 tok/s) had to be rebuilt from Lemonade's server log. Now every step's stats record says how long the model, the tools and everything else took, with a time to first token, a finish reason, reasoning tokens and a decode rate. The result dict also gains a run summary that names the three slowest steps and why they were slow.
/stats, so the numbers are correct for cloud models too.🔍 Technical details
Real non-streamed step 1 against
fireworks.deepseek-v4-flash-0731. The existing keys (type,step,performance_stats) are unchanged:{"type": "stats", "step": 1, "performance_stats": {"prompt_tokens": 472, "completion_tokens": 69, "total_tokens": 541, "reasoning_tokens": 9}, "step_seconds": 1.1254, "llm_seconds": 1.1238, "tool_seconds": 0.0002, "overhead_seconds": 0.0014, "ttft_seconds": null, "finish_reason": "tool_calls", "reasoning_tokens": 9, "output_tok_per_s": 61.4, "llm_calls": 1, "tools": [{"name": "add", "seconds": 0.0002}]}overhead_secondsis whatever is left over, so the three parts always add up tostep_seconds. The record is filled in when the step closes, because the step keeps running after the record is appended.AgentSDKtimes every request, including failed and cancelled ones, and passes it to a per-turn sink. Only calls made on the loop thread are counted. An LLM call made inside a tool body is already part of that tool's time.output_tok_per_s: for a streamed call it is the tokens after the first divided by the time after the first token. A non-streamed call has no first-token time, so the rate is taken over the whole request and is a lower bound.ttft_secondsis recorded by the provider at the first content, reasoning or tool-call fragment. Tool-call-only streams yield nothing until the end, so timing the first chunk the SDK receives would report the whole request as ttft.get_last_finish_reason()has the same name and meaning as in the cut-off-continuation branch, which keeps the merge simple./statsinperformance_stats, because that carries the ttft the UI shows.stream_options.include_usageis sent only to cloud models, so the request to local backends (llama.cpp, FLM) is unchanged.Test plan
python -m pytest tests/unit/agents/test_step_timing.py -q: 17 tests covering field types, the parts summing to the step (fake clock and a real loop), ordering of the slowest steps, SDK sink contents, Lemonade usage, ttft and finish reason, and the cloud-onlyinclude_usagerequestpython -m pytest tests/unit tests/test_sdk.py -qshows no new failures againstmainstreaming=Falseand one withstreaming=True. Both should show the new fields on each stats record andtiming_summaryin the result