Skip to content

Navigation Menu

Sign in
Sign up

feat(agent): state whether an answer was verified, on every exit path - #3397

Open
kovtcharov-amd wants to merge 1 commit into
main from
feat/verification-scope-on-answers
Open

feat(agent): state whether an answer was verified, on every exit path #3397
kovtcharov-amd wants to merge 1 commit into
main from
feat/verification-scope-on-answers

Conversation

@kovtcharov-amd

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

Copy link
Copy Markdown
Collaborator

The agent reported success in the same confident language whether it had run the test suite or run nothing at all. A user could not tell a checked result from an unchecked one without going and looking, so the rational response was to distrust every completion message — which costs a round trip on every task. Every answer now ends with one short line naming what ran, what passed, and what went unchecked.

Three states are distinguishable:

Verification: verified — pytest ran and passed.
Verification: partially verified — pytest passed, lint did not.
Verification: unverified — no tools ran, so nothing was checked.

This does not make the agent verify more — that is #3381. It stops it implying it did. A claim never made cannot be wrong, and this half costs no extra model call: the signal comes from the tool calls the loop already records.

Closes #3376.

Test plan

  • python -m pytest tests/unit/agents/test_verification_scope.py -q — 72 tests covering the three states, the length bound, and each exit path
  • python -m pytest tests/unit/agents -q --ignore=tests/unit/agents/email — no new failures
  • Run a query that touches no test runner and confirm the answer ends unverified; run one that invokes pytest and confirm it ends verified

Notes for the reviewer

It applies on every exit path, not just the one. finalize_answer is called at a single site, on the parsed-answer path. The cancel timeout, LLM connection and generic errors, context overflow, parse give-up and the loop-break summary all set final_answer directly and bypass it — and those are disproportionately the runs where "was any of this checked" matters most. Each has a test. #3381 depends on this enumeration and can consume it rather than redoing it.

One deliberate exception: the console-cancellation path returns an empty result by design, and downstream treats blank as a signal to skip persistence. A scope line would make it non-blank, so empty stays empty.

Bounded on purpose. The statement is capped, because it is appended to the answer and the answer enters conversation history — an unbounded tail would be re-sent every subsequent turn, which matters on the 32K profile.

The email agent compares its emitted answer against a grounded copy; that comparison now strips the statement first, which is why it appears in the diff.

A red eval check is expected on any PR right now — #3341 tracks the gate failing identically on main.

The agent reported success in the same confident language whether it had run
the test suite or run 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 bounded line naming what ran, what passed, and what went unchecked —
verified, partially verified, or unverified.
The statement is derived from the tool calls the loop already records, so it
costs no extra model call, and it is capped so a multi-turn session does not
carry an unbounded tail in its history.
It is applied on every path that produces a final answer, not only the parsed
answer that reaches finalize_answer: the cancel timeout, LLM connection and
generic errors, context overflow, parse give-up and the loop-break summary all
set final_answer directly, and those are disproportionately the runs where the
question "was any of this checked" matters most. The console-cancellation path
is the deliberate exception — its result is empty by design and a scope line
would make it non-blank.
@github-actions github-actions Bot added documentation Documentation 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

Request changes

This adds a one-line "was this actually checked?" statement to every answer the agent loop emits, on all exit paths. The idea is good and the exit-path coverage is genuinely thorough — but the check-detector fires on commands that merely mention a test runner, so an answer can claim it was verified when nothing was run. That is the exact failure the feature exists to prevent, so it needs fixing before merge.

Headline issues

  1. A command that only mentions a runner counts as having run it. Installing, committing, searching or reading a file whose text contains "pytest", "ruff", "mypy" and friends is classified as a passing check, so the answer ends "verified — pytest ran and passed" after nothing was tested. I ran the classifier during review to confirm this; output is in the evidence section below. The fix is to match the runner only where a command actually starts, not anywhere in the string.

  2. The wording is code-shop specific but ships to every agent and every surface. Email triage, document Q&A and plain conversation will all end with "unverified — no tools ran, so nothing was checked", including replies from the OpenAI-compatible API and the messaging adapters. Nothing lets an agent opt out. Worth a maintainer call on whether this should be on by default everywhere or enabled per agent.

  3. The line can end up doubled or faked. It is appended without first removing one that is already there, and by the PR's own reasoning the line travels back into conversation history — so a model that copies the pattern can emit its own "verified" line that survives the removal helper.

Real-world evidence

The automated evidence stage failed to run on this PR (evidence-bundle.md records an error before any evidence was produced), so nothing here exercised the changed surface end to end, and the PR description shows no CLI or Agent-UI output either. The third test-plan item — run a query with and without a test runner and check the line — is unchecked. Since this changes the text of every answer on every surface, that gap is worth closing before merge, ideally with a gaia eval agent run as well: the appended line is part of what the eval judge scores.

The one thing that did run is a check I performed myself against the new classifier module (it is dependency-free, so it can be exercised in isolation):

"git commit -m 'fix pytest failures'" -> pytest
'grep -rn "pytest" src/' -> pytest
'cat pytest.ini' -> pytest
"echo 'remember to run npm test'" -> npm test
'git log --oneline -- util/lint.py' -> util/lint.py
'ls -la' -> None
'rm -rf build' -> None

Each non-None line above becomes "Verification: verified — ... ran and passed" when the command exits 0. This is what issue 1 rests on.

🔍 Technical details

🔴 Critical — the command classifier matches anywhere in the string (src/gaia/agents/base/verification.py:42)

_CHECK_COMMAND_RE is applied with .search() over the whole command, so any occurrence of a runner token counts. Combined with _is_error_result (which reads return_code), a zero-exit pip install ruff, cat pytest.ini, grep -rn "pytest" src/ or git commit -m "fix mypy errors" produces Verification: verified — ruff ran and passed. The module docstring states the opposite intent ("a false positive would claim a check that never ran"), and test_command_classification only passes because its negative case is git commit -m 'wip'.

Anchor the match to a command position — start of string or immediately after a shell separator:

_CHECK_COMMAND_RE = re.compile(
 r"(?:\A|[;&|]\s*|\b(?:and|then)\s+)\s*(?:[\w./\\-]*[/\\])?("
 r"pytest|py\.test|tox|nox"
 r"|python\s+-m\s+(?:pytest|unittest)"
 r"|npm\s+(?:run\s+)?(?:test|lint|build|typecheck)"
 r"|yarn\s+(?:test|lint|build)"
 r"|pnpm\s+(?:run\s+)?(?:test|lint|build)"
 r"|go\s+(?:test|vet|build)"
 r"|cargo\s+(?:test|clippy|check|build)"
 r"|dotnet\s+(?:test|build)"
 r"|mvn\s+(?:test|verify)"
 r"|make\s+(?:test|check|lint|build)"
 r"|ctest|jest|vitest|mocha"
 r"|ruff|flake8|pylint|mypy|pyright|eslint|tsc|shellcheck"
 r"|python\s+util[/\\]lint\.py"
 r")\b",
 re.IGNORECASE,
)

I ran that variant against the file's own parametrize table: every existing positive still classifies, every false positive above returns None. One expectation shifts — python util/lint.py --all labels as python util/lint.py rather than util/lint.py, so that row needs updating. Please also add the confirmed false positives as negative cases in tests/unit/agents/test_verification_scope.py::test_command_classification — that test is what should have caught this.

🟡 Applies unconditionally to every agent and every surface (src/gaia/agents/base/agent.py:6483, :6590, :6606)

_with_verification_scope sits on the base loop, so EmailTriageAgent, the chat/doc profiles and the flagship all get "none of them a test, lint, or build" appended to answers where that vocabulary has no meaning — a RAG-grounded document answer reads as unchecked because retrieval is not a build step. It also reaches src/gaia/api/openai_server.py:364 (OpenAI-compatible completions) and the Telegram adapter, so third-party clients see an extra trailing line on every completion with no way to turn it off. Suggest a class-level opt-in (e.g. VERIFICATION_SCOPE_ENABLED, default on only for agents with a shell/test surface), or vocabulary that generalises beyond code work.

🟡 Appended without stripping an existing line (src/gaia/agents/base/agent.py:4476)

The PR notes the line rides in conversation history. Small local models imitate trailing patterns, and a model-emitted Verification: verified ... line is not removed before the real one is appended: the answer then carries two, strip_verification_scope (anchored to \Z) only removes the trailing one, and both SSEOutputHandler.print_final_answer and the email agent's grounded-answer comparison assume exactly one. Cheap fix:

 if not answer or not answer.strip():
 return answer
 answer = strip_verification_scope(answer).rstrip()
 return f"{answer}\n\n{self.verification_scope_statement()}"

(with strip_verification_scope added to the existing import).

🟢 Nits

  • Doc drift (docs/sdk/core/agent-system.mdx:32): the tool list omits lint and run_test_suite, both of which are in _CHECK_TOOLS.
  • Truncation can drop the verdict (verification.py:139): with three long labels the 200-char cut lands before "ran and passed", leaving a statement that names checks but no outcome. Truncating the label list rather than the whole string would keep the clause.
  • Email-agent ordering (hub/agents/email/python/gaia_agent_email/agent.py:1230): on the direct-set exit paths ground_final_answer runs after the line is appended and can append its own correction below it, leaving the scope line mid-answer where the strip helper and the SSE extraction no longer see it.
  • BuilderAgent runs its own loop and calls _execute_tool directly (src/gaia/agents/builder/agent.py:374), so its answers carry no statement at all — worth a note if the intent is universal coverage.

Strengths

  • Hooking _note_verification_signal into _execute_tool_timed is the right seam: legacy, native tool-calling and forced-call branches are all covered without touching three call sites, and nested tool calls still record.
  • Applying the line after finalize_answer, so a subclass rewrite cannot silently drop it, is a good call — and it has a test.
  • The direct-set exit paths (cancel timeout, connection error, context overflow, parse give-up, loop break, max steps) each get their own test through the real loop with a stubbed client; that enumeration is the hard part of the issue and it is done carefully.
  • Keeping the console-cancellation path empty, and testing that it stays empty, avoids turning a cancelled turn into a completed-looking one.

Copy link
Copy Markdown
Collaborator Author

Both red checks are the same account-level problem, not this change.

The Email Triage Eval got far enough to call the API and came back with 400 — Your credit balance is too low to access the Anthropic API. It fails the same way on #3375, which touches nothing in common with this PR. It runs here at all only because this change edits the email agent — the answer-grounding comparison now strips the verification line before comparing, which is why that file appears in the diff.

The tool_selection gate is the same cause with its output swallowed; I have written the evidence up on #3341. Neither is fixable from this branch.

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 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 によって変換されたページ (->オリジナル) /