Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: MCP tool calling reliability test framework#718

Merged
itomek merged 7 commits into
mainfrom
feat/issue-709-mcp-reliability
May 21, 2026
Merged

feat: MCP tool calling reliability test framework#718
itomek merged 7 commits into
mainfrom
feat/issue-709-mcp-reliability

Conversation

@itomek-amd

@itomek-amd itomek-amd commented Apr 2, 2026

Copy link
Copy Markdown
Collaborator

Why this matters

GAIA had no automated way to verify the agent loop + Lemonade + local model reliably select the correct MCP tool in response to natural-language requests. Tool-selection regressions could only be caught by manual testing. This PR ships an end-to-end eval harness, 10 generic example scenarios, and several framework-side fixes the eval immediately exposed — including a silent lie on tool loops, a JSON-envelope leak for tool-calling models, and a server-name doubling pattern that trained small models to truncate tool names.

What changed

Eval framework

  • gaia eval agent (src/gaia/eval/runner.py) — spawns a subprocess that acts as user-simulator + judge against the Agent UI MCP backend.
  • analyze_failures.py — produces per-tool failure rollups from trace JSON.
  • judge_turn.md STEP 0 — pipeline-scope override for verbatim-tagged scenarios: the rubric grades on tool selection only, not whether the tool's underlying operation succeeded at the hardware layer.

Generic test scenarios (eval/scenarios/mcp_reliability/)

  • 10 scenarios covering no-param, single-param, multi-step, conditional, error-handling, and "no tool needed" cases.

Agent-loop fixes (see "Problems found" below)

  • Server-name sanitisation
  • Truthful loop-break helper
  • model_id ordering in Agent.__init__
  • Native-path loop-break unwrap

Problems found, in the order we found them

P1 — Bare-prefix tool calls and the silent-success lie. Small local models sometimes emit just the server prefix (e.g. mcp_foo_mcp when the registered tool is mcp_foo_mcp_<tool>) instead of the full name. The framework returned Tool not found and the model retried with the same bad name four times. At that point the loop-detection path hard-coded "Task completed with mcp_foo_mcp. No further action needed" as the final answer — a silent lie to the user.
Fix: sanitise MCP server names so the framework strips redundant mcp tokens before namespacing (a server named foo_mcp produces mcp_foo_<tool> instead of mcp_foo_mcp_<tool>). Extracted Agent._build_loop_break_summary that branches on whether the most recent result was an error — surfaces the actual error wording instead of "Task completed".

P2 — JSON-envelope leak. The framework was supposed to suppress the embedded-JSON response-format template for models that support native tools=[] calling (Phase 2 work). The check ran against self.model_id, but model_id was assigned AFTER _register_tools() ran. The MCP registration triggered system-prompt caching while model_id was still None, so the suppression check returned False and the envelope template leaked into every tool-calling agent's prompt (~85 redundant tokens teaching {"tool": ..., "tool_args": ...} alongside the native schema).
Fix: moved self.model_id = model_id to before _register_tools() in Agent.__init__. Added a regression test that asserts a tool-calling agent's system prompt does NOT contain the ==== RESPONSE FORMAT ==== block.

P3 — Loop-break helper missed the native path. Phase 4 extracted _build_loop_break_summary to share logic between two call sites. The helper looked at step_results[-1] to decide if recent calls were errors. The legacy single-tool path appends to step_results (correct). The native-tool-call path appends to previous_outputs instead — wrapper dicts of shape {tool, args, result}. The helper at the native site saw an empty list, never noticed the failures, and returned "Task completed with <name>" despite a loop of errors.
Fix: at the native call site, unwrap previous_outputs before passing to the helper ([o.get("result") for o in previous_outputs]). Added a regression test exercising the helper with a sequence of error results.

P4 — Eval rubric conflated test scope. Original success_criteria strings read "PASS if agent calls <tool> AND the tool succeeds". The LLM judge interpreted "the tool succeeds" as "the underlying operation worked at the hardware/service layer". Some MCP services wrap real operation failures in a status: success envelope with the actual outcome buried in data.content[*].text ("Failed to ..." / "error code: N"). On dev hardware where many vendor-side features don't work, the judge graded the agent as failing even when it picked the right tool — because the tool's underlying op failed.
Fix: judge_turn.md STEP 0 overrides the rubric for verbatim-tagged scenarios — correctness = 10 if the right tool was invoked, correctness = 0 if the wrong tool was invoked, regardless of underlying op success or what the agent's prose said. Pipeline correctness only.

P5 — Module caching gotcha during iteration. Edits to a custom agent's _get_system_prompt() didn't take effect through the Agent UI chat endpoint until the backend process was restarted. Python caches imported modules at startup, so the agent registry held a stale class reference. Not a framework bug, but documented as a UX gotcha — restart the backend after editing custom-agent code.

Test coverage

113 unit tests across new and extended files:

  • tests/unit/mcp/client/test_sanitize_server_name.py (NEW) — 12-case sanitiser matrix + idempotency + warn-once
  • tests/unit/agents/test_loop_break_truthful.py (NEW) — 7 cases including the native-path regression
  • tests/unit/agents/test_tool_not_found_error.py (NEW) — 6 cases including no-echo invariant on the candidate-list message
  • tests/unit/agents/test_agent_source_invariants.py (NEW) — AST guard against the lie-on-loop literal reappearing
  • tests/unit/agents/test_response_format.py (extended) — verbatim rule + MCP mixin prompt
  • tests/unit/mcp/client/test_mcp_client.py (extended) — sanitised prefix + register/unregister symmetry
  • tests/unit/mcp/client/test_mcp_client_mixin.py (extended) — covers the new client.prefix attribute
  • tests/unit/eval/test_mcp_reliability_scenarios.py (NEW) — validates the 10 generic YAML scenarios
  • tests/unit/eval/test_iterations.py (NEW) — exercises the --iterations flag

Test plan

  • python -m pytest tests/unit/agents/ tests/unit/mcp/ tests/unit/eval/ -q — all green
  • python util/lint.py --all — clean
  • Boot Lemonade + GAIA UI, run gaia eval agent --category mcp_reliability --iterations 3 — verify no trace contains "Task completed with mcp_" (the lie-on-loop signature) and no doubled-prefix tool names in any trace
  • Manual smoke: boot Agent UI, send a single message requiring an MCP tool — verify it invokes correctly and the response wording reflects the tool's actual status

@github-actions github-actions Bot added documentation Documentation changes cli CLI changes tests Test changes electron Electron app changes labels Apr 2, 2026
@itomek itomek self-assigned this Apr 3, 2026
@itomek

itomek commented Apr 9, 2026

Copy link
Copy Markdown
Collaborator

@claude review this PR

@github-actions

github-actions Bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

Claude finished @itomek's task —— View job


PR Review: MCP Tool Calling Reliability Test Framework

  • Read PR diff and changed files
  • Review CLI changes (src/gaia/cli.py)
  • Review dispatch module (src/gaia/ui/dispatch.py)
  • Review test scenarios (eval/scenarios/mcp_reliability/)
  • Review unit tests
  • Review frontend changes
  • Provide comprehensive feedback

Summary

This is a well-structured PR that adds a comprehensive MCP reliability evaluation framework along with a boot-time initialization system for the Agent UI. The code is clean, well-documented, and includes thorough test coverage.


Review Feedback

🟢 Strengths

1. Clean Architecture - The DispatchQueue in src/gaia/ui/dispatch.py is well-designed with:

  • Clear job lifecycle states (PENDINGRUNNINGDONE/FAILED)
  • Dependency handling with timeout protection (60s)
  • Automatic pruning of old jobs
  • Thread-safe design using asyncio event loop

2. Comprehensive Test Coverage - 16 new tests covering:

  • Job lifecycle transitions
  • Dependency handling edge cases
  • Concurrency verification
  • CLI validation (--iterations 0, --fix --iterations N)

3. Well-Designed Eval Scenarios - The 10 MCP reliability scenarios cover a good range:

  • Simple: basic tool calls, no-tool-needed restraint, introspection
  • Moderate: structured params, error handling, search context
  • Complex: multi-step chains, conditional selection, multi-document

4. Good UX - Frontend changes properly:

  • Block input during initialization (ChatView.tsx:503-506)
  • Show initialization progress with animated banner (ConnectionBanner.tsx:66-82)
  • Adaptive polling (3s during init, 15s after)

🟡 Suggestions (Non-blocking)

1. Consider using asyncio.Event instead of polling for dependencies

src/gaia/ui/dispatch.py:126-138 - The current implementation polls every 0.2s:

while dep.status not in (JobStatus.DONE, JobStatus.FAILED):
    if time.monotonic() >= deadline:
        ...
    await asyncio.sleep(_DEPENDENCY_POLL_INTERVAL)

An asyncio.Event per job would be more efficient for CPU and latency, though the current approach is perfectly acceptable for boot-time use.

2. CLI table formatting edge case

src/gaia/cli.py:904 - If a scenario ID exceeds 40 characters, the table formatting may break:

print(f"  {sid:<40} {rate_str:>12} {result_str}")

Consider truncating long IDs or using dynamic width.

3. Test isolation with subprocess

tests/unit/eval/test_iterations.py:112-146 - The subprocess tests call the full CLI. While this tests the real behavior, consider mocking AgentEvalRunner for faster unit tests and reserving subprocess tests for integration.


🔴 Issues (Should Address)

1. Unused import in test file

tests/unit/eval/test_iterations.py:6:

import io

This import is not used in the test file.

2. model_load_lock visibility change should be documented

src/gaia/ui/_chat_helpers.py:62-63 - The lock was renamed from _model_load_lock to model_load_lock (public) for cross-module access. The comment explains why, but this is a public API change that could affect external code importing this module. Consider adding to the module docstring.


📋 Test Plan Verification

Based on code review, the test plan items should work:

  • python -m pytest tests/unit/eval/ -xvs — Tests are well-structured
  • gaia eval agent --help--iterations flag is registered at line 2106-2112
  • gaia eval agent --iterations 0 — Validation at line 3972-3974
  • gaia eval agent --fix --iterations 3 — Incompatibility check at line 3976-3982

Overall Assessment

Approve ✅ - This is high-quality code with good design patterns, comprehensive tests, and proper documentation. The minor issues noted above are non-blocking. The PR successfully adds:

  1. A robust MCP reliability evaluation framework with 10 scenarios across 3 complexity tiers
  2. A --iterations flag for measuring consistency across multiple runs
  3. A boot-time initialization system with frontend progress tracking
  4. GO/NO_GO readiness signals for reliability assessment

Nice work! 🎉


kovtcharov-amd
kovtcharov-amd previously approved these changes May 20, 2026
@itomek itomek force-pushed the feat/issue-709-mcp-reliability branch from 8175a66 to 2257e86 Compare May 21, 2026 14:14
@itomek

itomek commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Addressed the blockers + important items from the review (force-pushed to 2257e86b8):

Fixed

  • 🔴 SpacerSptool regression in src/gaia/eval/pdf_document_generator.py (3 sites — import + 2 call sites). Was caused by an over-broad substring substitution during an earlier vendor-name scrub; reverted to Spacer.
  • 🟡 Removed **_ignored_kwargs from Agent.__init__ — the python_factory already filters kwargs via _accepted_init_params; the base class no longer silently swallows unknowns. This fixes the 4 failing test_registry_python_factory.py cases.
  • 🟡 _post_process_tool_result return-type annotations updated to Optional[List[Dict[str, Any]]] in both chat/agent.py and blender/agent.py to match the base-class contract.
  • 🟡 uv.lock requires-python reverted from >=3.14 back to >=3.13 (matched origin/main). The bump was an accidental side-effect of running uv lock on a 3.14 env.

Not addressed in this push (deferring for your call)

  • 🟡 Hardcoded EDT timezone in analyze_failures.py — agreed this should be a CLI flag (--tz-offset). Worth a separate change; this PR is already large.
  • 🟢 mcp_tool_limit default raised 10 → 100 — happy to lower to ~50 if you'd prefer that as the OEM-realistic ceiling; flag it and I'll change.
  • 🟢 DEFAULT_OEM_LOG_DIR Windows-only default — agreed; will move to None + only-warn-when-explicitly-set in a follow-up if needed.

Verified locally:

  • pytest tests/unit/agents/ tests/unit/mcp/ → 504 passed, 2 skipped, 3 warnings
  • python util/lint.py --black --isort → clean

Re-running CI now.

@github-actions

Copy link
Copy Markdown
Contributor

Five concrete fixes for real eval-discovered regressions, each paired with a regression test — this is exactly how eval-driven development should work. The bulk of the diff is deletion of session logs that should never have shipped; the actual code changes are focused and well-reasoned.

Summary

The lie-on-loop fix (_build_loop_break_summary) and the model_id-ordering fix are the headline wins. Both were caught by the new eval harness, both are deterministic regressions that unit tests missed, and both now have targeted tests that guard against reintroduction. Server-name sanitisation is idempotent, documented, and has a thorough parametric test matrix. The MCP subprocess cleanup on cache eviction (_disconnect_cached_agent) plugs a real resource leak.

The one thing to eyeball before merge: the MCP tool limit silently went from 10 → 100, which is a meaningful behaviour change for local-model users.


Issues

🟡 MCP tool-limit default raised 10 → 100 without changelog note (chat/agent.py:1539)

The original hard-coded _MCP_TOOL_LIMIT = 10 was intentional — the comment read "skip if MCP would add >10 tools (context bloat guard)". The new default is 100 (10×). For local models on NPU/iGPU with tight context budgets, registering 49+ MCP tools against what was previously a 10-tool ceiling is a silent prompt-size regression.

The change is correct for the OEM MCP use case (the --mcp-tool-limit help text says ~49 tools), but it needs a line in the PR description so future maintainers understand why the conservative guard was lifted. The previous default should also be mentioned in the commit message so bisects land here.

No code change required; this is a docs/comms gap.


🟢 sanitise_server_name warns on pure case differences (mcp_client.py:5576)

The warning predicate if n != raw fires whenever the final normalised form differs from the raw input — including for names like "Filesystem" or "MyServer" that contain no mcp token, only an uppercase letter. The warning says "Consider renaming the server in mcp_servers.json," which is misleading when the only difference is capitalisation.

The test test_sanitise_does_not_warn_on_clean_input only exercises lowercase inputs, so this slips by.

    baseline = (raw or "").strip().lower().replace("-", "_").replace(" ", "_")
    if n != baseline and raw not in _logged_sanitisations:

This compares against the case/separator-normalised form rather than the raw string, so only genuine mcp-token stripping triggers the warning.


🟢 _single_tool_done relies on getattr fallback instead of __init__ initialisation (base/agent.py:1085, 1997)

getattr(self, "_single_tool_done", False) is called in _parse_response and _build_tool_message, but the attribute is not set in Agent.__init__. It's reset at the top of _process_query_impl (line 2228) and set to True inside _post_process_tool_result. Any call to those two helpers outside the standard query lifecycle (e.g., from a subclass or a unit test that drives the parsing path directly) silently falls back to False, which could mask a misconfigured single_tool_per_turn=True agent. One line in __init__ is cheaper than the mental overhead of tracking the implicit initialization:

        self._single_tool_done: bool = False

(Place immediately after the self.model_id = model_id line at base/agent.py:~323.)


🟢 Multi-paragraph docstrings violate CLAUDE.md one-line rule

_build_loop_break_summary and sanitise_server_name both have 10+ line docstrings. CLAUDE.md: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." The sanitise_server_name docstring is especially long (≈40 lines including the KNOWN LIMITATION block). That context belongs in the commit message, not the code.

Example fix for _build_loop_break_summary (base/agent.py:4046):

    def _build_loop_break_summary(
        self,
        tool_name: str,
        consecutive_count: int,
        step_results: list,
    ) -> str:
        """Final-answer text when the loop breaks on repeats; honest on errors."""

🟢 except Exception: pass in two new helpers (mcp/mixin.py:5739, _chat_helpers.py:5756)

Both usages (__del__ cleanup and cache-eviction MCP disconnect) are genuinely best-effort cleanup — the __del__ case is especially well-justified since raising from __del__ produces a noisy interpreter traceback without propagating anywhere useful. CLAUDE.md calls bare except Exception: pass tech debt and asks new contributors not to cite existing violations as precedent. These two are justified exceptions; a short inline # __del__ must not raise or # best-effort on eviction makes the intent explicit rather than leaving the reviewers to infer it.


🟢 _LoopDummyAgent alias adds confusion (test_loop_break_truthful.py:6081)

# Need _LoopDummyAgent in scope for the second test.
_LoopDummyAgent = _DummyAgent

test_helper_rejects_wrapper_dicts_silently is the only caller. The alias exists solely because the test was written with a different class name in mind. Just use _DummyAgent directly in that test and remove the alias.

    with patch("gaia.agents.base.agent.AgentSDK"):
        a = _DummyAgent(silent_mode=True, skip_lemonade=True)

Strengths

  • Lie-on-loop fix is clean and complete. _build_loop_break_summary is a single, centralized helper that eliminates the two in-line "Task completed with…" literals that were the bug. The AST invariant test (test_agent_source_invariants.py) is a smart defence: if anyone re-introduces a second copy of that literal the test breaks immediately, before any eval run.

  • model_id ordering fix is subtle and important. The system-prompt cache was being populated during _register_tools() with model_id=None, causing is_tool_calling_model() to return False and the JSON envelope to leak into every tool-calling agent's prompt. Moving the assignment one line earlier, then backing it with a direct assertion test, is the right approach.

  • Register/unregister symmetry test (test_mcp_client.py:TestRegisterUnregisterSymmetry) catches the exact class of bug the PR fixes — sanitised prefix at register time, raw name at unregister time. End-to-end tests like this are much more valuable than mocking each side separately.


Verdict

Approve with suggestions. The core fixes are correct, well-tested, and address real eval-discovered regressions. The 🟡 issue (MCP tool-limit default) needs a line in the PR description before merge; the 🟢 items can be applied as one-click suggestions or deferred to follow-up.

@itomek

itomek commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Addressed the second-round review (force-pushed):

CI-blocking lint fixes

  • 🔴 analyze_failures.py: removed f prefix on 2 non-interpolating f-strings (W1309)
  • 🔴 chat/agent.py: added missing from gaia.agents.base.tools import _TOOL_REGISTRY (E0602 / F821)
  • 🔴 test_iterations.py: removed unused import io (F401)
  • 🔴 test_mcp_reliability_scenarios.py: removed unused from pathlib import Path (F401)
  • 🔴 agent.py: 3 × # pylint: disable-next=assignment-from-none on _post_process_tool_result call sites (subclasses override to return plans)

Review nits addressed

  • 🟢 _single_tool_done initialised in Agent.__init__ (no more lazy getattr fallback)
  • 🟢 sanitise_server_name: warning now compares against case/separator-normalised baseline so pure-case differences don't nag the user
  • 🟢 _build_loop_break_summary + sanitise_server_name docstrings trimmed to one-liner per CLAUDE.md
  • 🟢 __del__ and cache-eviction except Exception: pass blocks now carry inline rationale comments
  • 🟢 _LoopDummyAgent alias removed in test_loop_break_truthful.py; second test uses _DummyAgent directly

Still deferred (worth a separate PR, called out in PR description gaps)

  • 🟡 mcp_tool_limit 10 → 100 — needs a changelog line; happy to lower to ~50 if preferred
  • 🟡 Hardcoded EDT timezone in analyze_failures.py — should become --tz-offset flag
  • 🟢 DEFAULT_OEM_LOG_DIR Windows-only path — should default None + warn-when-explicitly-set

Verified locally:

  • pytest tests/unit/agents/ tests/unit/mcp/ tests/unit/eval/ → 520 passed
  • python util/lint.py --black --isort → clean

@itomek itomek force-pushed the feat/issue-709-mcp-reliability branch from 2257e86 to 452b3e6 Compare May 21, 2026 14:26
Establishes an automated harness for verifying GAIA's agent loop +
Lemonade + local model reliably select the correct MCP tool for a
given user request. Closes the gap where tool-selection regressions
could only be caught by manual testing, and surfaces several
framework-side bugs the new eval immediately exposed.

The eval

- ``gaia eval agent`` (src/gaia/eval/runner.py) spawns a subprocess
  acting as user-simulator + judge against the Agent UI MCP backend.
- ``analyze_failures.py`` produces per-tool rollups and failure
  taxonomies from the trace JSON written by each run.
- eval/prompts/judge_turn.md adds STEP 0 — for ``verbatim``-tagged
  scenarios the rubric scopes to tool selection only (pipeline
  correctness), not whether the tool's underlying operation succeeds
  at the hardware/service layer (vendor concern, outside scope).

Test scenarios

- ``eval/scenarios/mcp_reliability/`` — 10 generic MCP tool-call
  probes (single-param, multi-param, chained, conditional).
- ``eval/scenarios/mcp_tool_reliability/`` — ~80 richer probes
  covering system-mode toggles, display-lens controls, dark mode,
  mic/audio, USB charging, battery/memory/storage status, etc.
- ``tests/unit/eval/test_mcp_tool_reliability_scenarios.py``
  validates each YAML's structure (turns/objective/success_criteria).

Agent-loop fixes the eval surfaced

- Sanitise MCP server names — strip redundant ``mcp`` tokens before
  namespacing so doubled prefixes like ``mcp_X_mcp_<tool>`` cannot
  occur. Small local models otherwise learn to truncate to the
  doubled prefix and emit non-existent tool names.
- Truthful loop-break — when ``max_consecutive_repeats`` fires on a
  sequence of identical *failed* calls, the framework no longer
  hard-codes ``"Task completed with <name>"``; it surfaces the
  underlying error instead.
- JSON-envelope suppression for tool-calling models — ``model_id``
  must be set BEFORE ``_register_tools()`` so the system-prompt
  cache built during MCP registration sees the correct
  ``is_tool_calling_model`` result. Without this, the embedded-JSON
  template leaked into Gemma-4's prompt despite the existing
  suppression check.
- Native-path loop-break — the helper must unwrap
  ``previous_outputs`` (each entry is ``{tool, args, result}``)
  before consulting the last result. The legacy single-tool path
  appends bare results to ``step_results``; the native path uses
  a different list. Without the unwrap the truthful-summary
  fallback path never fired.

Test coverage

113 unit tests across new and extended files:

- ``test_sanitize_server_name.py`` (NEW, 12 cases + idempotency + warn-once)
- ``test_loop_break_truthful.py`` (NEW, 7 cases incl. native-path regression)
- ``test_tool_not_found_error.py`` (NEW, 6 cases incl. no-echo invariant)
- ``test_agent_source_invariants.py`` (NEW, AST guard on duplicate "Task completed" literals)
- ``test_response_format.py`` (extended — verbatim rule + MCP mixin prompt)
- ``test_mcp_client.py`` (extended — sanitised prefix + register/unregister symmetry)
- ``test_mcp_client_mixin.py`` (extended — covers new prefix field)
- ``test_mcp_tool_reliability_scenarios.py`` (NEW, validates the 80+ YAML scenarios)
@github-actions

Copy link
Copy Markdown
Contributor

This PR ships four well-diagnosed framework fixes, 10 generic MCP reliability scenarios, and 113 new tests — the eval infrastructure is doing exactly what it was built to do. One shipped file needs cleanup before merge; the rest is ready.


Issues Found

🟡 Important — OEM-specific internals shipped in the public package (src/gaia/eval/analyze_failures.py)

The scenarios the analyzer was built to process (eval/scenarios/mcp_tool_reliability/) are correctly gitignored. The analyzer itself is not, so it ships as part of the installable package with hardcoded internal details that will confuse or break for any external user:

DEFAULT_OEM_LOG_DIR = Path("C:/ProgramData/OEM/OEMService")   # line 51
_OEM_LOG_TZ_OFFSET = timedelta(hours=-4)  # EDT               # line 278

The module docstring also names 41-tool rollup, OEMService*.log, and C:/ProgramData/OEM/OEMService as usage examples. None of this is meaningful outside the original development environment.

Options (pick one):

  1. Move the file to the gitignored eval/ tree (outside src/) so it stays on disk but doesn't ship.
  2. Genericize: replace DEFAULT_OEM_LOG_DIR with a None default and remove the OEM-named constant and log-parsing regexes, keeping only the generic YAML/JSON analysis logic.
  3. Add src/gaia/eval/analyze_failures.py to .gitignore (acceptable since it has no public callers and setup.py packages by find_packages, not by explicit listing).
DEFAULT_LOG_DIR = None   # supply via --tool-log-dir

🟡 Important — mcp_tool_limit default raised 10 → 100 without per-model guidance (src/gaia/agents/chat/agent.py:101, src/gaia/cli.py:1342)

The old limit of 10 was a conservative context-bloat guard. Raising the default to 100 is needed for the OEM MCP service (~49 tools), but it now affects every gaia chat user who has MCP servers configured — including users running 4B-class models whose context windows can't absorb 100 extra tool descriptions. The CLI help text names the OEM MCP specifically, which is an internal detail.

    chat_parser.add_argument(
        "--mcp-tool-limit",
        type=int,
        default=100,
        help="Maximum MCP tools to register (default: 100). "
        "Reduce for small models with limited context windows.",
    )

And in ChatAgentConfig:

    mcp_tool_limit: int = 100  # Raise to accommodate large MCP servers; reduce for small models

Consider documenting the tradeoff (more tools = larger system prompt = context pressure) so users can tune it.


🟢 Minor — Module-level _logged_sanitisations is mutable global state (src/gaia/mcp/client/mcp_client.py:30)

_logged_sanitisations: set = set()

This accumulates entries for the lifetime of the process and is never cleared. Fine for production (warn-once is the goal), but it bleeds between tests — the second test that constructs an MCPClient("tool_mcp", ...) won't see the warning. The existing test suite accounts for this with the warn-once semantics test, but future tests relying on warning presence can silently pass. A pytest fixture that clears the set between tests would make this airtight. Not blocking, but worth noting.


🟢 Minor — json and defaultdict imported inside function body (src/gaia/cli.py:4246-4249)

def _print_reliability_summary(scorecards, pass_threshold=0.90):
    import json
    from collections import defaultdict
    ...

Both are stdlib and have no side effects. They belong at the top of cli.py with the other stdlib imports.

    from collections import defaultdict
    import json

(Move to the top-level import block.)


Strengths

  • Root-cause accuracy is excellent. All four problems (bare-prefix emission, model_id ordering, native-path unwrap, rubric conflation) are diagnosed at the exact failure point, not papered over with prompt rules. The eval transcripts → code fix pipeline is the system working as designed.
  • The _build_loop_break_summary extraction is clean and the "kept failing" vs "Task completed" branching is the right abstraction boundary. The AST-based invariant test (test_agent_source_invariants.py) guarding against the lie literal reappearing is unusually thorough engineering.
  • Test suite quality is high. The test_helper_rejects_wrapper_dicts_silently test documents the exact call-site contract (the native path must unwrap before calling the helper) as a test rather than a comment — future authors can't accidentally re-introduce the bug without breaking the suite.

Verdict

Request changes on analyze_failures.py (OEM internals in a shipped package module). The mcp_tool_limit default change is worth a discussion comment but not a hard blocker if the team is intentionally raising it. Everything else is clean; the loop-fix and model_id ordering fix are especially good and should go in.

@itomek itomek enabled auto-merge May 21, 2026 14:43
Address bot review items on PR #718:

- analyze_failures.py: drop OEM-specific defaults and naming. The hard-
  coded log dir and EDT tz offset move to CLI flags (--tool-log-tz-
  offset-hours, --tool-log-glob); file glob defaults to *.log; module
  docstring and class names are vendor-neutral (OEMLogEntry ->
  ToolLogEntry). Public-facing analyzer works for any MCP service
  emitting bracketed-timestamp logs.
- cli.py: hoist json and defaultdict to module-level imports; drop the
  6 inner re-imports flagged by W0404. Genericize --mcp-tool-limit help
  text to explain the small-model tradeoff instead of citing a vendor.
- chat/agent.py: isort fix (_TOOL_REGISTRY ordering) unblocking CI.
- test_sanitize_server_name.py: convert _logged_sanitisations manual
  clears into an autouse fixture so isolation is automatic, not per-
  test bookkeeping.
@github-actions

Copy link
Copy Markdown
Contributor

This PR ships solid, root-cause-driven fixes for five concrete agent-loop failures, backed by 113 unit tests and a clean deletion of stale AI-generated run logs. The single most important thing: the model_id ordering fix (P2) is subtle but high-impact — the regression test that asserts the JSON envelope is NOT in tool-calling agents' prompts is exactly the right guard.


Issues

🟡 Important — _post_process_tool_result doesn't call super() in BlenderAgent/ChatAgent

Both overrides were updated to return Optional[List[Dict]] but neither calls super()._post_process_tool_result(). The base class is now where _single_tool_done = True gets set for single_tool_per_turn agents:

# base agent.py:4085
if self.single_tool_per_turn and not self._is_error_result(_tool_result):
    self._single_tool_done = True

No existing agent uses single_tool_per_turn=True today, so this is not a current bug. But the first subclass that does will need to call super() — and there's nothing enforcing that. The docstring on _post_process_tool_result should say so explicitly:

src/gaia/agents/blender/agent.py:435, src/gaia/agents/chat/agent.py:382

        """
        Post-process the tool result for Blender-specific handling.

        Returns ``None`` (no recovery plan); the framework's default
        success/error gating applies.

        Subclasses that override this and intend to support
        ``single_tool_per_turn=True`` MUST call ``super()._post_process_tool_result(...)``
        so the base class can set ``_single_tool_done``.

        Args:

🟡 Important — MCP tool limit default jump (10 → 100) needs eval note

src/gaia/agents/chat/agent.py:184: _MCP_TOOL_LIMIT was hardcoded to 10 and is now configurable at a default of 100 — a 10× increase that lands in the system prompt. The PR description doesn't include a baseline eval comparison showing existing RAG/chat scenario scores are unaffected by the larger prompt. CLAUDE.md requires an eval run for prompt-mass changes. The fix is correct (10 was too aggressive), but the PR should reference a --compare scorecard or explicitly note that existing categories weren't re-evaluated.


🟢 Minor — ANSI escape codes without TTY check

src/gaia/cli.py:4294–4296: _print_reliability_summary writes \033[32m / \033[31m directly. On CI, piped output, or Windows terminals, these appear as literal ^[[32m escape sequences. GAIA's AgentConsole uses explicit color guards:

        if passed:
            if sys.stdout.isatty():
                result_str = f"\033[32m{'PASS':>8}\033[0m"
            else:
                result_str = f"{'PASS':>8}"
        else:
            if sys.stdout.isatty():
                result_str = f"\033[31m{'FAIL':>8}\033[0m"
            else:
                result_str = f"{'FAIL':>8}"

Same pattern applies to the GO/NO_GO lines at cli.py:4303–4309.

🟢 Minor — Duplicate word in eval/prompts/judge_turn.md

eval/prompts/judge_turn.md:11: "the the OEM MCP service's tools"

service layer (~38–60% of the OEM MCP service's tools currently report

🟢 Minor — test_task_completed_with_appears_in_exactly_one_string_literal is fragile

tests/unit/agents/test_agent_source_invariants.py:76: The AST walk catches every string literal, including test strings, docstrings, and f-string constant parts. Any future test that asserts "Task completed with" (e.g., assert "Task completed" not in summary written as assert "Task completed with" not in ...) will break this invariant without touching the guarded code. The intent is right; consider scoping the walk to only _build_loop_break_summary's function body via ast.get_source_segment or a narrower node filter.


Strengths

  • P2 (model_id ordering) + regression test is the cleanest fix in the PR: a one-line reorder in Agent.__init__, a minimal reproduction path, and a test that fails with the old ordering and passes with the new one. Textbook.
  • sanitise_server_name is properly idempotent — the while-loop strips cascading _mcp_ tokens and the 12-case parametrized matrix including "foo_mcp_bar_mcp""foo_bar" and the empty-string fallback covers all expected real-world server naming variants.
  • _disconnect_cached_agent is applied consistently to all three eviction paths (model change, agent_type change, LRU eviction) in _chat_helpers.py. This is the kind of symmetric fix that prevents subprocess leaks from accumulating silently.
  • Deletion of 2,500+ lines of AI-generated run logs (eval_run_report.md, ARCHITECTURE_ANALYSIS.md, monitor_log.md) keeps the repo signal-to-noise ratio healthy.

Verdict

Approve with suggestions. The two Important items are design notes, not blocking bugs (no current agent uses single_tool_per_turn and the 10→100 limit change is directionally correct). Apply the typo fix and ANSI-guard before merge if easy; the super() docstring note is a low-priority follow-up.

CI block:
- cli.py: drop the last `import json as _json` alias (W0404 reimport
  flagged by pylint without `-E`). The aliased loads call now uses the
  top-level json module.

Bot review #3:
- judge_turn.md: collapse "the the OEM MCP service's tools" duplicate
  and drop the OEM-specific percentage; the rule is vendor-agnostic.
- cli.py `_print_reliability_summary`: guard ANSI escapes behind
  `sys.stdout.isatty()` so piped output (CI, logs, non-ANSI Windows
  shells) doesn't show literal `^[[32m` sequences.
- blender/chat agent.py `_post_process_tool_result` docstrings: note
  the `super()` requirement for `single_tool_per_turn=True` subclasses.
- test_agent_source_invariants.py: scope the "Task completed with"
  literal walk to `_build_loop_break_summary`'s body so unrelated
  mentions (test assertions, future docstrings) don't trip the gate.
@github-actions

Copy link
Copy Markdown
Contributor

Four real bugs fixed, all with targeted regression tests and clear root-cause documentation — this is exactly what the eval framework should surface. No blocking issues; two latent correctness concerns worth addressing before the next agent ships with single_tool_per_turn=True.


Summary

P1–P4 fixes are correct and well-tested: the model_id ordering bug (JSON envelope leak), the native-path loop-break lie, the bare-prefix tool-name truncation, and the server-name doubling pattern. The 10 generic mcp_reliability scenarios are well-structured and the --iterations flag is a clean addition. Cleanup of ~1500 lines of session-specific working docs is appropriate housekeeping.

Main concern: both BlenderAgent and ChatAgent override _post_process_tool_result without calling super(), which silently breaks single_tool_per_turn for those two agents (latent, not active yet). Secondary: a type annotation nit in the refactored to_gaia_format.


Issues Found

🟡 Important — BlenderAgent / ChatAgent don't call super()._post_process_tool_result (src/gaia/agents/blender/agent.py:433, src/gaia/agents/chat/agent.py:380)

The base class Agent._post_process_tool_result now owns _single_tool_done management:

# agent.py (base)
def _post_process_tool_result(self, ...):
    if self.single_tool_per_turn and not self._is_error_result(_tool_result):
        self._single_tool_done = True
    return None

Both BlenderAgent and ChatAgent override this method and return None without calling super(). If either agent (or any existing subclass) sets single_tool_per_turn = True, the _single_tool_done flag will never be set — the agent will loop indefinitely instead of stopping after one tool. The docstrings warn future subclasses to call super(), but the warning is on the wrong class — BlenderAgent and ChatAgent are already the offending overrides.

Since neither currently sets single_tool_per_turn = True this is latent, but the next OEM agent that does will silently break. Fix both to delegate through:

    def _post_process_tool_result(
        self,
        tool_name: str,
        tool_args: Dict[str, Any],
        tool_result: Dict[str, Any],
    ) -> Optional[List[Dict[str, Any]]]:
        """..."""
        # (existing Blender-specific handling here)
        ...
        return super()._post_process_tool_result(tool_name, tool_args, tool_result)

Same pattern needed in ChatAgent.


🟡 Important — MCPTool.to_gaia_format parameter type: str = None (src/gaia/mcp/client/mcp_client.py:151)

raw_server_name: str = None is not valid — None is not a str and pylint/mypy will flag it.

    def to_gaia_format(
        self, prefix: str, raw_server_name: Optional[str] = None
    ) -> Dict[str, Any]:

Optional is already imported at the top of the file.


🟢 Minor — _disconnect_cached_agent swallows exceptions silently (src/gaia/ui/_chat_helpers.py:5817)

The __del__ case in mcp/mixin.py is a legitimate exception to the fail-loudly rule (Python contracts: __del__ must not raise). The cache-eviction case in _disconnect_cached_agent has no such constraint — a failed disconnect during eviction is observable and useful for debugging. At minimum, log at DEBUG:

        try:
            mcp_manager.disconnect_all()
        except Exception as exc:  # best-effort on cache eviction
            logger.debug("MCP disconnect failed during cache eviction: %s", exc)

🟢 Minor — analyze_failures.py default scenarios dir points to gitignored path (src/gaia/eval/analyze_failures.py:4689)

DEFAULT_SCENARIOS_DIR = REPO_ROOT / "eval" / "scenarios" / "mcp_tool_reliability"

This default points to the private/gitignored vendor-coupled directory, not the public mcp_reliability one. On a fresh checkout load_scenarios() will log a WARNING: Scenarios dir missing and return an empty map — silently producing an empty report. The module docstring shows the private path in its --scenarios-dir example, so the intent is clear for internal use, but a contributor running analyze_failures will get a confusing silent-no-results experience. Consider defaulting to the public dir and noting in the module docstring that --scenarios-dir should point at the private scenarios for vendor-coupled analysis.


Strengths

  • AST-based regression guard (test_agent_source_invariants.py): using ast.walk to assert the "Task completed with" literal lives in exactly one place in _build_loop_break_summary is creative, runs in milliseconds, and would have caught the original lie-on-loop bug at CI time.

  • Native-path regression test is precisely documented (test_loop_break_truthful.py:6088): the test comment explicitly captures the call-site contract — unwrapped results at the native path — making it impossible to misread what the test is verifying.

  • model_id ordering fix is surgical and self-contained: one line moved earlier in __init__, one regression test (test_json_envelope_suppressed_for_tool_calling_models) that exercises the exact failure mode. No side effects.

  • Warn-once sanitisation (_logged_sanitisations set): the pattern correctly prevents log spam in long-running server processes while still surfacing the server-name misconfiguration on first contact.

  • Cleanup: removing ~1500 lines of session-specific working docs (ARCHITECTURE_ANALYSIS.md, eval_run_report.md, monitor_log.md) is the right call. Those files read like in-progress notes, not permanent reference material, and were cluttering the public repo.


Verdict

Approve with suggestions. The core fixes are correct and thoroughly tested. The two 🟡 issues are latent (not currently active) but the BlenderAgent/ChatAgent super-call omission will catch the next developer who tries single_tool_per_turn=True on those agents, so it's worth fixing in this PR while the context is fresh.

Address PR #718 bot review:

- BlenderAgent / ChatAgent _post_process_tool_result: previous push
  only documented the super() requirement; this push actually delegates
  through. Without this, any subclass setting single_tool_per_turn=True
  would loop indefinitely because _single_tool_done never gets flipped.
- MCPTool.to_gaia_format: raw_server_name typed as str = None, which
  pylint/mypy reject. Promote to Optional[str] (already imported).
- _disconnect_cached_agent: replace the silent except-pass with a
  logger.debug so disconnect failures during cache eviction are
  observable. The __del__ case in mcp/mixin.py keeps its bare pass
  (Python __del__ contract: must not raise).
- analyze_failures.DEFAULT_SCENARIOS_DIR: was pointing at the
  gitignored vendor-coupled mcp_tool_reliability dir, giving fresh
  contributors a silent "no scenarios found" experience. Default to
  the public mcp_reliability dir; private scenarios still work via
  --scenarios-dir.
@github-actions

Copy link
Copy Markdown
Contributor

Five real framework bugs fixed, solid test coverage, clear PR description. Two items need resolution before merge.

Summary

This PR ships an MCP tool-calling reliability eval framework together with four framework-level fixes that the eval directly exposed: JSON-envelope leak (P2), truthful loop-break summary (P1/P3), server-name sanitisation (P1), and model_id ordering in __init__ (P2). The fixes are well-scoped and each is covered by a targeted regression test. The three deleted files (ARCHITECTURE_ANALYSIS.md, eval_run_report.md, monitor_log.md) are correctly removed — they were AI session logs that should never have been committed. One hardcoded vendor constant and one missing eval gate are the blockers.


Issues Found

🟡 analyze_failures.py — hard-coded vendor constant 41 will spam every public user (src/gaia/eval/analyze_failures.py:5447)

_self_check warns if the per-tool rollup has anything other than 41 unique tools. 41 is the internal OEM tool count; the public repo ships 10 generic scenarios. Every run of the public analyzer will always emit the warning, making it noise from day one. Either remove the guard entirely or accept it via a configurable flag.

    # Tool-count guard is vendor-specific; skip for public/generic scenarios.
    # Pass --expected-tool-count N to add it back when running against a
    # full OEM scenario set.

Affect: remove or gate the check at analyze_failures.py lines 5447–5450.


🟡 mcp_tool_limit default raised 10 → 100 without an eval run (src/gaia/agents/chat/agent.py:1233, src/gaia/cli.py)

Raising the MCP tool registration cap changes how many tools appear in the system prompt, which directly affects tool-selection accuracy for small local models (Gemma-4, Qwen3 series). CLAUDE.md requires a gaia eval agent comparison to baseline whenever tool registration changes. The PR description doesn't include one for this specific change.

The direction is likely correct — 10 was probably too restrictive for real-world MCP configs — but it needs the eval gate cleared. Run:

# Terminal 1
python -m gaia.ui.server --port 4200 --host 127.0.0.1

# Terminal 2
gaia eval agent --category mcp_reliability --compare eval/results/<baseline>/scorecard.json

and include the result in the PR description. If no regression, this is a one-line comment addition.


🟡 mcp/mixin.py:__del__ — bare except Exception: pass (src/gaia/mcp/mixin.py:395–402)

CLAUDE.md prohibits bare exception swallowing. The __del__ constraint is real (Python prints and discards __del__ exceptions anyway), but the fix should at minimum log at DEBUG — exactly what the equivalent change in _chat_helpers.py already does correctly:

            try:
                manager.disconnect_all()
            except Exception as exc:
                logger.debug("MCP disconnect failed in __del__: %s", exc)

🟢 simple_tool_call.yaml requires a tool that doesn't exist in default GAIA (eval/scenarios/mcp_reliability/simple_tool_call.yaml)

The scenario asks the agent to call system_status, get_status, or similar — none of which exist in a clean GAIA install. Running this in CI or a vanilla setup will always produce FAIL, making the "generic public scenarios" claim misleading. Update the success criteria to use a tool that actually exists by default (e.g., any RAG tool, browse_files) or add a tags: [requires_mcp_setup] marker and skip it in default runs.


🟢 test_helper_rejects_wrapper_dicts_silently asserts the wrong behavior by design (tests/unit/agents/test_loop_break_truthful.py:6142)

The test name and internal comment explain this is intentionally documenting the bug shape — the helper returns "Task completed" when fed wrapped dicts because the unwrap must happen at the call site. But assert "Task completed" in summary looks like an accidental success assertion to any future reader who hasn't read the 20-line comment block. Rename the test to test_caller_must_unwrap_previous_outputs_before_passing so the intent is self-describing without requiring the comment. The comment block itself violates CLAUDE.md's "one short line max" guidance for WHY comments.


Strengths

  • P1–P4 fixes are each pinned by a regression test — the AST-level invariant (test_task_completed_with_appears_in_exactly_one_string_literal) is genuinely creative and will catch future refactors that reintroduce the lie-on-loop pattern.
  • sanitise_server_name is idempotent, warns once, and is tested with a 12-case matrix. The _reset_logged_sanitisations autouse fixture correctly isolates the module global between test runs.
  • MCP resource cleanup is now consistent: _disconnect_cached_agent is called in all three eviction paths in _chat_helpers.py (model change, agent-type change, LRU eviction), and evict_session_agent also cleans up. The helper correctly catches exceptions and logs at DEBUG rather than swallowing them.
  • _post_process_tool_result return-type change is non-breaking: existing subclasses that return None (implicit or explicit) stay unaffected; the new Optional[List[dict]] path is opt-in. BlenderAgent and ChatAgent both correctly call super().

Verdict

Request changes — the analyze_failures.py constant and the missing eval run for mcp_tool_limit are the blockers. The __del__ fix is a one-liner. Address those three and this is ready to merge.

Address PR #718 bot review:

- analyze_failures._self_check: drop the hard-coded "expected 41 tools"
  warning that would fire on every public-scenario run. Replaced with
  an opt-in --expected-tool-count flag (default None = no check); the
  warning only fires when the caller asserts a known fixed tool count.
- mcp/mixin.py __del__: replace silent except-pass with a defensive
  logger.debug (wrapped in its own try so __del__ still can't raise).
  Mirrors the fix already applied in ui/_chat_helpers.py.
- chat/agent.py mcp_tool_limit: keep the 100 default but annotate
  with the validation evidence (49-tool MCP server, Gemma-4-E4B,
  100% pass rate on smoke scenarios in this PR) and the small-model
  bloat tradeoff.
- mcp_reliability/simple_tool_call.yaml: switch from a non-existent
  "system_status" tool to a file-listing tool that exists in default
  GAIA, so the scenario actually runs out of the box.
- test_loop_break_truthful.py: rename
  test_helper_rejects_wrapper_dicts_silently to the self-describing
  test_caller_must_unwrap_previous_outputs_before_passing, and trim
  the 14-line preamble comment per CLAUDE.md's "one short line max"
  guidance.
@github-actions

Copy link
Copy Markdown
Contributor

This PR delivers real value — four well-diagnosed agent-loop bugs fixed with targeted tests, a clean eval framework extension, and long-overdue session-artifact cleanup. The model_id-ordering fix and the truthful loop-break helper are production-quality fixes that would have been invisible without the eval framework that caught them.


Issues

🟡 --reset-between-scenarios flags advertised but not wired (src/gaia/cli.py)

The CLI registers three new args (--reset-between-scenarios, --lemonade-model, --lemonade-ctx-size) and validates them (e.g. full mode requires --lemonade-model), but the validated values are never passed to AgentEvalRunner and AgentEvalRunner has no parameters to accept them. A user who runs:

gaia eval agent --reset-between-scenarios fast --iterations 3

will get the banner-and-loop logic but zero resets — silent no-op. The --help output will list flags that don't do anything.

Either remove the flags from this PR and add them when the implementation lands, or add a # Not yet implemented — stub note and gate the --reset-between-scenarios validation with a NotImplementedError / early-exit so users get an explicit error rather than a silent no-op.

🟡 analyze_failures.py has no unit tests (src/gaia/eval/analyze_failures.py)

This is 995 lines of new code (timestamp-alignment logic, log parsing, per-tool rollup, severity classification, markdown rendering) with zero test coverage. All the other new modules in this PR ship with direct unit tests. Even a smoke test exercising the FailureAnalyzer with a synthetic trace JSON would prevent the most likely regressions.

🟡 MCP tool limit quietly jumped from 10 → 100 (src/gaia/agents/chat/agent.py)

# old
_MCP_TOOL_LIMIT = 10
# new
_MCP_TOOL_LIMIT = self.config.mcp_tool_limit  # default 100

For any user with 11–99 MCP tools configured, the old code silently skipped MCP loading; the new code loads all of them. This is a correct fix (the old limit was too aggressive), but a behavior change at this scope belongs in the PR description so reviewers can evaluate the prompt-mass tradeoff deliberately. The validation evidence (49-tool server, Gemma-4-E4B) is buried in a config comment — move it to the PR description or at minimum to a changelog entry.


Nits

🟢 _disconnect_cached_agent logs at DEBUG — upgrade to WARNING (src/gaia/ui/_chat_helpers.py:466)

logger.debug("MCP disconnect failed during cache eviction: %s", exc)

MCP subprocess cleanup failures on cache eviction are silent in production (DEBUG is off by default). logger.warning matches the __del__ pattern in mixin.py and the GAIA "fail loudly" convention.

            logger.warning("MCP disconnect failed during cache eviction: %s", exc)

🟢 [SYSTEM: Tool call complete...] string leaks into multi-turn history (src/gaia/agents/base/agent.py:1919–1927)

block["text"] += (
    "\n\n[SYSTEM: Tool call complete. "
    "Write your one-sentence response to the user now. "
    "Do not call any more tools.]"
)

This is appended to the tool-result message that goes into messages. For single_tool_per_turn=True agents this is a single-turn-only concern, but the decorated string will appear in the role-history that subsequent audit/replay tools read. A named constant would also make it easier to grep for and strip in test fixtures.

🟢 test_caller_must_unwrap_previous_outputs_before_passing asserts success on wrapped input (tests/unit/agents/test_loop_break_truthful.py:163)

The test name describes a caller contract (must unwrap), but the assertion proves the WRONG behaviour: assert "Task completed" in summary confirms that passing wrapped dicts silently produces a false-success message. Any future call-site that forgets to unwrap will produce a lie and this test will pass. Consider renaming to test_wrapped_dicts_fall_through_to_success_illustrating_required_unwrap and adding a comment that this is a "trap test" documenting the wrong behaviour, not endorsing it.


Strengths

  • P2 fix (model_id ordering) is textbook: one-line move, comment explains the exact race, regression test asserts the prompt invariant. The AST-based source invariant (test_agent_source_invariants.py) is a creative addition that prevents the lie-on-loop literal from drifting back without anyone noticing.
  • P1 fix (bare-prefix + silent-success lie) consolidates two identical hard-coded strings into _build_loop_break_summary and properly branches on error vs success. The "no-echo" invariant — bad tool name must not be re-quoted in the error message — is a thoughtful UX detail that directly breaks the model's reinforcement loop.
  • MCP cleanup (_disconnect_cached_agent) plumbs disconnect through all four eviction paths (model change, agent-type change, LRU, explicit eviction) — previously leaked subprocess handles on every cache miss.
  • Deleted artifacts (eval_run_report.md, monitor_log.md, ARCHITECTURE_ANALYSIS.md) — the net −413 lines is a genuine improvement to repo navigability. The .gitignore entries for vendor-coupled scenarios are exactly the right boundary between public and internal eval infrastructure.
  • sanitise_server_name idempotency loop is concise and correct; the _logged_sanitisations warn-once guard is the right default for a configuration-level advisory.

Verdict

Request changes. The --reset-between-scenarios stub and the untested analyze_failures.py are 🟡-blocking. Both are straightforward fixes: stub → remove or gate with NotImplementedError; analyze_failures → smoke test with a synthetic trace dict. Everything else is polished and the core fixes (P1–P4) are merge-ready.

Address PR #718 bot review:

- cli.py --reset-between-scenarios / --lemonade-model / --lemonade-ctx-size:
  these were accepted by the parser but never plumbed into
  AgentEvalRunner — silent no-ops. Per CLAUDE.md fail-loudly, raise
  NotImplementedError when any of them is set with a message pointing
  users at driver-script-level restarts in the meantime. The help text
  now opens with [NOT YET IMPLEMENTED] so --help reflects reality.
- tests/unit/eval/test_analyze_failures.py: new file. 22 tests cover
  _derive_tool_and_style, _parse_tool_timestamp (with tz offset),
  correlate_log_to_turn (all three bucket paths),
  _bucket_counts (including the no_log_data fallback),
  build_failure_records (pass/fail), build_per_tool_report
  (iteration aggregation + meets_gate), _self_check (with and without
  --expected-tool-count), and _fmt_pct.
- ui/_chat_helpers.py: upgrade the cache-eviction disconnect failure
  from logger.debug to logger.warning — a leaked MCP subprocess is
  observable (stuck port, orphaned process) and worth surfacing.
- agents/base/agent.py: extract the
  "[SYSTEM: Tool call complete...]" suffix into a module-level
  _SINGLE_TOOL_DONE_SUFFIX constant so it's greppable and strippable
  from test fixtures that record role history.
- tests/unit/agents/test_loop_break_truthful.py: rename the wrapped-
  dicts test to the self-describing
  test_wrapped_dicts_fall_through_to_success_illustrating_required_unwrap
  and frame it as a "trap test" — documents the wrong behaviour to
  pin the caller-unwrap contract, not endorsement.
@github-actions

Copy link
Copy Markdown
Contributor

This is a well-engineered PR that ships real framework fixes (P1–P3 in the author's taxonomy) alongside the eval harness that found them. The five bugs are clearly described, the fixes are targeted, and the 113-unit-test suite is unusually thorough. Two items need attention before merge.


Issues

🟡 mcp_tool_limit default 10 → 100 is a silent behavioral change (src/gaia/agents/chat/agent.py)

The old limit was _MCP_TOOL_LIMIT = 10, hardcoded with the comment "context bloat guard." This PR bumps the default to 100. That's a 10× increase that affects every user who has an MCP server registered in ~/.gaia/mcp_servers.json, without any opt-in — servers with 11–100 tools that were previously silently skipped now load in full.

The PR comment correctly notes "For larger tool sets the prompt bloat can hurt small-model accuracy" and "keep this as low as the workflow allows" — but then ships 100 as the default. The validation cited (a 49-tool server on Gemma-4-E4B) is one data point on one model; existing users on Qwen3.5-35B, Gemma-4-E4B, or smaller models may see prompt-mass regressions.

This warrants either: (a) a smaller validated default (e.g. 50) that's still an improvement over 10 and leaves room to tune up, or (b) an explicit statement in the PR body that existing MCP users should re-run evals with their tool sets, so the reviewer can make an informed decision.

🟡 _inject_recovery_plan is called from non-planning loop paths with no test coverage (src/gaia/agents/base/agent.py)

_inject_recovery_plan sets execution_state = STATE_EXECUTING_PLAN and resets current_step = 0. It is wired into all three _post_process_tool_result call sites — including the native-tool-call path and the native-fanout path — which are NOT inside an existing STATE_EXECUTING_PLAN execution context.

Today this is safe because no subclass returns a non-None plan from _post_process_tool_result (both BlenderAgent and ChatAgent just call super() which returns None). But when the first subclass uses this API, if it triggers mid-fanout, the state machine transition is untested. There's no test that exercises _inject_recovery_plan via the hook at any of the three call sites.

Suggested fix: add a guard in _inject_recovery_plan that logs a warning (or raises) if called while execution_state is already STATE_EXECUTING_PLAN or in the fanout path — or add a note to the docstring that callers must only return a plan from the sequential _post_process_tool_result context.

🟢 Three stale pylint: disable-next=assignment-from-none comments (src/gaia/agents/base/agent.py)

                    _next_plan = self._post_process_tool_result(
                        tool_name, tool_args, tool_result
                    )

The base class now returns Optional[List[Dict[str, Any]]] instead of None, so pylint will no longer flag assignment-from-none on these three call sites. The # pylint: disable-next=assignment-from-none comments can be removed (lines ~2397, ~3395, ~3613).

🟢 .gitignore missing newline at EOF (lines 19–20 in the diff)

eval/scenarios/mcp_tool_reliability/
tests/unit/eval/test_mcp_tool_reliability_scenarios.py

The added block ends without a trailing newline. Both existing gitignore entries and the eval/monitor_log.md deletion have the same issue — worth a one-line fix to keep git diff clean.

🟢 Stub CLI flags raise NotImplementedError on use (src/gaia/cli.py)

--reset-between-scenarios, --lemonade-model, and --lemonade-ctx-size are added to the parser but raise NotImplementedError when passed any value. The error is loud (consistent with CLAUDE.md) and well-documented. However, CLAUDE.md also says "no half-finished implementations." The approach is defensible as API reservation — just flag it so the reviewer makes a conscious decision to accept it.


Strengths

  • model_id-before-_register_tools() fix (P2) is clean and correctly diagnosed. The regression test in test_response_format.py (test_json_envelope_suppressed_for_tool_calling_models) is a model example of how to pin a subtle ordering bug — it will prevent the exact same regression from re-entering silently.

  • _build_loop_break_summary + AST invariant test is a creative guard. Writing a structural test that parses the source AST to assert the literal "Task completed with" appears in exactly one place inside the helper — rather than relying on behavioural tests that the literal is in the right branch — is the right call for this class of lie-on-loop bug. Keeps the guarantee live even if the method is refactored.

  • MCP subprocess cleanup on cache eviction (_disconnect_cached_agent) closes a real resource leak. The three _agent_cache.pop() + _disconnect_cached_agent() call sites in _chat_helpers.py are consistent and the __del__ hardening in MCPClientMixin correctly swallows errors without suppressing them.

  • sanitise_server_name test matrix (12 parametrized cases + idempotency + warn-once isolation via autouse fixture) is thorough. The _reset_logged_sanitisations fixture clearing the module-level set before/after each test correctly solves the only shared-state concern.


Verdict

Approve with suggestions. The two 🟡 items (default limit change and untested _inject_recovery_plan call sites) are not blocking if the author can address the mcp_tool_limit concern in a comment or the PR description and acknowledges the _inject_recovery_plan limitation in the docstring. The rest of the fixes are solid.

… injection

Address PR #718 bot review:

- ChatAgentConfig.mcp_tool_limit + --mcp-tool-limit CLI default: drop
  100 → 50. 50 covers the 49-tool MCP server already validated in this
  PR at 100% pass rate on Gemma-4-E4B (one tool of headroom), and is
  still 5× the previous 10. Workflows with >50 tools should override
  explicitly and warrant a fresh eval on the target model. Both
  config-class default and the CLI argparse default + help text are
  updated in lockstep.
- Agent._inject_recovery_plan: add a warning log + docstring caveat
  when called while already in STATE_EXECUTING_PLAN (in-flight plan
  gets replaced — usually a subclass-hook bug, not intent). The
  docstring also flags that fanout/native-batch call sites haven't
  been exercised here.
- Sharpened the 3 pylint: disable-next=assignment-from-none comments
  to explain WHY they're needed (pylint infers None from the base
  body even though the annotation is Optional[List[...]]). The bot
  review's suggestion to delete them was incorrect — pylint still
  flags the call sites without them.
- .gitignore: append missing trailing newline at EOF for cleaner
  future diffs.
@itomek itomek added this pull request to the merge queue May 21, 2026
Merged via the queue into main with commit f8b2c1a May 21, 2026
42 checks passed
@itomek itomek deleted the feat/issue-709-mcp-reliability branch May 21, 2026 16:38
@itomek itomek mentioned this pull request May 21, 2026
6 tasks
antmikinka pushed a commit to antmikinka/gaia that referenced this pull request May 21, 2026
# GAIA v0.19.0 Release Notes

GAIA v0.19.0 tightens the agent loop against silent-failure regressions
and continues the focused-agent split. A new `gaia eval agent`
reliability harness exercises tool selection end-to-end against the
local Agent UI MCP backend and surfaced four agent-loop bugs that
previously produced silently-wrong "Task completed" answers — all four
are fixed in this release. Dedicated `BrowserAgent` and `AnalystAgent`
replace the ChatAgent-backed web and data profiles. Agents can now
declare a `REQUIRED_HARDWARE` capability tier that is validated at
startup against the running Lemonade server. GAIA can connect to a
remote Lemonade Server protected by an API key. And a new CI job
auto-implements PRs for bulletproof bug issues in parallel with the
existing triage path.

**Why upgrade:**
- **MCP tool-calling reliability framework + four framework fixes** —
`gaia eval agent` runs a user-simulator + judge against the local MCP
backend; the harness uncovered and fixed silent loop-break lies, a
~85-token JSON-envelope leak in tool-calling-model prompts, a missed
native-path failure check, and an over-broad eval rubric.
- **Specialized agents** — `BrowserAgent` and `AnalystAgent` ship as
dedicated implementations behind the `gaia browse` and `gaia analyze`
CLIs, replacing the monolithic ChatAgent-backed profiles.
- **Hardware-requirement validation** — agents can declare
`REQUIRED_HARDWARE` and fail fast at startup when the host's device tier
(CPU/iGPU/dGPU/NPU/hybrid) does not satisfy it, instead of silently
degrading.
- **Remote Lemonade auth** — setting `LEMONADE_API_KEY` threads
`Authorization: Bearer <key>` through every Lemonade-bound HTTP path;
wrong/missing key surfaces an actionable error naming the variable.
- **CI auto-fix for bulletproof bug issues** — bug-labelled issues that
meet the "1-2 files, < 50 lines, 100% confidence" bar now get an
auto-implemented PR in parallel with the standard triage comment.


## What's New

### MCP Tool-Calling Reliability Framework

A new end-to-end eval harness lands at `src/gaia/eval/runner.py` (PR
[amd#718](amd#718)) and is invoked via `gaia
eval agent`. The runner spawns a subprocess that acts as user-simulator
+ LLM judge against the live Agent UI MCP backend, exercises 10 generic
scenarios (no-param, single-param, multi-step, conditional,
error-handling, "no tool needed"), and produces per-tool failure rollups
via `analyze_failures.py`. The judge rubric in `judge_turn.md` grades on
tool selection alone for `verbatim`-tagged scenarios — it explicitly
does not penalize the agent for underlying-service failures, which had
been conflating pipeline correctness with hardware availability.

Running the harness against the agent loop immediately exposed four
framework regressions, all fixed in this release:

- **Silent loop-break lie.** When small local models emitted just the
server prefix (`mcp_foo_mcp`) instead of the full registered tool name,
the agent retried four times and then hard-coded `"Task completed with
mcp_foo_mcp. No further action needed"` as the final answer despite zero
successful tool calls. Server-name sanitisation now strips redundant
`mcp` tokens before namespacing, and a new
`Agent._build_loop_break_summary` helper branches on whether the last
result was an error so the user sees the actual error wording. A new AST
guard in `tests/unit/agents/test_agent_source_invariants.py` prevents
the lie-on-loop literal from reappearing.
- **JSON envelope leak in tool-calling-model prompts.** The `{"tool":
..., "tool_args": ...}` template was supposed to be suppressed for
models that support native `tools=[]` calling, but `self.model_id` was
assigned *after* `_register_tools()` ran, so the suppression check
always returned False and ~85 redundant tokens shipped in every prompt.
Moving the `model_id = model_id` assignment ahead of registration closes
the gap; a regression test asserts the `==== RESPONSE FORMAT ====` block
does not appear in a tool-calling agent's system prompt.
- **Loop-break helper missed the native path.** The shared helper looked
at `step_results[-1]` to detect a failure streak, but the
native-tool-call path appends to `previous_outputs` instead (wrapper
dicts). On native callers the helper saw an empty list, missed every
failure, and returned `"Task completed"`. The fix unwraps
`previous_outputs` at the call site; the regression test exercises the
helper with a sequence of error results.
- **Eval rubric conflated pipeline and hardware.** Some MCP services
wrap real operation failures in a `status: success` envelope with the
failure buried in `data.content[*].text`. On dev hardware where
vendor-side features don't all work, the judge graded the agent as
failing even when it picked the right tool. The new STEP 0 in
`judge_turn.md` overrides the rubric for `verbatim`-tagged scenarios —
`correctness = 10` if the right tool was invoked, `0` if the wrong tool
was, regardless of underlying op success.

Eight new unit-test files cover the sanitiser matrix, loop-break helper,
candidate-list invariant, scenario validation, and `--iterations` flag.


### Dedicated Browser and Analyst Agents

The Agent UI's web and data entries previously routed to `ChatAgent`
profiles, which meant they carried the full monolithic agent surface
instead of the focused tool sets the use cases actually need. PR
[amd#1070](amd#1070) adds dedicated
`BrowserAgent` and `AnalystAgent` implementations and wires the built-in
web/data registrations (plus their `lite` variants) to them. Two new CLI
subcommands ship alongside: `gaia browse` for the browser flow and `gaia
analyze` for the analyst flow. Both compose the relevant tool mixins
explicitly rather than inheriting everything from the ChatAgent shim.


### Hardware-Requirement Validation for Agents

Agents can now declare a hardware capability tier via `REQUIRED_HARDWARE
= HardwareRequirement(min_device=...)` (PR
[amd#1057](amd#1057)). At agent startup,
`LemonadeManager.ensure_ready(required_min_device=...)` queries the
running Lemonade server through `LemonadeClient.get_system_info()` and
raises `HardwareRequirementError` if the host's reported device tier
does not satisfy the declaration. NPU-only or dGPU-only agents fail fast
at construction time instead of silently degrading at first inference.

This is Phase 1 — validation only. The resolved `recipe` is computed and
logged for debugging but is *not* applied to the Lemonade server startup
path; a follow-up will wire the resolved recipe through if the project
chooses to.


### `LEMONADE_API_KEY` for Authenticated Remote Lemonade

Before this release, GAIA could not connect to a remote Lemonade Server
protected by an API key — every request returned `401` regardless of the
user's configuration. PR [amd#1149](amd#1149)
(closes [amd#1139](amd#1139)) threads
`LEMONADE_API_KEY` (from `.env` or the shell environment) as
`Authorization: Bearer <key>` through every Lemonade-bound HTTP path in
GAIA: the central `LemonadeClient._send_request`, the four
`requests`-bypass sites, both OpenAI-SDK constructor sites,
`LemonadeProvider`, `VLMClient`, the Agent UI router (`system.py`),
Agent UI chat helpers, the server startup probes, and the base `Agent`
health probe.

Behaviour is fully additive — when the env var is unset, every call path
behaves identically to v0.18.1. A wrong or missing key produces a
fixed-string error naming `LEMONADE_API_KEY` (the response body is
intentionally not echoed back, to avoid leaking reflected
`Authorization` headers from misconfigured proxies).


### CI Auto-Fix for Bulletproof Bug Issues

PR [amd#1159](amd#1159) adds a Claude-powered
auto-fix job to `.github/workflows/claude.yml` that runs in parallel
with the existing `issue-handler` triage. When a new issue lands with
the `bug` label, Claude reads the report, and if the fix passes a strict
"bulletproof" bar — 1–2 files touched, fewer than 50 lines changed, 100%
confidence, no hardware-dependent verification needed — it implements
the change, validates with `python util/lint.py` and the relevant unit
tests, opens a PR, and posts the PR link plus step-by-step verification
instructions back on the originating issue. Bug reports that don't meet
the bar still get the standard triage comment from `issue-handler`; the
auto-fixer exits silently for the rest.


## Bug Fixes

- **BrowserAgent and AnalystAgent crashed instantly in the Agent UI**
(PR [amd#1202](amd#1202)) — Selecting either
of the new split agents and sending any message raised `AttributeError:
'<Agent>' object has no attribute '_mcp_manager'`. The MCP client
mixin's optional attribute was never initialised on the split agents,
and `get_mcp_status_report()` — invoked on every `/api/chat/send` — hit
the undefined dereference. The two-layer fix adds a class-level
`_mcp_manager: Optional[MCPClientManager] = None` on `MCPClientMixin`
(covering every future agent that inherits it) plus explicit per-agent
initialisation in the five affected agents (documenting intent at the
point of use). CLI paths were unaffected — only the Agent UI's
`/api/chat/send` triggered the bug.
- **`LEMONADE_BASE_URL` env var normalisation** (PR
[amd#1160](amd#1160)) — Setting
`LEMONADE_BASE_URL` to a value without the trailing `/api/v1` suffix
produced 404s deep in the request pipeline. The variable is now
normalised on load so both `http://host:8000` and
`http://host:8000/api/v1` resolve to the same canonical form.
- **Custom agents on disk now visible to the Agent UI** (PR
[amd#1138](amd#1138)) — `~/.gaia/agents/`
registrations were not surfacing to the Agent UI's agent list because
the registry discovery pass skipped the disk source on cold start. The
fix lists custom-directory agents alongside the built-ins on every
request.
- **`pr-review` CI re-enabled and credit-resilient** (PR
[amd#1163](amd#1163)) — The Claude-based
`pr-review` job had been disabled after an Anthropic credit window; all
Claude-backed jobs now treat 429 quota errors as non-fatal warnings so
CI keeps moving when the project hits its quota.
- **Silent skips in `test_sdk.py` removed** (PR
[amd#1190](amd#1190)) — Tests that depended on
an environment fixture were silently `pytest.skip()`-ing instead of
being explicitly conditional. The skips are now gated on the actual
fixture and surface as `XFAIL` when intentionally inapplicable. Closes
the first slice of [amd#877](amd#877).
- **`refresh-context7` fails loudly on unexpected status** (PR
[amd#1073](amd#1073)) — The terminal job of
the publish workflow previously masked non-cooldown HTTP responses as
"OK". It now distinguishes the known HTTP 400 cooldown window from any
other status code, so a regression that breaks the Context7 refresh is
no longer indistinguishable from the documented cooldown.


## Tooling & Docs

- **CLI smoke-test for every subcommand and console script** (PR
[amd#1193](amd#1193)) — Every `gaia
<subcommand>` and every console script declared in `setup.py` is now
exercised with `--help` in CI, catching import-time regressions and
shadowed entry points before they ship.
- **Fail-path coverage for `GovernedAgentMixin` and `CheckpointBridge`**
(PR [amd#1161](amd#1161)) — Adds unit tests
for the error/abort branches of the governance layer that were
previously only exercised on the happy path.
- **Custom-agent MCP harness for installer testing** (PR
[amd#1069](amd#1069)) — A reproducible
installer-level MCP harness for custom agents, so installer-affecting
changes are tested against the real agent-registration path rather than
mocks.
- **Dependabot revived, agent-ui entry added, patch auto-merge shipped**
(PR [amd#1191](amd#1191)) — Dependabot PRs are
flowing again, the Agent UI npm workspace is now covered, and patch-bump
PRs that pass CI auto-merge.


## Full Changelog

**17 commits** since v0.18.1:

- `6c6e4c34` — fix(agents): unbreak BrowserAgent/AnalystAgent in Agent
UI (amd#1202)
- `6379e183` — feat(agent-hub): discover installed agent entry points
(amd#1187)
- `a79acc88` — test(cli): smoke-test every subcommand and console script
with --help (amd#1193)
- `f7a75902` — ci(dependabot): revive PRs, add agent-ui entry, ship
patch auto-merge (amd#1191)
- `70d75b70` — fix(tests): remove silent skips in test_sdk.py (amd#877 Part
A) (amd#1190)
- `f8b2c1ab` — feat: MCP tool calling reliability test framework (amd#718)
- `f4270687` — feat(agent-hub): Agent Hub UI + platform plan + hub
skeleton (amd#1103)
- `6ef1feee` — fix(llm): normalize LEMONADE_BASE_URL env var to include
/api/v1 suffix (amd#1160)
- `72b77167` — fix(ci): re-enable pr-review and make all Claude jobs
credit-resilient (amd#1163)
- `b46bf654` — fix(agent-ui): list custom agents from disk (amd#1138)
- `63aedb47` — test: add fail-path coverage for GovernedAgentMixin and
CheckpointBridge (amd#1161)
- `7b8f22c0` — feat(llm): support LEMONADE_API_KEY for authenticated
remote Lemonade (amd#1149)
- `b22fa73d` — feat(ci): auto-fix job for bulletproof bug issues (amd#1159)
- `6b7b9e7d` — fix(ci): make refresh-context7 fail loudly on unexpected
status (amd#1073)
- `e2d4e2d7` — feat(sdk): hardware requirement validation for agents
(amd#1057)
- `1a73dcc5` — feat(agents): add browser and analyst agents (amd#1070)
- `2554424b` — test(installer): add custom agent MCP harness (amd#1069)

Full Changelog:
[v0.18.1...v0.19.0](amd/gaia@v0.18.1...v0.19.0)

## Release checklist

- [x] `util/validate_release_notes.py docs/releases/v0.19.0.mdx` passes
- [x] `src/gaia/version.py` → `0.19.0`
- [x] `src/gaia/apps/webui/package.json` → `0.19.0`
- [x] Navbar label in `docs/docs.json` → `v0.19.0 · Lemonade 10.2.0`
- [x] All 17 commits in range (v0.18.1..HEAD) are represented in the
notes
- [ ] Review from @kovtcharov-amd addressed
antmikinka pushed a commit to antmikinka/gaia that referenced this pull request May 21, 2026
…ariant (amd#1203)

Build Installers on the merged [v0.19.0 release
commit](amd@6f8bba0)
fails on both `ubuntu-latest` and `windows-latest` legs of the **Custom
agent MCP harness** matrix because
[`tests/installer/test_custom_agent_mcp_harness.py:199`](https://github.com/amd/gaia/blob/main/tests/installer/test_custom_agent_mcp_harness.py#L199)
still encodes the pre-amd#718 error wording. PR
[amd#718](amd#718) deliberately replaced
`"Tool '<name>' not found"` with the generic `"Unknown tool name. Use
only tools listed in your AVAILABLE TOOLS section."` to prevent small
models from interpreting the echoed bad name as self-confirmation and
looping on it — that's the "no-echo invariant on the candidate-list
message" called out in v0.19.0's release notes. The test from
[amd#1069](amd#1069) missed the migration.

This is a one-line test update to the second assertion in
`test_custom_agent_with_mcp_reports_diagnosable_connection_failure`. The
test's "diagnosable connection failure" intent is preserved by the
**first** assertion in the same function, which still verifies
`get_mcp_status_report()` surfaces the transport-level error (`"MCP
server process died (exit code: 17)"`) — that's the actual user-facing
diagnostic for a dead MCP server.

This unblocks the v0.19.0 release
([amd#1201](amd#1201)) — pre-tag verification
requires a green Build Installers run on the merged commit, which
currently fails because of this single assertion.

## Test plan

- [x] `python -m pytest tests/installer/test_custom_agent_mcp_harness.py
-xvs` — 3/3 pass locally
- [x] Lint: change is a string-literal update; no formatter impact
- [ ] Build Installers run on this branch goes green
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents cli CLI changes dependencies Dependency updates devops DevOps/infrastructure changes documentation Documentation changes electron Electron app changes eval Evaluation framework changes mcp MCP integration changes performance Performance-critical changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants