-
Notifications
You must be signed in to change notification settings - Fork 162
feat(agent): state verification scope on every emitted answer - #3401
feat(agent): state verification scope on every emitted answer #3401kovtcharov-amd wants to merge 2 commits into
Conversation
... 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.
|
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 The blocking problem is the opposite direction. A shell command that merely mentions a runner counts as having run one. Two smaller ones to fix alongside it:
Real-world evidenceThe bundle in this PR is real and matched to the surface: a live One caveat that bears on the verdict: only the 🔍 Technical details🟡 Important1. A mentioned runner is scored as an executed one (
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 2. The scope line is persisted into memory (
(add 3. Surfaces re-send the answer as history, so from turn two onward every assistant message the model sees ends in 🟢 Minor
Strengths
|
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_answerand are exactly the runs most likely to be incomplete.The line is capped at 200 characters so it cannot grow the prompt as it
accumulates in conversation history, and
strip_verification_scope()removes itfor consumers that need the answer text alone.
Closes #3376.
🔍 Exit-path enumeration (#3381 consumes this)
Every path in
Agent._process_query_implthat produces a final answer. Linenumbers are post-change.
answer— thefinalize_answerseam_cancel_eventtimeout ("exceeded the allowed time")ConnectionErrorConnectionErroruser_messagefinal_answernever set){"status": "cancelled", "result": ""}ctx_sizere-raiseSeam A is the
finalize_answercall site; the statement goes on after thehook, 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. Itsoutput is generated scaffolding files, not a claim about work being checked.
EmailTriageAgent._mailbox_target_guard— a pre-flight refusal returnedbefore the loop runs. No tools executed and no completion claimed.
🔍 What counts as a check
Tool name in
{run_tests, run_test_suite, run_lint, lint, typecheck, build}, ora
command/cmd/scriptargument 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 readsunverified, which is cautious rather than wrong, whereas a false positive wouldclaim 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 themwithout 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 alarge 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 3failures reproduce unchanged on the base commit.
POST /api/chat/sendreturns an
answerevent ending inVerification: unverified — no tools ran, so nothing was checked.A turn cancelled from the UI still returnsan empty result.
gaia eval agent --category rag_quality --agent-type doc— judged passrate 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_analysisreports INFRA_ERRORbecause the scenario pins
agent_type: data, an agent removed from theregistry — 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 theunchanged judged pass rate as the signal.
black,isort,pylint --errors-onlyclean on the touched files(
util/lint.pyitself could not run here — it shells out touvx, andPyPI is unreachable from this machine).