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

Skip to content

test(eval): behavior-E2E harness — assert tool side-effects against real model#1431

Merged
itomek merged 15 commits into
mainfrom
test/1429-agent-behavior-e2e-harness
Jun 4, 2026
Merged

test(eval): behavior-E2E harness — assert tool side-effects against real model#1431
itomek merged 15 commits into
mainfrom
test/1429-agent-behavior-e2e-harness

Conversation

@itomek

@itomek itomek commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Both unit tests (mocked LLM) and UI-render E2E tests passed while the builder silently failed to create agents ~40% of the time (#1428). The failure class — "agent claims success but the tool never ran" — is invisible to tests that mock the model or only check that the UI rendered a reply.

This adds a reusable behavior-E2E harness that catches it: drive a tool-using agent through the real server with a real model, repeat each scenario N×, and assert the tool's side-effect actually occurred (file on disk, registry entry). A "success" reply with no side-effect is a hard failure. Built-in scenario: builder — plants an unguessable agent name per run, confirms ~/.gaia/agents/<id>/agent.py exists and appears in GET /api/agents.

In-CI execution is deferred to #1297 (self-hosted Strix Halo runner); the workflow YAML is present and ready. Validated out-of-band on Strix Halo class hardware.

Test plan

  • pytest tests/unit/eval/test_behavior_harness.py — 24 deterministic unit tests (no model, no network) — PASS
  • Harness GREEN on fixed branch vs Qwen3.5-35B-A3B-GGUF — 5/5 builder runs write the file; test_builder_creates_agent_file PASSED in 244s (5 agents: test-721e1b6e, test-a40e71cb, test-f85a63ec, test-dcfdbf16, test-2718354e)
  • RED regression proof — deterministic unit tests run against the pre-fix parser: test_zephyr_regression_fixture and 6 additional TestSingleFencedCandidate tests FAIL on reverted code; all 14 PASS on fixed code
  • pyproject.toml registers real_model marker; pytest --strict-markers passes — PASS
  • .github/workflows/test_agent_behavior_e2e.yml present with builder path filter and [self-hosted, strix-halo] runner

Note on stochastic RED: a live 5-run batch with the pre-fix parser can pass if the model uses bare tool calls (P ≈ 8% per batch). The deterministic RED proof is the unit test suite — test_zephyr_regression_fixture replays the verbatim Zephyr capture and asserts tool=None without the fix, tool="create_agent" with it.

Stacks on / Closes #1429
Related: #1428, #883, #1297

itomek added 4 commits June 4, 2026 09:47
_extract_embedded_tool_call now collects ALL {…} candidates tagged
fenced/unfenced and applies a 4-rule decision:
  1. ≥1 unfenced → return the first (unchanged, zero regression).
  2. exactly 1 fenced → return it (fixes the Zephyr capture case).
  3. >1 fenced + 0 unfenced → ambiguous → None + logger.warning.
  4. else → None.

Closes #1428.
…o docstring/prompt

Builder now detects when the LLM emits hallucinated success markers (✅,
"Agent Created", "File location") without calling create_agent, injects a
corrective turn, and loops. If create_agent returns "Error:…" the loop ends
immediately with an honest failure — never a fabricated success message.

System prompt and create_agent docstring list the 4 missing built-in tools:
code_index, filesystem, scratchpad, browser.

The "output ONLY the bare JSON object" rule is added to the system prompt to
directly attack the narrate-then-fence-then-fabricate pattern.
Each failed discover() / register_from_dir() attempt now stores the error
reason in _load_errors (agent_id → "type: message"). The public
get_load_error(agent_id) accessor lets callers (chat helpers, future UI)
surface a meaningful reason instead of just "unknown agent".

One broken agent still does not block discovery of other agents.
All 4 sites in _chat_helpers.py that previously did agent_type="chat" on
a registry miss now surface a friendly message via _agent_unavailable_message()
instead of silently impersonating a different agent.

Non-streaming: returns the error string directly from _do_chat.
Streaming: emits a final SSE answer event and returns from _run_agent.

When the registry recorded a load error for the agent, the reason is included
in the message so the user knows why it failed.
@github-actions github-actions Bot added documentation Documentation changes dependencies Dependency updates devops DevOps/infrastructure changes eval Evaluation framework changes tests Test changes performance Performance-critical changes labels Jun 4, 2026
@itomek itomek self-assigned this Jun 4, 2026
@itomek itomek marked this pull request as ready for review June 4, 2026 15:27
@itomek itomek requested a review from kovtcharov-amd as a code owner June 4, 2026 15:27
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review: behavior-E2E harness (#1431)

Summary — Solid, well-targeted addition. The side-effect-not-reply premise, planted unguessable tokens, N× repetition, and false_success-as-hard-fail directly attack the #1428 bug class ("agent claims success, tool never ran"), and the classification logic is cleanly split from the live BehaviorHarness so 24 deterministic unit tests cover it without a server. All four REST contracts the harness calls were verified against the real UI server and match (/api/sessionsid, /api/chat/sendcontent, /api/agentsagents[].id, /api/health). No security concerns, no blocking issues in the runtime path. The one thing worth doing before merge: stop reimplementing the builder's agent-id normalization and import the real function — a verification harness that derives its expected path with a copy of the logic it's verifying can silently drift into green. The two CI-workflow bugs below are latent (the workflow is gated off until #1297) but will break the step the day it activates.

Issues

🟡 Side-effect check duplicates the builder's slug logic instead of reusing it (src/gaia/eval/behavior_harness.py:246-249, again at 418-422)
The real builder already exposes _normalize_agent_id (src/gaia/agents/builder/agent.py:355), and crucially runs _split_camel_case(name.strip()) before it (agent.py:392-395). The harness reimplements only the normalize half, inline, in two places — so the docstring claim "Derive the agent id the same way the builder does" is already not quite true, and if the builder's rules change the check will look in the wrong directory and emit wrong verdicts. That's the exact false-signal failure mode this harness exists to prevent. For the current test-<hex> tokens it happens to agree, so it's a latent risk, not a live bug — but reuse removes both the risk and the duplication. The unit-test docstring (test_behavior_harness.py:793) already refers to _normalize_agent_id() as the canonical function; make it real:

from gaia.agents.builder.agent import _normalize_agent_id, _split_camel_case
...
agent_id = _normalize_agent_id(_split_camel_case(planted_name.strip()))

Apply in both _builder_side_effect_check and the registry-recheck block in run_scenario.

🟡 gaia init --check is not a valid flag (.github/workflows/test_agent_behavior_e2e.yml:30)
init has no --check; the closest is --skip-lemonade ("Skip Lemonade installation check"). As written, argparse exits non-zero and the step fails. The workflow is gated off until #1297, so this isn't breaking CI today — but it'll break the step the moment it activates. Likely intended gaia init --skip-lemonade (or a Lemonade health probe).

🟡 Workflow venv is created but never activated (.github/workflows/test_agent_behavior_e2e.yml:25-35)
uv venv && uv pip install -e ".[dev]" runs in one step; the later gaia init … and python -m pytest … steps run in fresh shells with no source .venv/bin/activate (or VIRTUAL_ENV/uv run), so gaia and the installed package won't be on PATH. Same caveat: deferred, but won't run as-is. Prefer uv run python -m pytest … and uv run gaia …, or set VIRTUAL_ENV once.

🟢 rstrip("/api/v1") mangles non-default URLs (tests/integration/eval/test_behavior_e2e.py:521)
str.rstrip treats the argument as a char set, not a suffix — it works for the default URL by luck but corrupts any base URL whose host/port ends in a/p/i/v/1/. Use:

    health_url = base_url.removesuffix("/api/v1").rstrip("/") + "/api/v1/health"

🟢 except Exception: return False is a silent fallback (src/gaia/eval/behavior_harness.py:377)
GAIA's "fail loudly" rule (CLAUDE.md) applies repo-wide, eval infra included. Here a server error is swallowed and read as "agent not listed", quietly downgrading a run to honest_failure and masking the very kind of infra problem the harness should surface. At minimum log it with context — mirror the exc_info=True pattern you already use in run_scenario (behavior_harness.py:432).

🟢 Dead field Scenario._planted_names (src/gaia/eval/behavior_harness.py:217)
Declared but never read or written — the harness re-extracts the planted name from the prompt via regex (behavior_harness.py:406) instead. Drop it, or have prompt_factory return (prompt, planted_name) so the regex round-trip (and its quoting coupling at :406/:801) goes away entirely.

Strengths

  • The core idea is right and the design supports it. Verdict taxonomy (true/false_success, honest_failure, error) with false_success dominating aggregation is the correct shape, and the aggregation rules are exhaustively unit-tested (TestAggregateVerdicts).
  • Testability-first split: pure helpers (_success_markers, _aggregate_verdicts, side_effect_check) vs. the live BehaviorHarness, so 24 tests run with no model/network — and the real_model marker is registered in pyproject.toml with an auto-skip fixture, so normal CI stays green.
  • Docs landed with the code (docs/sdk/testing.mdx) and clearly explain the false-success rationale and how new agents plug in a Scenario.

Verdict

Approve with suggestions. Nothing blocks the runtime path. Please reuse _normalize_agent_id/_split_camel_case before merge (cheap, and it closes the harness-drift gap), and fix the two workflow bugs so the step is correct when #1297 turns it on.

itomek added 2 commits June 4, 2026 11:33
…d-error key

Addresses two issues raised in PR review:

- The fabrication-marker check ("Agent Created", "✅", "File location") fired
  on every tool-less turn, including the post-success summary turn after
  create_agent already ran. A natural "✅ Your agent is ready" summary was
  flagged as fabricated → corrective turn → model called create_agent again →
  "already exists" error → builder reported failure despite the agent
  having been created successfully.

  Fix: track created_ok=True when create_agent returns a non-error result.
  Post-success turns bypass the fabrication check and return the summary directly.

- get_load_error() docstring now notes that errors are keyed by directory
  name so callers understand when a type-string lookup may return None.
Base automatically changed from fix/1428-builder-silent-tool-call-drop to main June 4, 2026 15:48
kovtcharov-amd
kovtcharov-amd previously approved these changes Jun 4, 2026

@kovtcharov-amd kovtcharov-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Test/CI/docs only, stacks cleanly on #1430. Asserting the tool's side-effect (file on disk + /api/agents listing) rather than the reply, with planted unguessable names, is exactly the harness this failure class needed — and the pure-helper split keeps the 24 unit tests model/server-free.

Minor (non-blocking): the push: trigger targets [self-hosted, strix-halo], which doesn't exist until #1297, so pushes matching the path filters will queue a runner-less job that shows as pending in Actions. Fine since it's documented — just flagging the UI noise until the runner lands.

@itomek itomek enabled auto-merge June 4, 2026 15:50
itomek added 3 commits June 4, 2026 11:51
Mocked unit tests and UI-render E2E tests both miss the class of bug where
an agent claims success but the underlying tool never ran (reproduced in #1428).
This harness catches it by driving the real server with a real model, repeating
each scenario N× with a planted unguessable name, and asserting the file-system
side-effect occurred — not just that the agent produced a reply.

- `src/gaia/eval/behavior_harness.py`: reusable `Scenario`/`BehaviorHarness`,
  `Verdict` enum (true_success / false_success / honest_failure / error),
  `_aggregate_verdicts` with hard-fail on any false_success, builder scenario
- `tests/unit/eval/test_behavior_harness.py`: 24 deterministic unit tests,
  no model or network required, runs in normal CI
- `tests/integration/eval/test_behavior_e2e.py`: live test gated by
  `@pytest.mark.real_model` and `require_real_model` fixture (auto-skip when
  Lemonade is absent)
- `.github/workflows/test_agent_behavior_e2e.yml`: CI workflow ready for
  `[self-hosted, strix-halo]`; activates once #1297 lands
- `pyproject.toml`: registers the `real_model` pytest marker
- `docs/sdk/testing.mdx`: documents the pattern for future agents

Closes #1429
…#1429)

Follow-up to the harness commit: pyproject.toml registers the new
`real_model` pytest marker (required by --strict-markers), and
docs/sdk/testing.mdx documents the side-effect assertion pattern
for future agent contributors.
- Import _normalize_agent_id/_split_camel_case from builder instead of
  re-implementing slug logic in two places; harness now derives the
  expected agent path with the same function the builder uses
- Remove dead Scenario._planted_names field (never read or written)
- Log with exc_info=True in _agent_listed on Exception instead of
  silently returning False
- Use str.removesuffix() instead of rstrip() for /api/v1 URL strip in
  the integration test fixture
- Fix workflow: gaia init --check (invalid flag) -> gaia init --skip-lemonade
- Fix workflow: add "uv run" prefix to pytest step so the venv is
  active without a separate activate step
@itomek itomek force-pushed the test/1429-agent-behavior-e2e-harness branch from 87f4bca to 05e7429 Compare June 4, 2026 15:55
@itomek

itomek commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

All items addressed in the rebase+fix commit (05e74296):

🟡 _normalize_agent_id / _split_camel_case reuse — Both slug-derivation blocks in behavior_harness.py (_builder_side_effect_check and the registry-recheck in run_scenario) now import and call the canonical builder functions instead of re-implementing them inline.

🟡 gaia init --check — Changed to uv run gaia init --skip-lemonade (the valid flag for skipping the Lemonade install check).

🟡 Workflow venv not activated — All run: steps that need the package now use uv run prefix so no explicit source .venv/bin/activate is required.

🟢 rstrip("/api/v1") mangling — Changed to base_url.removesuffix("/api/v1").rstrip("/") in the integration test fixture.

🟢 except Exception: return False — Now logs exc_info=True via logger.warning before returning False, matching the pattern already used in run_scenario.

🟢 Dead Scenario._planted_names field — Removed from the dataclass and its docstring entry.

Also removed three unused imports (dataclasses.field, tempfile, pathlib.Path) that became dead after the above changes. 24/24 unit tests still pass.

@github-actions github-actions Bot added the agents label Jun 4, 2026
Comment thread .github/workflows/test_agent_behavior_e2e.yml Fixed
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🟡 src/gaia/eval/behavior_harness.py:276 — silent fallback violates CLAUDE.md

_agent_listed catches Exception broadly and returns False, which the harness treats as "agent not in registry → downgrade true_successhonest_failure". This masks API failures silently and is prohibited by the No-Silent-Fallbacks rule.

        except requests.RequestException as exc:
            logger.warning(
                "_agent_listed: /api/agents request failed: %s",
                exc,
                exc_info=True,
            )
            raise

Or, since this is an optional extra check after the file-exists check already confirmed success, you could simply let the exception propagate to the outer except Exception as exc in run_scenario (which logs and records Verdict.error), keeping the harness honest about infrastructure failures vs. agent failures.

@itomek

itomek commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Removed the try/except from _agent_listed entirely — exceptions from _get now propagate to the outer run_scenario handler, which logs and records Verdict.error. No silent downgrade.

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🟡 .github/workflows/test_agent_behavior_e2e.yml:41 — artifact upload path will always be empty on failure.

The workflow uploads path: /tmp/gaia-behavior-e2e-*/, but the harness writes transcripts to pytest's tmp_path / "artifacts" — a path like /tmp/pytest-0/test_builder_creates_agent_file0/artifacts/. The glob never matches, so the behavior-e2e-artifacts archive will be uploaded empty every time the test fails, making CI failures undebuggable.

          path: /tmp/pytest-*/test_builder_creates_agent_file*/**/artifacts/

Or, more robustly, pass a fixed artifact_dir that matches the upload glob:

# in test_builder_creates_agent_file
import tempfile, pathlib
artifact_dir = pathlib.Path(tempfile.mkdtemp(prefix="gaia-behavior-e2e-")) / "artifacts"

then the original glob /tmp/gaia-behavior-e2e-*/ works as intended.

@itomek

itomek commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed — updated the upload glob to /tmp/pytest-*/test_builder_creates_agent_file*/**/artifacts/ which matches pytest's actual tmp_path layout.

@itomek itomek added this pull request to the merge queue Jun 4, 2026
Merged via the queue into main with commit 91b476e Jun 4, 2026
39 checks passed
@itomek itomek deleted the test/1429-agent-behavior-e2e-harness branch June 4, 2026 18:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents dependencies Dependency updates devops DevOps/infrastructure changes documentation Documentation changes eval Evaluation framework changes performance Performance-critical changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent-behavior E2E: assert tools actually execute against a real model (catch silent tool-call drops)

4 participants