Skip to content

Navigation Menu

Sign in
Sign up

feat(agent): state verification scope on every emitted answer - #3401

Open
kovtcharov-amd wants to merge 2 commits into
main from
feat/3376-verification-scope
Open

feat(agent): state verification scope on every emitted answer #3401
kovtcharov-amd wants to merge 2 commits into
main from
feat/3376-verification-scope

Conversation

@kovtcharov-amd

@kovtcharov-amd kovtcharov-amd commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

The agent said "done" in exactly the same confident language whether it ran the
test suite or ran nothing at all, so there was no way to tell a checked result
from an unchecked one without going and looking — and the rational response was
to distrust every completion message. Every answer now ends with one short line
saying which: verified (a test/lint/build ran and passed), partially
verified
(checks ran, not all passed), or unverified (nothing was
checked). It costs no extra model call — the signal is already in the turn's own
tool-call log — and it lands on every exit the loop has, including the LLM-error,
context-overflow, loop-break and max-steps answers that never passed through
finalize_answer and are exactly the runs most likely to be incomplete.

Fixed the off-by-one in the pagination helper.
Verification: verified — pytest ran and passed.

The line is capped at 200 characters so it cannot grow the prompt as it
accumulates in conversation history, and strip_verification_scope() removes it
for consumers that need the answer text alone.

Closes #3376.

🔍 Exit-path enumeration (#3381 consumes this)

Every path in Agent._process_query_impl that produces a final answer. Line
numbers are post-change.

Line Path In scope
6483 Parsed answer — the finalize_answer seam ✅ seam A
4650 _cancel_event timeout ("exceeded the allowed time") ✅ seam B
5087 Streaming ConnectionError ✅ B
5153 Streaming context-overflow, after the one-shot trim-and-retry ✅ B
5155 Streaming generic error ✅ B
5219 Non-streaming ConnectionError ✅ B
5301 Non-streaming context-overflow, after trim-and-retry ✅ B
5312 Non-streaming typed Lemonade user_message ✅ B
5314 Non-streaming generic error ✅ B
5383 Parse give-up after 3 errors, prior image path known ✅ B
5389 Parse give-up after 3 errors, generic ✅ B
5727 Loop-break summary, native tool-calling path ✅ B
5943 Loop-break summary, legacy path ✅ B
6606 Max-steps message (final_answer never set) ✅ B
6565 Console cancellation → {"status": "cancelled", "result": ""} ❌ excluded (#3386)
5123, 5265 Wrong-ctx_size re-raise n/a — emits no answer; the caller reloads and retries

Seam A is the finalize_answer call site; the statement goes on after the
hook, so a subclass that rewrites the answer cannot drop it. Seam B is a
post-loop catch-all guarded by a flag, so nothing is stamped twice. Both are
covered by unit tests, one per path.

Deliberately out of scope, and why:

  • BuilderAgent._process_query_impl — its own loop, not the base one. Its
    output is generated scaffolding files, not a claim about work being checked.
  • EmailTriageAgent._mailbox_target_guard — a pre-flight refusal returned
    before the loop runs. No tools executed and no completion claimed.
  • The six ad-hoc answer guards — generalising them is feat(agent): verification state before the loop accepts an answer #3381 .
🔍 What counts as a check

Tool name in {run_tests, run_test_suite, run_lint, lint, typecheck, build}, or
a command / cmd / script argument naming a known runner (pytest, tox,
ruff, mypy, eslint, tsc, npm test, cargo test, go test,
util/lint.py, ...). Deliberately conservative: a runner the pattern misses reads
unverified, which is cautious rather than wrong, whereas a false positive would
claim a check that never ran.

The classifier and the statement builder live in a new dependency-free module,
gaia/agents/base/verification.py, so the Agent-UI SSE handler can use them
without importing the 7k-line agent.py.

Test plan

  • python -m pytest tests/unit/agents/test_verification_scope.py — 45 tests:
    the three states, the 200-char bound, and one case per in-scope exit path,
    plus the cancelled-turn exclusion.
  • python -m pytest tests/unit/ -q — 10723 passed. This machine has a
    large pre-existing environmental failure set (router tests blocked by the
    no-network guard), so the run was diffed against the same suite on the base
    commit in a clean worktree rather than read as pass/fail: identical failure
    sets (643 failed / 488 errors on both), and exactly 45 more passing — the
    new tests.
  • python -m pytest hub/agents/email/python/tests -q — 2036 passed; the 3
    failures reproduce unchanged on the base commit.
  • Golden path through the real Agent UI backend: POST /api/chat/send
    returns an answer event ending in Verification: unverified — no tools ran, so nothing was checked. A turn cancelled from the UI still returns
    an empty result.
  • gaia eval agent --category rag_quality --agent-type doc — judged pass
    rate 100% (7/7), avg score 9.1 → 9.7 against
    tests/fixtures/eval_baselines/gemma-4-e4b-95e4b372/scorecard_rag_quality.json
    (the ctx-65536 baseline matching this GPU profile). Every comparable
    scenario scored at or above baseline. csv_analysis reports INFRA_ERROR
    because the scenario pins agent_type: data, an agent removed from the
    registry — it fails before any agent loop runs, so it is stale harness
    config, not a regression. The comparison tool also warns that the judge
    changed since the baseline was captured (claude-sonnet-4-6
    claude-opus-5), so treat the score deltas as directional and the
    unchanged judged pass rate as the signal.
  • black, isort, pylint --errors-only clean on the touched files
    (util/lint.py itself could not run here — it shells out to uvx, and
    PyPI is unreachable from this machine).

Ovtcharov added 2 commits September 4, 2026 18:31
... the repo
Nine design and reference docs were sitting untracked in the working copy,
so the decisions they record were invisible to everyone else — including the
skill-bound task execution design that the async-task and multi-slot-broker
work is meant to build against.
Alongside them sat a 45MB mailbox corpus, agent-run captures, and internal
analysis, none of which were gitignored. `git add -A` would have committed a
mailbox to a public repo. The ignore block that already quarantines private
working reports now covers those classes too, and its pointer to where that
material lives is corrected — the path it named has not existed for some time.
"Done" read identically whether the agent ran the test suite or ran nothing
at all, so a user had no way to tell a checked result from an unchecked one
without going and looking. Every answer now ends with one line naming which of
three states applies — verified, partially verified, unverified — derived from
the turn's own tool-execution log, so it costs no extra model call.
The statement lands at two seams. The parsed-`answer` path gets it just after
`finalize_answer`, so a subclass that rewrites the answer cannot drop it. Every
other exit sets `final_answer` directly and never reaches that hook — LLM
connection error, context overflow, typed Lemonade error, cancel-event timeout,
parse give-up, loop-break summary, max steps — so a post-loop catch-all covers
them. Those are disproportionately the runs that went wrong, which is exactly
where the statement earns its place. The console-cancellation path returns a
deliberately empty result and is excluded.
The line is capped at 200 characters, because it enters conversation history
and is re-sent on every subsequent turn. `strip_verification_scope` removes it
again for consumers that need the answer text alone; the Agent-UI SSE handler
uses it so an answer its cleaners strip to nothing stays empty instead of
arriving as a scope line on its own.
Closes #3376.
@github-actions github-actions Bot added documentation Documentation changes devops DevOps/infrastructure changes tests Test changes agents agent::email Email agent changes labels Sep 5, 2026

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Verdict: Request changes — one fix before merge, two worth folding into the same pass.

Every answer the agent loop emits now ends with a line saying whether anything was actually checked, derived from the turn's own tool log. The design is right, the exit-path coverage is genuinely thorough, and the pass/fail signal is read from the same predicate the loop already uses — so a pytest run that exits non-zero correctly reads partially verified rather than verified.

The blocking problem is the opposite direction. A shell command that merely mentions a runner counts as having run one. pip install pytest, grep -rn mypy src/, or a commit message like fix mypy errors all exit clean and the turn reports "verified — pytest ran and passed." That is the exact false claim the feature exists to prevent, and it is worse than the old silence: a fabricated verification badge is more trusted than an unlabelled "done." Match the runner only where it is actually invoked — at the start of the command or right after a shell separator — rather than anywhere in the string.

Two smaller ones to fix alongside it:

  • The line lands in long-term memory. The stored assistant turn is the answer with the scope line, so it gets embedded, fed to memory extraction, and returned on recall. The email agent got a stripper at its comparison point; the memory hook is the other consumer that wants the answer text alone.
  • Appending isn't idempotent. Because the line now sits at the end of every assistant message in history, a small local model will eventually copy it into its own answer — and the loop appends unconditionally, so the user sees two verification lines that can disagree. One line of defence fixes it.

Real-world evidence

The bundle in this PR is real and matched to the surface: a live POST /api/chat/send SSE stream carries the line intact through the cleaners, it persists to the DB and renders in session export, and a real MCP client (send_message / get_messages) returns the same text. The CLI couldn't reach the loop on that runner (Lemonade pre-flight exits first), and Agent UI pixels are marked pending the strix-halo lane — acceptable for merge-time CI given the route-level evidence. An eval run on rag_quality is reported at 100% judged pass rate.

One caveat that bears on the verdict: only the unverified branch ran live. The verified and partially verified states — the ones where the false-positive above would actually surface, and the only ones that make a claim — are deferred to the inference lane. That half of the feature rests on unit tests and static review here.

🔍 Technical details

🟡 Important

1. A mentioned runner is scored as an executed one (src/gaia/agents/base/verification.py:42, used at :82)

_CHECK_COMMAND_RE.search(command) matches anywhere in the command string, and the shell tool returns return_code == 0 for all of these:

Command Reported
pip install pytest verified — pytest ran and passed.
grep -rn "mypy" setup.cfg verified — mypy ran and passed.
git commit -m "fix mypy errors" verified — mypy ran and passed.

The module docstring commits to the opposite trade ("a false positive would claim a check that never ran"), so this is a gap in the stated contract, not a preference. Anchor the alternation to invocation position and take the runner from group 1:

_CHECK_COMMAND_RE = re.compile(
 # The runner must be INVOKED — start of command or just after a shell
 # separator — not merely mentioned inside it.
 r"(?:\A|[;&|]|\bthen\b|\bdo\b)\s*"
 r"(?:sudo\s+|env\s+\S+=\S+\s+|uv\s+run\s+|poetry\s+run\s+|npx\s+)*"
 r"("
 r"pytest|py\.test|tox|nox"
 # ... unchanged alternation ...
 r")\b",
 re.IGNORECASE,
)

with verification.py:83 reading match.group(1) instead of group(0). Worth a test per row of that table.

2. The scope line is persisted into memory (src/gaia/agents/base/agent.py:6645)

_after_process_query receives result["result"], so MemoryMixin.store_turn writes the line into every stored assistant turn, it is embedded into the FAISS index, and _extract_via_llm sees it as content. It is per-turn process metadata, identical on every turn — not something to remember or retrieve.

 self._after_process_query(
 user_input, strip_verification_scope(result.get("result", ""))
 )

(add strip_verification_scope to the import at agent.py:42)

3. _with_verification_scope is not idempotent (src/gaia/agents/base/agent.py:4476)

Surfaces re-send the answer as history, so from turn two onward every assistant message the model sees ends in Verification: .... Gemma-4-E4B mimics trailing patterns; when it does, the emitted answer carries the model's invented line and the real one, and strip_verification_scope only removes the last. Cheap to make safe:

 if not answer or not answer.strip():
 return answer
 # Idempotent: the model sees this line in history and may echo it.
 answer = strip_verification_scope(answer)
 return f"{answer.rstrip()}\n\n{self.verification_scope_statement()}"

🟢 Minor

  • A check that raises is invisible (agent.py:3338, :3350) — _note_verification_signal runs only after _execute_tool returns, so a run_tests tool that raises produces "unverified — no tools ran", which is factually wrong about the run rather than merely cautious. Moving the note into the finally (with failed=True on the raising path) would cover it.
  • First command key wins, even when it doesn't match (verification.py:78-83) — the loop returns on the first present key, so {"cmd": "ls", "script": "pytest -q"} reads None. Continue to the next key on a non-match.
  • PR description covers only the feature — the diff also lands 7 plan docs, a CI reference, a manual-testing guide, and .gitignore changes. Per CLAUDE.md's PR rules that bundling wants a one-line threads list so a reviewer knows what else is in scope.

Strengths

  • The exit-path enumeration is the real work here, and it is right: placing the statement after finalize_answer so a subclass can't drop it, guarding the post-loop catch-all with a flag so nothing is stamped twice, and excluding the console-cancellation path so a cancelled turn stays empty. One test per path, driven through the real loop rather than the helper.
  • Deriving pass/fail from _is_error_result means return_code / has_errors from the shell tool are already honoured — a failing pytest reads partially verified, which is the case that mattered most to get right.
  • The dependency-free module keeps the SSE handler off agent.py, and the card-echo guard (set the line aside, re-attach only if something survives the cleaners) is a subtle case caught before it shipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Reviewers

No reviews

Assignees

No one assigned

Labels

agent::email Email agent changes agents devops DevOps/infrastructure changes documentation Documentation changes tests Test changes

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

feat(agent): say when an answer is unverified, not just when it's done

1 participant

AltStyle によって変換されたページ (->オリジナル) /