feat(tui): show where a turn's time went, not just how long it took - #3024
feat(tui): show where a turn's time went, not just how long it took#3024kovtcharov wants to merge 4 commits into
Conversation
The TUI's per-turn line reported elapsed time, ttft, steps and tools — none of which explain why a local turn is slow. The dominant cost is prefill: the system prompt and tool schemas are re-sent on every LLM call, and at the ~390 tok/s this machine prefills at, prompt size converts almost directly to seconds. That number was never measured, let alone shown. Adds an opt-in per-turn record (GAIA_TURN_LOG) carrying fixed prefill size, cached-vs-new input tokens per call, output tokens, the wall-time split across model/tools/overhead, and absolute timestamps. It rides the existing `answer` event under a `metrics` key and reaches the TUI through the canonical translator, so the Go client and the HTTP surface both get it. With the var unset the wire payload is byte-identical to before. The record names the model the turn's requests actually went to (AgentSDK.effective_model), not the configured local id — under --use-claude those differ.
|
Verdict: Approve with suggestions This adds an opt-in per-turn performance record — where a turn's seconds went, how big the fixed prefill is, and how much of the input the KV cache could reuse — written to a JSONL file and drawn under the answer in One thing to fix before merge: the Agent UI SSE contract spec still lists the five fields a The rest are small and optional:
Real-world evidenceNo For the record, the surface here is the TUI's 🔍 Technical detailsIssues🟡 The canonical SSE contract spec doesn't know about The spec enumerates the 🟢 Non-streaming close fetches
🟢 Tool rows with no matching backend call are dropped from the breakdown (
🟢 The cancel branch seals the record (so the JSONL line is written) but returns before publishing, so a cancelled turn's breakdown never reaches a console. Probably intended — cancel emits no 🟢 Dev docs don't say the log captures prompt text (
Strengths
|
|
Verdict: Approve Clean implementation of opt-in per-turn performance recording. The zero-overhead default (recorder never constructed unless The test suite is thorough where it matters — the negative cases (an ordinary turn's payload is byte-identical to before, a stashed record doesn't leak without the env var) and the unhappy paths (abandoned streams, nested tool calls counted once, raising turns that still seal their record) are all covered. 🔍 Technical detailsOne observation worth noting for future readers: The Neither of these is a blocker. |
Unit tests and the lint gate both failed on this branch. The coercion test read the source of `_execute_tool` looking for `_coerce_tool_args`. Timing moved the dispatch body into `_execute_tool_impl`, so the string it wanted was one function away and the assertion failed even though coercion still runs before every tool. It now reads the impl and asserts the delegation, so splitting the method again fails loudly instead of quietly testing the wrong function. `query`, `agent_name` and `model_id` were passed to TurnRecorder twice — once to the constructor, which ignored all three, and again to finish(). That is what pylint reported as unused arguments, but the duplication was the real problem: `_recorded_model_id()` was resolved a second time at the end of the turn and could disagree with the model the turn actually ran on. The recorder now holds them from construction and finish() takes only what is genuinely new. Also drops an `import time` the module-level import made redundant, gives `_tool_timing_depth` a class-level default beside the other per-instance attributes, and marks the optional `print_turn_metrics` hook's unused argument as deliberate.
A trailing pylint pragma pushed the def past the line limit, and black will not move a comment to fix it.
|
Verdict: Approve Per-turn performance recording is a well-designed opt-in developer feature. Several things stand out as correctly handled: the gate on Test coverage is unusually thorough: cancelled streams, nested tools, raising tools, broken console hooks, stale-record-into-next-turn, and the ordering invariant (seal before One 🟢 nit on the Go side: 🟡 Real-world evidence: The 🔍 Technical detailsFloat vs int token fields in Go ( // current
OutputTokensServer float64 `json:"output_tokens_server"`
// suggestion
OutputTokensServer int `json:"output_tokens_server"`Same for Evidence gap (conditional): If the PR description already includes a Everything else reviewed and clean:
|
…md#3026) > **Stacked branch.** Cut from the shared `integration/full-tui` base, so the diff against `main` also shows 17 commits already covered by amd#3022-amd#3024. This change is 7 files: `src/gaia/llm/providers/claude.py`, `src/gaia/agents/base/turn_metrics.py`, `tui/internal/event/canonical.go`, `tui/internal/ui/chat/turnmetrics.go` (+ test), and the two unit-test files. It depends on amd#3024 for the turn-metrics plumbing. Every LLM call the Claude backend made re-sent the same ~13,700 tokens of system prompt and tool schemas at full price — Anthropic prompt caching is opt-in, and nothing in the provider ever asked for it. A ReAct turn is 2-5 calls, so the largest single cost in the product was being paid over and over for bytes that never changed. This turns it on, and fixes the usage parsing that would otherwise have reported the win as zero: the provider read only `input_tokens` and `output_tokens`, never the cache counters, so the `--dev` metrics line printed `0 cached` whether caching worked or not. Measured on `claude-haiku-4-5` through the flagship TUI: a cold turn writes 13,696 tokens to the cache, and **every turn after it reads them back — 96-98% of input served from cache, on every step of a multi-step turn**. Cached input bills at ~0.1x, so the repeated prefix now costs about a tenth of what it did. The cache also survives a TUI restart within its TTL, so relaunching costs nothing. This is complementary to the open token-reduction work (amd#3007 prefill, amd#3008 dynamic tools, the gaia-voice trim): those shrink the prefix, this stops paying for it on every repeat. If both land the effects multiply rather than add — a smaller prefix, charged at a tenth, once instead of per call. <details> <summary>🔍 Technical details</summary> **Two breakpoints, not one.** Anthropic renders `tools` → `system` → `messages`, so a marker on the system block covers the whole fixed prefill. A second marker at the end of the tools segment is there because caching gives no partial credit: with only the system marker, one byte of drift anywhere in the system prompt would discard the tool schemas too. Block-level markers rather than top-level `cache_control=` on `messages.create()` — top-level auto-placement marks the *last* cacheable block, which in an agent loop is the newest tool result, so every request would write a fresh entry and read almost nothing. **`input_tokens` is the uncached remainder, not the prompt size.** `prompt_tokens` now sums it with the cache reads and writes; left alone, a working cache would have read as a prompt that shrank by 98%. The streaming path takes the counters off `message_start`, the only event that carries them. **Metrics source.** The turn record and the `--dev` block prefer the backend's own cache accounting wherever it reports any (a cold turn that only *wrote* counts, so turn 1 and turn 2 are on the same scale). Lemonade reports none, so the local prefix estimate still drives that display unchanged — the two sources are recorded separately and never summed. **Prefix-stability audit — what still limits the hit rate.** Caching is a prefix match, so anything volatile ahead of a breakpoint caps the ceiling. Clean: no timestamp, uuid, session id, pid, or cwd anywhere in the system prompt or tool descriptions (the one `datetime.now()` is already correctly prepended to the *user* message, `memory.py:2114` — do not "fix" it into the system prompt). Outstanding, in severity order, none addressed here: 1. `dynamic_tools=True` with a 26-slot LRU cap over a ~66-tool registry (`hub/agents/gaia/python/gaia_agent/agent.py:162,171`) rewrites the `tools` array per turn once at the cap. Tools render first, so an eviction invalidates everything. Worth considering pinning off under `--use-claude`: a cached full registry at 0.1x beats an uncached rotating subset at 1.0x. 2. `indexed_docs_section` is volatile but sits ~600 tokens from the top of the system prompt, above ~2,500 tokens of static rules (`gaia_agent_chat/profiles.py:141`, `agent.py:769-842`). Indexing one document invalidates everything below it. Moving it to `VOLATILE_PROMPT_FRAGMENTS` helps llama.cpp too. 3. The memory block prints `(confidence: 0.87)` per fact (`memory.py:2044`), and every `recall` bumps confidence by 0.02 and can reorder facts — a guaranteed per-turn byte change for no model benefit. 4. Splitting `system` into a static head and volatile tail with a breakpoint between would salvage the static ~2,500 tokens from all of the above. The measured run above hit 96-98% because that session took the full-registry path, so the tools array was byte-stable. </details> ## Test plan - [ ] `pytest tests/unit/test_claude_provider.py tests/unit/test_turn_metrics.py -q` — 54 tests, including breakpoint placement on the outgoing request, no mutation of the caller's tool list, cache-field parsing on both the streaming and non-streaming paths, and an older SDK response with no cache fields. - [ ] `cd tui && go test ./internal/ui/chat/ ./internal/event/` - [ ] Full `pytest tests/unit -q` was run and diffed against the unmodified base commit: **no new failures** (605 vs 610 pre-existing environmental failures — this branch has 5 fewer, none related). - [ ] Live, two identical consecutive turns: ``` GAIA_TURN_LOG=/tmp/turns.jsonl gaia run gaia --dev --use-claude --claude-model claude-haiku-4-5 ``` Before / after `cache_read_input_tokens`, straight from the turn log: | | prompt_tokens | cache_read | cache_write | |---|---:|---:|---:| | before (any turn) | 14,034 | **0** | 0 | | after, turn 1 (cold) | 14,034 | 0 | 13,696 | | after, turn 2 (identical) | 14,057 | **13,696** | 0 | | after, 2-step tool turn | 28,403 | **27,392** (both steps) | 0 | **Not run: `gaia eval agent`.** This touches an LLM-affecting surface, so CLAUDE.md requires it, and it has not been run — Lemonade is currently banned on this machine (the user's PC crashes), and a Claude-backed run is not a valid substitute for a local-model baseline. It remains outstanding and should be run before merge on a machine where Lemonade is available. Nothing here changes prompt *text*, tool schemas, or the tool-call envelope — only where the cache breakpoints sit and how usage is parsed — but that is an argument for expecting it to pass, not evidence that it did. --------- Co-authored-by: Ovtcharov <kovtchar@amd.com> Co-authored-by: kovtcharov-amd <kalin.ovtcharov@amd.com>
|
Closing as superseded: the per-turn timing breakdown is already live on main (landed via #3026) and now carries more fields than this PR did — the one genuine remainder, a turn log that records the local model id under |
The TUI already tells you a turn took 34 seconds. It has never been able to tell you why, and the reason is a number nothing measured: the system prompt plus tool schemas re-sent on every single LLM call. On this machine Lemonade prefills at roughly 390 tok/s, so a 17k fixed prefill is ~44 seconds of every turn before the model writes a word — invisible behind an elapsed-time figure that looks like the model is just slow. This adds an opt-in per-turn record that measures it, and shows it.
What this adds beyond today's stats line
Today
--devprints12.0s · ttft 11.9s · 1 steps. Every one of those describes the outcome; none decomposes it. New:… · 1 steps · 17.0k prefill. One number, and it is usually the answer.prefill tok/snext to it shows which.GAIA_TURN_LOG), append-only, so a session is greppable and two builds are diffable.With
GAIA_TURN_LOGunset none of this exists: the recorder is never constructed and the wire payload is byte-identical to before (asserted by ajson.dumpsequality test including key order).gaia eval agentThis touches
src/gaia/agents/base/agent.py, so CLAUDE.md requires an agent eval before merge. It has not been run. Lemonade is banned on the machine this was developed on (it crashes the host), and no query was sent to any local backend during this work.The change is structurally low-risk for LLM behaviour — it adds no prompt text, changes no tool schema, and touches no prompt-assembly code — but that is an argument, not an eval. Someone with a working Lemonade box needs to run
gaia eval agentagainst the committed baseline before this merges.🔍 Bug found while instrumenting, NOT fixed here
In a Claude session the agent logs
[PARSE] tool_call_path=native model_id=Gemma-4-E4B-it-GGUFwhile every request goes toapi.anthropic.com.Agent.model_idkeeps the configured local id under--use-claude; the substitution happens later, inAgentSDK.effective_model.That stale id feeds
is_tool_calling_model(), which selects the tool-call parsing strategy — so on Claude it currently picks Gemma's strategy and happens to work. That is luck, not design, and it will break the first time the two strategies diverge.I fixed only the part inside this change: the turn record and the
model idline above reportAgentSDK.effective_model, the model actually used. The parsing-strategy selection is untouched and still wrong — it reaches into call sites this PR has no business editing, and deserves its own issue.Conflicts with other open PRs
tui/internal/ui/chat/model.go,tui/internal/ui/chat/canonical.go— also touched by fix(tui): stop --use-claude from starting Lemonade, and name the Claude model #3005 (model picker) and feat(tui): lay out to the real terminal width #3006 (responsive width). Branched frommain@ 9c89cb4 and not rebased. My edits here are small and additive: two lines appended insideanswerStats, one call added inrenderMessage, one struct field set in theCanonicalFinalEventcase. Whoever lands second should get a clean textual merge; if not, mine is the hunk to re-apply by hand.src/gaia/agents/base/agent.py— four PRs on this file (fix(agent): bill tool-approval time to the human, not the tool #3007, feat(skills): let a gh write ask instead of fail #3009, feat(skills): load the right skill without the user knowing its name #3010, this one). Functions I touch, so the reviewer can sequence them:process_query(wrapped its body intry/finally),_execute_tool(now a timing wrapper; its original body moved verbatim to a new_execute_tool_impl),_process_query_impl(four added lines), and four new methods that nothing else calls —_begin_turn_record,_finish_turn_record,_publish_turn_metrics,_recorded_model_id. I did not touch_compose_system_prompt,_get_mixin_prompts, or any prompt text._execute_tool→_execute_tool_implrename is the one thing that can break a caller outside this diff. Nothing calls the impl directly; one test (test_skill_binary_grants.py) cherry-picks base methods onto a stub agent and needed the new name added, which is included here.Test plan
go build ./... && go vet ./... && go test ./...undertui/— green (integration tier included)pytest tests/unit/test_turn_metrics_wire.py— 19 passtests/unitas a whole has ~600 failures onmainon this machine that are unrelated to this change.gaia eval agent— NOT RUN, see above. Blocks merge.GAIA_TURN_LOG=~/.gaia/turns.jsonl gaia --dev, ask anything, confirm the block appears andjq . ~/.gaia/turns.jsonlparsesGAIA_TURN_LOGand check the footnote is exactly what it wasReviewer notes
Four timing defects were found in review and fixed here, each with a test that fails without its fix (verified by reintroducing the defect): a cancelled turn abandoned its open LLM call and charged those seconds to overhead;
_process_query_implre-raises on the wrong-ctx reload and left the recorder attached to fold the retry's calls into the abandoned turn; every non-streaming call charged its/statsHTTP round-trip to model time; and nested tool calls (CodeAgent's orchestration does this) were timed twice while a tool that raised recorded asok: true.The cached/new token counts come from a client-side estimator (
tiktokenwhen installed), not the model's own tokenizer — they are honest as ratios and deltas, not as exact model token counts.outis the backend's own number. This is stated in the docs and in the module docstring rather than left for someone to discover.src/gaia/agents/base/turn_metrics.pyswallows its own failures by design — a metrics bug must never be able to fail a user's turn. That exception is documented in the module docstring and is deliberately narrow: it covers recording only, never the agent's own work.