Skip to content

fix(agent): bill tool-approval time to the human, not the tool - #3007

Merged
kovtcharov-amd merged 6 commits into
amd:mainfrom
kovtcharov:claudia/task-25e62f25
Aug 24, 2026
Merged

fix(agent): bill tool-approval time to the human, not the tool#3007
kovtcharov-amd merged 6 commits into
amd:mainfrom
kovtcharov:claudia/task-25e62f25

Conversation

@kovtcharov

@kovtcharov kovtcharov commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Most of this branch already reached main with the #3022#3026 stack. It has been merged down to what never landed: 13 files instead of 35, +478/−261 instead of +3738.

Three things the --dev turn breakdown reported wrongly. Every one needed a real turn with a real human at the keyboard to appear, which is why the unit suite was green:

  • A shell command that ran in 1.3s was recorded as 322.6s — the approval prompt blocks inside the tool call, so the time someone spent deciding was billed to the tool. Approval is now timed separately and shown as its own waiting on you figure, leaving tool_s and overhead_s meaning what they say.
  • Cache reuse was understated 3.6×. The proxy prompt appended the tool schemas after the conversation, but chat templates inject them alongside the system block at the front, so the shared prefix stopped at the first history message. A turn whose entire 12.2k system+tools header was reusable reported 27%.
  • A missing time-to-first-token drew as 0.0s, which reads as an instant first token. It now reads --.

It also clears the reviewer findings that rode onto main unfixed with that stack. The one contributors actually hit: .perf/runtests.sh was pinned to one developer's worktree — and leaked a local username — so the guard the plan doc tells people to use aborted for every one of them.

On the eval. All five reviews blocked on gaia eval agent. That gate was about the prompt changes, which are now on main, merged without it. Nothing left here touches prompt composition, tool schemas, or model selection, so the gate does not apply to this PR — but the eval is still owed for what merged, and it is no longer hardware-blocked.

🔍 Technical details

Two reviewer findings were checked and are not valid as written:

  • The AVAILABLE TOOLS assertion in tests/mcp/test_mcp_cli_to_agent_workflow.py is fine. The premise was that an agent with no model_id falls back to DEFAULT_MODEL_NAME and so takes the native path; in fact Agent.__init__ stores the raw None (agent.py:816) and only the client gets the default (:848), so _uses_native_tool_calls() is False and the prose block is still emitted. Applying the suggested rewrite would have broken the test — it asserts on _openai_tools, which is None there.
  • os.path.commonprefix is not C-speed; it is a Python loop like the one it would replace. Measured on a 99K shared prefix: 5.61ms → 4.71ms. Binary search over slice equality gets 0.09ms, so that is what landed, fuzzed against the naive implementation over 200k random pairs.

That investigation exposed a real drift risk, now fixed: _openai_tools re-derived the predicate that _uses_native_tool_calls documents itself as "the single source of truth" for, so the schema path and the prose gate could disagree. Both read one predicate now, pinned by a parametrised test.

Still open, deliberately not fixed here — _publish_turn_metrics is called only from the printed-answer branch, so a turn that burns its step budget shows no --dev breakdown. The obvious fix (publish on the tail too) introduces a cross-turn leak: SSEOutputHandler.print_turn_metrics only stashes, and print_final_answer consumes-and-clears, so stashing on a path with no answer event would attach that record to a later turn. Worth its own change.

Test plan

  • python -m pytest tests/unit/test_turn_metrics.py tests/unit/test_turn_metrics_wire.py tests/unit/test_system_prompt_composition.py tests/unit/test_dynamic_tool_filtering.py hub/agents/gaia/python/tests/test_full_tool_bundles.py — 154 passed locally
  • cd tui && go build ./... && go test ./internal/ui/chat/ ./internal/event/
  • bash .perf/runtests.sh tests/unit/test_turn_metrics.py -q --collect-only prints [guard] gaia -> … and collects, instead of aborting; run from outside a checkout it still aborts with exit 2
  • git diff main...HEAD touches 13 files and reverts nothing from perf(claude): cache the fixed prefill, and report the cache counters #3026 — the cache counters, the execute_python_file confirmation gate, ChatAgent's output_handler, and the confirmation-summary disclosure limits are all still main's

…metrics

The flagship re-sent ~17,000 tokens of prompt to a 4B model on every LLM call,
2-5 times per turn, so even a trivial question paid tens of seconds of prefill
before the model produced a word. This cuts the fixed prefill 53% — 17,014 to
~8,007 tokens — without removing a capability.

Three sources, all measured offline with tiktoken (no model contacted):

- The tool list went out twice. Gemma takes native tool_calls, so all 66 JSON
  schemas ship in `tools=`; the system prompt then restated every name and
  summary in an `==== AVAILABLE TOOLS ====` block — 1,678 duplicate tokens per
  call. `_compose_system_prompt` now gates that block on the same condition
  that already gated `_response_format_template`. Non-native models still get
  it; for them it is the only place the tool names appear.
- `gaia-voice` is always on and was 2,145 tokens of rationale prose — every
  rule followed by the incident that motivated it. Rewritten as instructions:
  692 tokens, all 24 behavioural rules intact.
- Dynamic tool loading was built, tested, and switched off for the flagship by
  two independent gates. `FULL_CORE_TOOLS`/`FULL_BUNDLES` now cover all 67
  tools under the same union-equality drift guard, and the agent shows the
  model <=26 per turn instead of all of them.

Prompt sections are also reordered static-first. llama.cpp reuses its KV cache
only up to the first differing token, and the memory block sat at the very top,
so a single `remember()` invalidated ~3,900 tokens that had not changed.
`VOLATILE_PROMPT_FRAGMENTS` names the fragments that now compose last.

`GAIA_TURN_LOG=<path>` turns on per-turn recording: fixed prefill, input tokens
split cached vs new, output tokens, ttft, the wall-time split across model /
tools / agent overhead, and absolute timestamps — one JSON object per turn, so
two builds are diffable. Off by default. `--dev` draws the same breakdown under
each answer in the TUI.

Tool timing lives in `_execute_tool_timed`, which the agent loop calls, rather
than inside `_execute_tool`: that method is copied onto test stand-ins by
attribute assignment and read back with `inspect.getsource` by the coercion
contract test, so wrapping it in place breaks both.

Not done, and blocked rather than skipped: `gaia eval agent` against the
committed baseline, and any post-change latency measurement. Both need Lemonade
on Gemma-4-E4B, which is currently banned on this machine; a Claude run is not a
substitute for either. docs/plans/gaia-agent-latency.md lists exactly what is
owed and how to take it.
@github-actions github-actions Bot added documentation Documentation changes chat Chat SDK changes tests Test changes agents tui Go terminal UI (gaia-tui) labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This cuts the flagship agent's per-call prompt roughly in half — it stops re-sending the tool list twice, moves the parts that change mid-session to the end so the model's cache survives, trims the always-on voice skill, and only shows the model the tools a turn actually needs. It also adds an opt-in per-turn latency log. The engineering is careful and unusually well tested; the problems are in what hasn't been checked yet.

Three things to fix before merge:

  1. The behavioural eval hasn't been run. Four things the model reads changed at once — the prompt lost about 40% of its text, its sections were reordered, the tool list is no longer restated in prose, and the model now sees at most 26 of 67 tools per turn. Every unit test here checks plumbing, not answer quality, and the plan doc in this PR lists the eval as owed. This repo requires it for exactly this kind of change. Run it against the committed baseline and put the comparison on the PR.

  2. An existing MCP test will now fail. It asserts the tool names appear in the agent's prompt text — which is precisely what this change removes for tool-calling models. Two other tests were updated for the same reason; this one was missed.

  3. The always-on voice skill now tells the agent to use tools it may not have that turn. It says to always check the installed skill list before saying something is unavailable, and to look at the machine directly rather than guess — but the tools that do those things are no longer in the always-loaded set. On a turn where the selector doesn't pull them in, the agent is instructed to do something it has no way to do. There is a recovery path (the agent can ask for more tools), but this is the regression the eval would surface.

  4. A committed helper script is hardcoded to one developer's machine and the plan doc tells contributors to use it. It aborts everywhere else.

Real-world evidence

N/A — no evidence bundle was produced for this PR, and I could not read the PR description in this environment (the GitHub CLI was unavailable), so I can't tell whether evidence lives there. The verdict rests on static review alone. This change is user-visible on two surfaces: the TUI's --dev breakdown under an answer (a screenshot would settle it) and the agent's answer quality (the eval scorecard in point 1). Neither is shown here.

🔍 Technical details

🟡 Issues

1. Four LLM-affecting surfaces changed with no gaia eval agent run

src/gaia/agents/base/agent.py:952-988 (prompt gate + reorder), hub/agents/gaia/python/gaia_agent/agent.py:194-1203 (dynamic_tools=True, dynamic_tools_max=26), hub/agents/gaia/python/gaia_agent/skills/gaia-voice/SKILL.md (2,145 → 692 tok rewrite).

CLAUDE.md → "Run agent evals when changing LLM-affecting code paths — do NOT skip" names system prompts, prompt-assembly order, and the tool schema sent to Lemonade explicitly. docs/plans/gaia-agent-latency.md:619 lists the scorecard as row 1 of "Numbers still owed". Blocked on Lemonade is a legitimate reason it hasn't happened yet, not a reason to merge without it.

gaia eval agent --category rag_quality --agent-type doc
gaia eval agent --compare \
  tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_rag_quality.json \
  <run>/scorecard.json

2. tests/mcp/test_mcp_cli_to_agent_workflow.py:138

assert "AVAILABLE TOOLS" in agent.system_prompt
assert "mcp_memory_create_entities" in agent.system_prompt

MCPTestAgent passes no model_id, so it gets DEFAULT_MODEL_NAME (Gemma-4-E4B-it-GGUF) → is_tool_calling_model True → _uses_native_tool_calls() True → no block, and none of those four assertions hold. @pytest.mark.integration + the npx_available fixture may hide it in the default lane, which makes it worse, not better.

The right fix is asserting the schemas rather than the prose, since that is now where MCP tools reach the model:

        # Step 7: Verify MCP tools reach the model. Native tool-calling models
        # get schemas via ``tools=``; the prose block is only for text-path models.
        schema_names = {s["function"]["name"] for s in (agent._openai_tools or [])}
        assert "mcp_memory_create_entities" in schema_names
        assert "mcp_time_get_current_time" in schema_names
        assert "mcp_sequential-thinking_sequentialthinking" in schema_names

Worth a grep for other system_prompt assertions that assume the block; tests/unit/test_dynamic_tool_filtering.py is safe only because it sets model_id = None.

3. gaia-voice mandates non-CORE tools

FULL_CORE_TOOLS (tool_bundles.py:870) is 10 tools: 5 memory, read_file, query_documents, 2 loop-control, load_tools. The trimmed skill body still says:

  • "Never call a skill unavailable without list_skills first" — list_skills is in the skills bundle
  • "Read the working directory, list files, check OS and hardware, run shell commands" — run_shell_command, list_files, find_files, tree are in shell / file_discovery

Failure scenario: "can you make me a Word document?" — the selector matches on "document" and pulls rag_query/rag_index, not skills. The always-on honesty rule then instructs a list_skills call the model cannot make, and the refusal it was written to prevent is the likely outcome. load_tools + the bundle menu make it recoverable in principle, but it costs a step and depends on the model noticing.

Two options: promote list_skills (and possibly run_shell_command) into FULL_CORE_TOOLS — they are cheap schemas and the rules that name them are unconditional — or reword the skill to route through load_tools. Either way the eval in #1 is what confirms it.

4. .perf/runtests.sh is hardcoded to one machine

W='C:\Users\14255\Work\gaia\.claudia-worktrees\claudia-task-25e62f25'
PY='C:\Users\14255\Work\gaia\.venv\Scripts\python.exe'
...
  *claudia-task-25e62f25*) ;;
  *) echo "ABORT: gaia resolves to $resolved (not this worktree)"; exit 2;;

docs/plans/gaia-agent-latency.md:406 instructs readers to "Use .perf/runtests.sh to run tests, not bare pytest" — it exits 2 for every one of them. The guard idea is genuinely good; derive the paths instead of pinning them:

W=$(git rev-parse --show-toplevel)
PY=${PYTHON:-python}
export PYTHONPATH="$W/src:$W/hub/agents/chat/python:$W/hub/agents/gaia/python"
export PYTHONIOENCODING=utf-8
resolved=$("$PY" -c "import gaia,sys;sys.stdout.write(gaia.__file__)")
case "$resolved" in
  "$W"*) ;;
  *) echo "ABORT: gaia resolves to $resolved (not $W)"; exit 2;;
esac

(It also embeds a local username in a public repo.)

🟢 Nits

  • hub/agents/gaia/python/tests/test_full_tool_bundles.py:1501 — "leaves 12" should be 16 (dynamic_tools_max 26 − 10 CORE); test_bundles_stay_small also hardcodes > 6 instead of > MAX_BUNDLE_MEMBERS, so bumping the constant silently won't move the test.
  • turn_metrics.py:2242_common_prefix_len is a per-character Python loop over the full rendered prompt (100K+ chars) on every call. os.path.commonprefix((a, b)) is C-speed and identical in result. It lands in overhead_s, i.e. it inflates the very number the recorder exists to explain.
  • hub/agents/gaia/python/gaia-agent.yaml:1149tools_count: 67 counts load_tools, which only registers when the loader is built. GAIA_DYNAMIC_TOOLS=0 makes the real count 66 and the manifest wrong; the drift test only sees the default.
  • docs/reference/dev.mdx:345 — the snippet exports GAIA_TURN_LOG then runs gaia --dev, which works for a subprocess-spawned sidecar. Worth confirming (and saying) whether an already-running gaia daemon picks it up, since that is the common case.

Strengths

  • The tests are the best part of this PR. The negative wire test asserting the answer payload is byte-identical when recording is off, the idempotent-seal test, the nested-tool double-count test, the cancelled-stream test that fails if a cancel reaches for /stats, and the Go test proving a malformed record loses only the breakdown and not the turn's own stats — that is the set someone writes after being burned, not a coverage exercise.
  • ToolLoader.optional_tools is the right shape: it keeps validate_registry loud for typos and renames while tolerating the two structural cases (env-gated search_documentation, subclass-only mixins), and test_optional_tools_are_present_on_a_full_install stops it from decaying into a blanket exemption.
  • .perf/offline_prefill.py aborts if memory v2 didn't initialise and arms every requests verb to raise — a measurement harness that fails loudly rather than quietly under-reporting. The .perf/.gitignore deny-by-default rationale (a composed prompt embeds the user's memory store verbatim) is exactly right.
  • Keeping *_server and *_local token counts in separate fields, and refusing to sum them, avoids the single most common way this kind of instrumentation lies.
  • _execute_tool_timed as a sibling rather than a split of _execute_tool — with the reason recorded — is the correct call given the inspect.getsource contract test.

Driving the real TUI on Claude Haiku 4.5 — the first end-to-end run of the
per-turn recorder — showed it misreporting three things. Every one passed the
unit suite, because each needs a real multi-step turn with a real human at the
keyboard to appear.

Cache reuse was understated 3.6x. The rendered prefix used for the
cached-vs-new split appended the tool schemas AFTER the conversation, but chat
templates inject them alongside the system block at the front. The shared
prefix therefore stopped at the first history message: a turn whose entire
12.2k system+tools header was reusable reported 27% cache hit. It now reports
98.7% for the same turn. Pinned by a regression test that fails at 62% on the
old ordering.

Time spent waiting for a human was billed to the tool. The confirmation prompt
blocks inside _execute_tool, so a shell command that ran in 0.1s was recorded
as 322.6s because that is how long approval took. Approval is now timed
separately and surfaced as its own `waiting on you` figure, leaving tool_s and
overhead_s meaning what they say.

A missing ttft rendered as 0.0s. Claude reports no time-to-first-token, and the
step row printed the absent value as an instant first token — the fake zero the
recorder itself is careful never to emit. It now reads `--`.

Also verified in that run: the ladder passes on Haiku (arithmetic, cross-turn
conversation history without memory, a real shell tool call returning the true
directory), the reordered prompt composes and is accepted, the AVAILABLE TOOLS
block is absent on the native path, and the dev-mode metrics block renders.

The smoke test that found this ships as .perf/haiku_smoke.py and now forces
GAIA_MEMORY_DISABLED=1: the memory subsystem embeds through Lemonade, and an
earlier run of it spawned a llama-server for the embedding model while Lemonade
was under a ban. That reach is now closed at the source.

Still owed and still blocked: gaia eval agent and any Gemma latency number.
Haiku validates logic only.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve with suggestions

This is a large, well-reasoned PR: it cuts the flagship's fixed prefill by ~53% through four coordinated changes (duplicate tool-block removal, static-before-volatile reordering, gaia-voice trim, dynamic tool loading for the full profile), adds a complete opt-in turn-metrics pipeline from agent through TUI, and ships thorough unit + Go tests. The code quality is high and the plan document is unusually transparent about what is and isn't proven.

Two items need resolution before or shortly after merge.


🟡 The eval is mandatory for this change, and the plan explicitly says it hasn't run.

Four LLM-affecting surfaces changed: the system prompt lost 2,832 tokens, its section order changed (volatile moved from first to last), the AVAILABLE TOOLS prose block is now suppressed for native tool-calling models, and the flagship now sees at most 26 of 67 tools per turn. CLAUDE.md is unambiguous: "you MUST run gaia eval agent against the relevant category and compare to the committed baseline before claiming the change is done." The plan (docs/plans/gaia-agent-latency.md) itself lists the eval as row 1 of its "Numbers still owed" table with the reason being the Lemonade ban on the development machine.

The Lemonade ban is a real circumstance, not a deliberate skip — but the merge gate still needs the eval before this can be called done. Please run gaia eval agent --category rag_quality (and any other affected category) once the ban lifts, compare against the committed baseline, and post the scorecard diff.


🟡 .perf/runtests.sh has paths hardcoded to one developer's machine.

The script is designed to guard against a real problem (PYTHONPATH falling through to the main checkout). As committed it will ABORT for every other contributor because the guard checks that gaia.__file__ contains claudia-task-25e62f25. Replace the hardcoded W= and PY= assignments with dynamic equivalents; the script's own directory can locate the worktree root, and python resolves from the active virtualenv when the script is invoked inside one.


🟢 Nit: The plan doc mentions .perf/dump_prompt.py as the Phase 1 tool (lines 394–395) but it isn't tracked — the .gitignore correctly excludes it, and the comment in the doc explains why (the prompt embeds memory verbatim). Fine as-is; a reader might go looking for it. One sentence noting it was a live-Lemonade script that was deliberately not committed would prevent the confusion.

🔍 Technical details

Eval requirement (CLAUDE.md → "Run agent evals when changing LLM-affecting code paths"):

The four changed surfaces are:

  1. _compose_system_prompt: static-before-volatile reordering (src/gaia/agents/base/agent.py)
  2. Tool-block gate: not self._uses_native_tool_calls() now suppresses ==== AVAILABLE TOOLS ==== for Gemma and Claude (agent.py)
  3. gaia-voice/SKILL.md: 2,129 → 676 tiktoken tokens — a real system-prompt reduction
  4. GaiaAgentConfig.dynamic_tools = True + FULL_BUNDLES: the flagship now receives ≤26 of 67 tools per turn instead of all 67

Each of (1)–(4) is a change the base class makes to what the model reads. gaia eval agent against rag_quality (and at minimum tool_use if a category covers tool selection) and --compare tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_<cat>.json <run>/scorecard.json is the required gate.

runtests.sh hardcoded paths:

# .perf/runtests.sh lines 5-6
W='C:\Users\14255\Work\gaia\.claudia-worktrees\claudia-task-25e62f25'
PY='C:\Users\14255\Work\gaia\.venv\Scripts\python.exe'

The guard at lines 10–13 checks *claudia-task-25e62f25* — any other developer gets ABORT regardless of their actual worktree. A minimal fix:

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -W 2>/dev/null || pwd)"
W="$(dirname "$SCRIPT_DIR")"  # repo root
PY="${VIRTUAL_ENV}/Scripts/python.exe"  # or python from PATH

The pwd -W handles MSYS paths on Windows; fall back to pwd on Linux/macOS.

What looks correct (no action needed):

  • The _uses_native_tool_calls() gate is consistent: both the tool-block and _response_format_template use the same predicate, and the test test_response_format_template_and_tool_block_share_one_gate pins that they never diverge.
  • The _tool_timing_depth re-entrance guard is correct: the getattr(self, "_tool_timing_depth", 0) default handles first use, the finally always resets it, and the nested-tool test (test_a_nested_tool_call_is_timed_once) confirms double-counting is prevented.
  • _finish_turn_record idempotency is verified by test_finish_is_idempotent; the finally in process_query is safe.
  • _mixin_prompt_origins is always set by _get_mixin_prompts before _compose_system_prompt reads it, including through ChatAgent's filter override.
  • The Go two-pass decode (usageWireCanonicalUsage + separate CanonicalTurnStats) correctly isolates a malformed record from corrupting steps/tokens/ttft, and the test TestCanonicalUsageMetrics/"record is malformed" covers that path.
  • TurnRecorder._write swallows OSError and never propagates into the turn — test_an_unwritable_path_does_not_fail_the_turn pins this.

The quality gate failed on this branch.

`query`, `agent_name` and `model_id` were passed to TurnRecorder twice —
once to the constructor, which ignored all three, and again to finish().
Pylint reported the unused arguments, but the duplication mattered more:
the 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 new by then.

Also drops an `import time` that the module-level import made redundant,
gives `_tool_timing_depth`, `_confirmation_wait_s` and
`_mixin_prompt_origins` class-level defaults beside the other per-instance
attributes, and marks the optional `print_turn_metrics` hook's unused
argument as deliberate — it is non-abstract precisely so existing consoles
need not implement it.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes

Note: The gh CLI is unavailable in this runner (sandbox failure on bwrap), so prior-review history could not be confirmed. This review covers the full diff.


🔴 Eight tests in test_turn_metrics.py will fail at runtime with TypeError. TurnRecorder.finish() accepts only answer and steps as keyword arguments, but the test file calls it with query=, agent_name=, and model_id= — parameters it does not accept. Every test that does this fails before any assertion runs. The assertion assert [r["query"] for r in records] == ["q0", "q1", "q2"] in test_record_is_one_json_line_per_turn would also fail on its own because the recorders were built with the default query="hello" from the _recorder() helper, not the per-iteration value passed to finish().

Fix: strip the extra kwargs from every finish() call in the test file, and rework test_record_is_one_json_line_per_turn to build each recorder with its own query (pass it to _recorder(query=f"q{i}")).

🟡 LLM-affecting surfaces changed but gaia eval agent has not been run against the committed baseline. Four surfaces are in scope per CLAUDE.md (system prompt content and ordering, tool block gate, tool schemas via dynamic loading, gaia-voice content trim). The plan doc itself labels this "still owed" and blocked. CLAUDE.md is unambiguous: the eval must run before the change is called done, and an empty ANTHROPIC_API_KEY is not an acceptable reason to skip — the judge client rides the active Claude Code subscription. The path is: start the backend (python -m gaia.ui.server --port 4200), run gaia eval agent --category <cat>, compare against the committed baseline with --compare.

🟢 .perf/runtests.sh hardcodes a personal Windows path (C:\Users\14255\Work\gaia\...). It runs correctly on that machine and does nothing harmful on others, but a checked-in dev script that names one person's filesystem is confusing when anyone else tries to use it.

🔍 Technical details

The finish() signature mismatch:

src/gaia/agents/base/turn_metrics.py:264:

def finish(self, *, answer: str, steps: int) -> Dict[str, Any]:

Test calls (all raise TypeError: TurnRecorder.finish() got an unexpected keyword argument 'query'):

  • tests/unit/test_turn_metrics.py:66rec.finish(query="hello", answer="hi", steps=1, agent_name="GaiaAgent", model_id="Gemma-4-E4B-it-GGUF")
  • :200, :215, :230, :243, :257, :269, :278 — same pattern with query="q", agent_name="A", model_id="M"

The test at :243 also asserts [r["query"] for r in records] == ["q0", "q1", "q2"], but all three recorders were constructed with the default query="hello" — the per-iteration query must go to _recorder(query=f"q{i}"), not to finish().

Eval gate: CLAUDE.md §"Run agent evals when changing LLM-affecting code paths" lists exactly these triggers. The plan doc (docs/plans/gaia-agent-latency.md, row 1 of the "Numbers still owed" table) already names the eval command:

gaia eval agent --category <cat>
gaia eval agent --compare tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_<cat>.json <run>/scorecard.json

runtests.sh: .perf/runtests.sh:4–5 sets W='C:\Users\14255\...' and PY='C:\Users\14255\...'. Replacing the hardcoded values with env-var inputs (or a comment explaining it must be customised locally) would make it usable without editing.

@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This branch cuts the flagship agent's fixed prompt roughly in half — it stops restating the tool list in prose for models that already receive JSON schemas, moves the parts of the prompt that change mid-session to the end so the model's cache survives them, turns on per-turn tool selection for the flagship, and adds an opt-in per-turn timing record that surfaces in the terminal UI under --dev. The engineering is careful and unusually well tested on the paths that normally rot.

The blocker is not the code, it's the missing behavioural check. This PR rewrites the default prompt for the whole chat-agent family — the flagship, document Q&A, and the Agent UI's chat — and the project's own rule is that a change to those surfaces must be scored against the committed baseline before merge. That run hasn't happened; the branch documents it as blocked by unavailable local hardware. A maintainer needs to either run it or knowingly waive it, because nothing in the test suite can tell you whether answer quality moved.

Two smaller things to fix before merge:

  • The flagship's published changelog doesn't mention any of this. That same file already tells users about per-turn skill selection; per-turn tool selection being on by default, and the rewritten voice guidance, belong there too — otherwise the package ships behaviour its docs don't describe.
  • A helper script added under .perf/ is hardcoded to one developer's machine paths, so it can't run for anyone else who follows the plan document's instructions.

Real-world evidence

No evidence-bundle.md was produced for this PR, so nothing was exercised on a real surface in this lane. The branch does carry a Claude Haiku smoke script that validates the recorder end-to-end, and its own plan document is explicit that Haiku proves logic only and says nothing about local-model latency or answer quality — five specific numbers are listed as owed and blocked, including the eval scorecard.

I also couldn't read the PR description from this runner (the GitHub CLI wasn't available), so if the Haiku output or screenshots are posted there, treat that as covered.

The verdict rests on static review for two surfaces: the TUI's new --dev breakdown has no screenshot, and the prompt changes have no quality score. The first is a nudge; the second is the reason for the verdict.

🔍 Technical details

🟡 Important

1. Four LLM-affecting surfaces changed with no gaia eval agent run

CLAUDE.md lists system prompts, prompt-assembly order, and the tool schema sent to Lemonade as changes that require an eval against the committed baseline before merge. All four moved here:

  • _compose_system_prompt now suppresses the ==== AVAILABLE TOOLS ==== block behind _uses_native_tool_calls() (src/gaia/agents/base/agent.py:919, :1076)
  • volatile fragments (memory, skills, procedural recall) moved to the tail
  • dynamic_tools=True on GaiaAgentConfig, so the model sees ≤26 of 67 tools per turn
  • gaia-voice/SKILL.md cut 2,145 → 676 tokens

Blast radius is wider than the flagship: ChatAgent passes model_id=effective_model_id (hub/agents/chat/python/gaia_agent_chat/agent.py:411), which is a tool-calling model, so every ChatAgent subclass — the flagship, DocumentQAAgent, the Agent UI's chat — loses the prose block. Any --use-claude run of any agent loses it too, since _uses_native_tool_calls() now returns True for Claude where the old tool-block gate didn't check _use_claude at all. Agents that leave model_id unset (Analyst, Browser, FileIO, Code, Jira, Docker…) are unaffected, because self.model_id stays None — worth stating in the PR body, since the plan document reads as if this were flagship-scoped.

rag_quality in particular exercises the doc profile, which is directly in scope. No fix to suggest — this needs the hardware.

2. hub/agents/gaia/npm/CHANGELOG.md doesn't mention the change

Per CLAUDE.md, a functional change to a hub agent package must be named in its CHANGELOG. The 0.1.1 — unreleased section already documents "Per-turn skill-body selection" in exactly this register; per-turn tool selection defaulting on, and the voice-skill rewrite, are the same class of user-visible behaviour change and are absent. README.md / SPEC.md / SKILL.md were checked and carry no stale tool-count or prompt-shape claim, so the CHANGELOG is the only gap.

🟢 Minor

3. .perf/runtests.sh is hardcoded to one machine

W='C:\Users\14255\Work\gaia\.claudia-worktrees\claudia-task-25e62f25' and the guard's *claudia-task-25e62f25* case both pin a one-off worktree name. The plan document tells readers to use this runner, and it aborts for all of them. Derive the worktree from the script's own location instead:

W=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -W 2>/dev/null || cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
PY=${GAIA_PYTHON:-python}
export PYTHONPATH="$W\src;$W\hub\agents\chat\python;$W\hub\agents\gaia\python"
export PYTHONIOENCODING=utf-8
resolved=$("$PY" -c "import gaia,sys;sys.stdout.write(gaia.__file__)")
case "$resolved" in
  "$W"*) ;;
  *) echo "ABORT: gaia resolves to $resolved (not this worktree: $W)"; exit 2;;
esac

4. MAX_BUNDLE_MEMBERS is defined and then bypassed (hub/agents/gaia/python/tests/test_full_tool_bundles.py:144) — the check uses a literal 6 while the failure message interpolates the constant, so raising the constant silently doesn't raise the limit.

    oversized = {
        b.name: len(b.members)
        for b in FULL_BUNDLES
        if len(b.members) > MAX_BUNDLE_MEMBERS
    }

5. Two comments added in this PR disagree on the slot budget. test_full_tool_bundles.py:37 says the cap "leaves 12"; gaia_agent/agent.py:164 says "10 CORE + 16 dynamic slots". 26 − 10 = 16, so the test comment is the wrong one.

6. Metrics never reach the TUI on the exits that matter most. _publish_turn_metrics is called only from the printed-answer branch (src/gaia/agents/base/agent.py:6332). The max-steps tail (:6453) and the error return (:6396) seal and write the record, and the tail attaches it to result["turn_metrics"], but neither publishes — so a turn that burned its whole step budget shows no --dev breakdown, which is exactly the turn someone would open the breakdown for. The log file still has it.

7. docs/plans/gaia-agent-latency.md points readers at ~/.gaia/ORCHESTRATION.md for the reason Lemonade was banned — a local file no reader of the repo has. Inline the one-line reason instead.

Strengths

  • .perf/.gitignore is deny-by-default with a stated reason — a composed system prompt embeds the user's memory store verbatim (names, paths, preferences), and the measurement scripts print counts but never the prompt. That threat model was noticed unprompted and is enforced in two places.
  • The negative tests are the good half. test_off_by_default_payload_is_unchanged asserts the answer event is byte-identical JSON, not merely metrics-free; the Go decoder reads usage.metrics as RawMessage so a malformed record costs only the breakdown and never steps/tokens/ttft (tui/internal/event/canonical.go, with the malformed and empty-record cases both covered); and TestTimingHoldsUpOnTheUnhappyPaths covers cancel-mid-stream, nested tool calls, a raising tool, and a double close.
  • FULL_OPTIONAL_TOOLS is a narrow tolerance, not a blanket skip. It's exempt from validate_registry only, and test_optional_tools_are_present_on_a_full_install still fails if one of those names is renamed — so the escape hatch can't quietly disable the drift guard it lives next to.

finish() no longer takes the turn's identity, so tests/unit/test_turn_metrics.py
had to move with it. The one test that asserts a distinct query per record now
constructs a recorder per turn, which is where the query belongs — it is fixed
when the turn opens.
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Request changes — one policy blocker before merge; the rest is strong work.

This PR does four things that are explicitly gated by CLAUDE.md's mandatory eval requirement, and the eval has not been run. The plan doc is honest about this — "Numbers still owed — blocked, not skipped" — but CLAUDE.md's rule is clear: these changes cannot be called done until a gaia eval agent run is compared against the committed Gemma-4-E4B baseline. Everything else here is well-built: the TurnRecorder idempotency story is tight, the FULL_BUNDLES/FULL_CORE_TOOLS drift-guard CI test closes the exact gap the doc describes, and the volatile-last prompt ordering is the right model for KV-cache safety.


🟡 gaia eval agent baseline comparison is required and missing

Four LLM-affecting surfaces changed in this PR: the system-prompt section order, the tool-block gate (suppressed for native tool-calling models), the gaia-voice skill body (2145 → 676 tokens), and dynamic tool loading now active for the full profile (≤26 of 67 tools per LLM call). CLAUDE.md is explicit that each of these requires gaia eval agent against the committed baseline before the change can be merged.

The plan doc acknowledges this is owed and explains why it is blocked (Lemonade banned due to machine instability). The Haiku smoke test validates recorder logic, and the plan doc correctly states that Haiku is not a substitute for Gemma-4-E4B quality numbers. Until the eval runs and the scorecard compares clean (or a regenerated baseline is committed with an explicit call-out that capability changed), merging this introduces a quality regression that will be invisible to every unit test.

When Lemonade is available again:

gaia eval agent --category rag_quality --agent-type doc
gaia eval agent --compare \
  tests/fixtures/eval_baselines/gemma-4-e4b-d71cd914/scorecard_rag_quality.json \
  <printed-output-path>/scorecard.json

Run serially for each relevant category.


🟢 .perf/runtests.sh has hardcoded personal paths

The committed script sets W='C:\Users\14255\Work\gaia\...' and PY='C:\Users\14255\Work\gaia\.venv\Scripts\python.exe'. The guard at the bottom will ABORT for any other developer since their gaia.__file__ won't contain claudia-task-25e62f25. The plan doc says "Use .perf/runtests.sh to run tests, not bare pytest" — pointing readers at a script that fails immediately for them. Either parameterise the paths or add a comment explaining the script must be adapted per-developer before use.

🔍 Technical details

LLM-affecting surfaces (CLAUDE.md checklist):

  • Agent.VOLATILE_PROMPT_FRAGMENTS + _compose_system_prompt reorder → system-prompt section order changed
  • _uses_native_tool_calls() gate → ==== AVAILABLE TOOLS ==== block suppressed for Gemma-4-E4B on every call
  • hub/agents/gaia/python/gaia_agent/skills/gaia-voice/SKILL.md → body trimmed from ~2145 to 676 cl100k tokens
  • GaiaAgentConfig.dynamic_tools=True + FULL_BUNDLES/FULL_CORE_TOOLS → model sees ≤26 of 67 tools per LLM call instead of all 67

All four are in CLAUDE.md's explicit list: "Tool registration, tool docstrings, or the JSON tool schema sent to Lemonade" and "ChatAgent / DocumentQAAgent / FileIOAgent / ChatAgentLite system prompts or any mixin prompt fragment."

runtests.sh hardcoded paths: .perf/runtests.sh lines 7–8:

W='C:\Users\14255\Work\gaia\.claudia-worktrees\claudia-task-25e62f25'
PY='C:\Users\14255\Work\gaia\.venv\Scripts\python.exe'

The guard on lines 11–13 will always ABORT for a contributor not on this exact worktree. The plan doc cites this script as the right way to run tests to avoid the PYTHONPATH trap — a reader who follows that advice gets ABORT immediately.

itomek
itomek previously approved these changes Aug 24, 2026
@itomek itomek assigned itomek and unassigned itomek Aug 24, 2026
pull Bot pushed a commit to bhardwajRahul/gaia that referenced this pull request Aug 24, 2026
…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>
The prompt-shrinking half of this branch already reached main with the
amd#3022-amd#3026 stack, and amd#3026 rebuilt the turn recorder on top of it to add
Anthropic cache accounting. Resolved to main everywhere that work landed --
keeping the branch's older copies would have reverted the cache counters,
the confirmation-summary disclosure limits, ChatAgent's output_handler, and
the execute_python_file confirmation gate.

What survives is what never landed: approval time billed separately from
tool time, the rendered-prefix ordering fix behind the cached/new split,
and the absent-ttft rendering.
@kovtcharov-amd kovtcharov-amd changed the title perf(agent): halve the flagship's per-call prompt; add per-turn cost metrics fix(agent): bill tool-approval time to the human, not the tool Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve

Three distinct fixes land here cleanly — approval-wait excluded from tool timing, cache-proxy ordering corrected, _openai_tools deduplication — plus a portable .perf/runtests.sh. All three had a real user-visible defect described in the PR (322.6s reported for a 1.3s shell command; 27% cache hit instead of ~99%), the changes are correct, and the test coverage is thorough.

A few observations worth noting, none of them blocking:

_confirmation_wait_s across tool depth levels — the reset in _execute_tool_timed is guarded by _tool_timing_depth, so nested tool calls (e.g. CodeAgent orchestration) bypass the outermost reset and correctly accumulate into the same counter. This is subtle but correct, and test_two_approvals_in_one_tool_both_count exercises it.

Cache-proxy ordering fix — putting tools alongside structured[0] (the system message) rather than appending after the whole conversation is the right call; chat templates do inject schemas near the system block. The new test confirms the system+tools header stays in the shared prefix as history grows.

SCHEMA = "gaia.turn/1" not bumped — intentionally correct. The TUI checks this string to identify valid records, and bumping would break display on old binaries. The new waiting_on_user_s in totals and conditional waited_s in tool entries are purely additive; old readers ignore them.

.perf/runtests.sh — the switch from a hardcoded developer path to BASH_SOURCE[0]-derived root is the right fix. set -uo pipefail without -e is intentional (errors handled with explicit || exit 2) and fine.

🔍 Technical details

Re-entrancy guard (agent.py:3237): The _tool_timing_depth check makes nested calls fall through to _execute_tool without resetting _confirmation_wait_s, so a tool body that triggers its own confirmation correctly adds to the outer tool's wait total rather than clobbering it.

sdk.py:219structured[1:] when structured is falsy: The type annotation is List[Dict] (not Optional), so None can't arrive here in practice. The surrounding try/except Exception would absorb a TypeError anyway. Not a real concern.

Go TTFT alignment: " --" is 5 chars; %4.1fs on a real float also produces 5 chars (e.g. " 0.4s"), so the column width is preserved. TestAMissingTTFTReadsAsAbsentNotInstant checks both the absent and present cases.

Binary search correctness: _common_prefix_len uses (lo + hi + 1) // 2 (ceiling midpoint) to avoid an infinite loop when hi = lo + 1, which is the standard pattern for "find rightmost True." test_common_prefix_matches_the_obvious_implementation cross-checks it against the naïve walk on 10 parametrized cases including Unicode and symmetric inputs.

@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Aug 24, 2026
Merged via the queue into amd:main with commit c7c4cff Aug 24, 2026
55 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents chat Chat SDK changes documentation Documentation changes tests Test changes tui Go terminal UI (gaia-tui)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants