Skip to content

fix(agents): verify completion and preserve exhaustive inventories - #4374

Merged
kovtcharov-amd merged 32 commits into
mainfrom
replicate/verify-completion-inventories-4145
Sep 27, 2026
Merged

kovtcharov-amd merged 32 commits into
mainfrom
replicate/verify-completion-inventories-4145

Conversation

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator

Replicates #4145 (kovtcharov/gaia fork, push access blocked) onto a branch this session can push to. No content changes beyond conflict resolution against current main.

GAIA could claim it saved a file it never wrote this turn, or turn a long workshop transcript into a condensed list presented as complete. Now a save counts only when that exact file was written and read back, and a long document is extracted page by page into a full inventory in which every item keeps a verbatim quote from the source. The result is kept in memory, and anything unfinished is reported as incomplete. Ordinary questions ("What time does the store close?") behave exactly as on main.

Fixes #3983 and #4141; related to #4009. Closes #4145.

Test plan

  • python -m pytest tests/unit/agents/ -q: 1,663 pass, 51 skip. The one failure (test_symlinked_directory_is_the_same_output) is this Windows box lacking the symlink privilege, not the code.
  • python -m pytest tests/unit/ -k "memory" -q: 1,136 pass. The one failure (test_memory_discovery.py::TestCredentialManagerContractShape) reproduces on pristine main.
  • python -m pytest tests/unit/ -k "inventory or transcript or overlap or youtube or extract" -q: 547 pass; remaining failures are a local mcp package version mismatch and a live-Lemonade ASR test.
  • black --check / isort --check-only clean on every touched file.
  • Reporter check: Patrick reruns the original workshop transcript.
  • Judged gaia eval agent run: deferred by maintainer; the completion gate changes final answers, so decide before merge.
🔍 Technical details

What a reviewer should know

  • Saves
    • A request that names a file, folder or disk is a hard obligation: the file must be written and read back this turn.
    • A save claim is checked hard when a save was requested, a disk tool ran, or the answer says "I" or "we" saved something. Any other claim gets one correction and then stands, as on main.
    • A file written through the shell counts only when a successful call modified it. The agent's scratch folder never counts as the user's save.
  • Extraction
    • Pages are 4K characters with 600 characters of overlap, snapped to a line, sentence or word boundary. Each page gets two passes, a retry on an unusable reply, and a 16K-token output budget.
    • Cloud models are asked for no reasoning: a reasoning model spent 50–100 s per page and ran past the 30-minute limit on a 74K file.
    • Quote matching ignores case and spacing, but only on whole words. The stored quote is always the source text at its exact offsets.
    • An item is placed by the span of its name when the name is copied from the quote, otherwise by its quote.
    • Merging never fails a run and never drops an item. Differing descriptions are both kept, and uncertain cases keep both entries, marked "(may repeat item N)".
  • Exports
    • JSON records give each field its own key and CSV gives each field its own column. The framework reads the saved file back in full and requires an exact match, and hand edits to it are refused.

Adversarial coverage (from #4145)

  • A planted-item oracle (about 20,000 items, repeated names, paraphrased labels, repeats across pages and passes) finds no lost, absorbed or wrongly merged items.
  • In 60,000 hostile replies, no exception escaped the tool, and every quote matched the source at its offsets.
  • Live runs on Fireworks deepseek-v4p1-flash against real YouTube auto-captions of 13K, 21K and 74K characters; the 74K end-to-end agent run saved and verified 111 items in 122 s. Local Gemma-4-E4B completed the 13K file (63 items).

Conflicts resolved against current main

Two, both in src/gaia/agents/base/agent.py, plus an additive one in the flagship CHANGELOG.md:

  1. Turn-sealing order vs. feat(tui): say something to the agent while it is still working #3707 (mid-turn followups). main added a _drain_followups check that re-enters the loop rather than sealing the answer; fix(agents): verify completion and preserve exhaustive inventories #4145 replaced the same lines with finalize_answer + the completion-evidence gate. Kept both, drain first: a late user message still gets folded into the running turn, and the completion gate then runs on the answer that actually seals.
  2. Duplicated _refresh_active_skill_filter vs. fix(skills): a loaded skill brings the tools it declares #4369 (a loaded skill brings its tools). fix(skills): a loaded skill brings the tools it declares #4369 hoisted that call to before _refresh_active_tool_filter; fix(agents): verify completion and preserve exhaustive inventories #4145 still carried it in its old position below, so a naive merge called it twice per turn. Dropped the duplicate and kept main's hoisted call — the extraction block that follows reads _active_skill_filter, so running it earlier is strictly correct.

Verification of the resolution

  • Per-file diff of (merge-base → fix(agents): verify completion and preserve exhaustive inventories #4145 head) against (current main → this branch): byte-identical on every file except the one blank line the de-duplication above removes. That rules out the silent near-identical duplication a large auto-merge can introduce.
  • compile() (not just ast.parse, which does not reject repeated keyword arguments) over every .py in src/, tests/, hub/ and scripts/: clean. Repo-wide sweep for stray conflict markers: none.

Kalin Ovtcharov and others added 28 commits September 24, 2026 13:28
- extraction.py: render() unpacked a per-path (entries, pages, digest)
  tuple but never used digest -- unused-variable.
- agent.py: _tool_overrides, _extraction_ledger, _completion_evidence
  were all first assigned outside __init__ (lazily, via hasattr guards
  or directly in process_query) -- attribute-defined-outside-init.
  Declared upfront: _tool_overrides as the empty dict every call site
  already assumed, the other two as Optional (they're only ever built
  fresh per turn).
…g extraction

Three real issues from review, one from a field report (amd-pworfolk,
PR #4145):

- A query like "list all functions in app.py" has no destination
  preposition for destination_paths to anchor on, so its no-anchor
  fallback scanned the whole clause and picked up the source file
  itself as an extraction destination that never resolves -- every such
  code query reported status: incomplete. Excludes common code
  extensions from ExtractionLedger.requested; a real destination is
  never source code, and code-index/read-file tools already own that
  file.
- validate_sources/validate_outputs caught bare Exception on newly
  written code; narrowed to ValueError (read_snapshot's own signal) and
  OSError (the file operations it does not pre-validate).
- extract_pages only snapped page boundaries to newlines, but
  transcribe_media's own output is a single unbroken line -- every
  GAIA-transcribed source opened pages mid-word, and a page landing on
  pure filler with a truncated opening made the extractor unable to
  confirm completion, aborting the whole extraction. Falls back to
  sentence-ending punctuation when no newline exists in the overlap
  window.

Also fixes two Windows-only test failures the same field report
flagged: two assertions compared normalized (Windows-lowercased) keys
against raw mixed-case tmp_path strings, and a third symlink-based test
needs elevated privilege Windows CI runners don't reliably have --
added a skip marker rather than leaving it permanently red.
A slower local model spent all 5 steps searching and never answered,
reporting the script's own tight budget as a failed follow-up rather
than a real one (amd-pworfolk field report, PR #4145).
"List all functions in app.py" excluded app.py from the requested-
destinations set but left the ledger enabled, so gaps() still
demanded a verified source that code-index/read-file tools never
produce, collapsing the turn to status: incomplete. Gate enabled on
the query naming a real (non-code) destination or no destination
token at all.

Also folds duplicate offset/end/total computation out of
completion.py's delivered(), shares one path-normalization helper
between CompletionEvidence and ExtractionLedger instead of each
constructing its own evidence object, and renames a shadowed loop
variable in the extraction validation script.
gaps() == [] holds for two different reasons -- the ledger being off, and
the ledger being on with nothing outstanding -- so the existing assertion
passes even if activation regresses. Also pins that a document source still
activates when the save target happens to be a code file.
…wo pages

A real unbroken workshop transcript aborted on page 3 because pages 2 and 3
quoted the same sentence from slightly different start points. Their spans
overlapped without either containing the other, so reconciliation treated
identical fields as a conflict and discarded every page extracted so far.

Field values are verbatim in their quotes, so partially overlapping quotes now
merge when every value both sides state sits once, at the same source offset,
in each quote. Repeated items that share context still stay separate, because
their names sit at different offsets. Free-text entries keep the containment
rule, since their text cannot be grounded.
"What time does the store close?" or "List every U.S. state capital" came back
as "I couldn't verify completion" because the save detector counted any
"store/save/export" word and the extraction ledger demanded a source that no
one asked for. Save requests now need the verb as an instruction, and a turn
that never read a document leaves no extraction gap.

- Correct saves no longer read as missing through a symlinked directory, a
  named folder, or a relative path inside a repo subdirectory; paths resolve
  against the working directory, as the file tools do.
- A Lemonade connection error keeps its message and failed status; unverified
  work is listed beneath it rather than replacing it.
- Explaining deletion ("deleted files go to the bin") is not a cleanup claim.
- Extraction never merges two non-overlapping quotes into one entry, so a
  broad quote can no longer absorb a repeated item. Ambiguous replies are
  re-asked, and values repeated inside one quote still match their occurrence.
- An over-long extraction answer stores neither turn instead of an unanswered
  question; loading document-extract keeps code-symbol queries off.
- Docs: tool signature, merge rule, memory limit, gaia-voice token count,
  CHANGELOG entry; inline comments trimmed.
A second review round found ordinary requests still ending as incomplete
("Save me some time", "Store this in memory", "Write a guide to Node.js") and
extraction still able to lose or abort on realistic quotes.

- A save request now counts only when it names a file or disk; a pathless
  "save it" is checked through the answer's own save claim. A destination path
  must follow its preposition directly, so "to clean logs/app.log" is a topic.
- "Write X to Y" is a save only for "it", "this" or "the summary", not "a
  guide to Node.js". Every save verb in a sentence is read, and a trailing ?
  or ! no longer hides the path.
- The answer check reads only a claim's main clause, so "a function that
  saves to x" and "Once saved, the file is at x" are not claims. A deletion
  claim about a path that is absent and untouched is not flagged.
- Two overlapping extractions whose values sit at one source location, one
  inside the other, merge into one entry spanning both quotes with the fuller
  value of each field, so a value cut short at a page boundary no longer aborts.
- Two items from one reply never merge, and a candidate must reconcile with
  every quote already merged, so a broad quote cannot chain two items into one.
  Conflicts are re-asked with the conflicting quote named.
- Free-text entries with equal text merge when their quotes share most of
  their text; repeats sharing only context stay separate.
…lysis

A third review round found the nested-value merge could let an omission-pass
"march in place" swallow an earlier "march", and several ordinary requests
still ending as incomplete.

- Identity fields (name, id, title) must match exactly; only descriptive
  values may be cut short across a page boundary.
- Sources are the files a request reads from: "from meeting.txt into
  actions.json" now extracts meeting.txt and lets the inventory be saved to
  actions.json. Parenthesized paths and multiple sources are found.
- Extraction stays off for agents without a file permission validator, for
  PDFs and other binary documents, and for analysis of code ("find all bugs in
  main.py"); listing content such as TODOs still extracts from code.
- The inventory is appended to the model's answer instead of replacing it, so
  requested analysis ("which one is hardest") survives.
- Save claims keep their relative clauses ("the summary that you asked for");
  only code descriptions ("a function that saves to x") are ignored. A save
  from an earlier session is not a claim about this turn.
- Only requested or claimed outputs need readback; fixing a typo does not.
- Removing a file from an index or list is not a deletion claim.
…cord

A fourth review round found wording-only checks still misfiring: "Downloads
are saved to ~/Downloads" ended incomplete, "Write a Node.js server" demanded a
file named Node.js, and "List all the features of Node.js" required extracting
a document that does not exist. The checks now lean on what actually happened.

- An answer's save claims are checked only when the user asked for a save or
  a disk-writing tool ran this turn; a reused transcript still counts.
- A document source must exist on disk; data files (CSV, JSON, logs, YAML,
  XML) are left to code tools.
- Without a field named for a name, id or title (exercise_name, ExerciseName
  included), the first requested field identifies the item, so an omission
  pass reporting "march in place" can no longer absorb "march".
- "extract/put/copy ... into x.json" names an output, "not to x" drops x, a
  folder is a save target, and a bare name that modifies a noun ("a Three.js
  scene") or sits past a dash is not a path.
- Malformed tool paths (NUL bytes, non-strings) end as normal results.
A fifth review round found the previous commit let "I saved it to notes.md"
pass after "Create notes.md", dropped "Save it to x.txt thanks" as a request,
and aborted extraction when two copies of an item both left the first field
"not stated".

- A first-person save claim is always checked; only impersonal statements
  ("downloads are saved to ~/Downloads") need a save instruction or a write.
- The modifier rule ("a Three.js scene") applies only when no destination
  preposition names the path.
- With no identity value stated in both, any equal field identifies the item.
- A file a successful shell or Python run modified this turn counts as
  written once it is read back, so "ls > listing.txt" can complete.
…ntories

A sixth review round found the previous commit treated any "I" in a sentence
as a save claim, so "I think downloads are saved to ~/Downloads" ended
incomplete, and accepted files touched by a failed or unrelated shell run as
the saved output. A live run also showed a turn that ran out of steps dropping
its 40 extracted items from the answer and from memory.

- A first-person claim needs I/we as the subject of the save verb.
- A shell-written file counts only when its modification time falls inside a
  successful executor call and no failed one, and it never satisfies a
  pathless "saved it to a file" claim.
- "Create/make/generate notes.md" names an output; "a Node.js app" does not.
- A turn that ends without a final answer still shows the extracted inventory.
… speaker

A seventh review round found "create a summary of the errors in app.log"
treating the user's source as the save target, and "I've successfully saved
it to notes.md" slipping past the first-person check.

- Create/make/generate name an output only through their direct object
  ("create notes.md"), never through "in X", and a tech name before a noun
  ("a Next.js landing page") is not a file.
- A first-person claim is I/we followed within a few words by a save verb,
  with no be-verb or clause break between, so adverbs no longer hide it and
  "I think downloads are saved to x" is still information.
- A turn that stops at the step limit shows the finished inventory under the
  step-limit message.
…verdict

An eighth review round showed that narrowing which save claims count had
dropped main's safety net: "The summary has been saved to notes.md" with no
save requested passed with no challenge at all, while checking every claim
hard had turned informational answers into incomplete turns.

- A save claim still makes the turn incomplete when a save was requested, a
  file-writing tool ran, or I/we did the saving. Any other claim now gets one
  correction (write it, or say it was not saved now) and then stands, as the
  fabricated-save guard on main did.
- "Create/make/generate ... in notes.md" names an output when notes.md does
  not exist yet; an existing file there is the source.
- A file name followed by a participle and an article ("report.md summarizing
  the project") is the output, while "a Next.js landing page" is not.
A ninth review round found the previous commit treating "create a summary of
the action items in meeting.md" as a save to meeting.md whenever the source
sat outside the working directory, ending correct answers as incomplete.
Only a create verb's direct object names an output now; a claim about a file
after "in" still gets the one-correction check.
Testing against real YouTube workout captions (no newlines, no punctuation,
non-breaking spaces) and an audit of main since this branch showed the
pipeline failing on the input it exists for.

- A cloud reasoning model spent its whole 8K budget thinking about one dense
  caption page and returned no JSON. Page calls now ask cloud models for low
  reasoning effort, allow 16K output tokens, and report a spent budget by
  name; the old finish_reason check never fired because the SDK drops it.
- Quotes match captions despite non-breaking and doubled spaces; the stored
  quote is still the source's own text at its exact offsets.
- Pages snap to a word boundary when a transcript has no punctuation.
- A model that reshaped the saved JSON by hand made correct runs incomplete.
  Exports now give each requested field its own key, and hand edits to a file
  save_extracted_items wrote this turn are refused.
- Local page calls drop the anti-repetition penalties, which fight verbatim
  copying; a save into the agent's scratch folder, deleted on close, no longer
  satisfies "save it"; save_extracted_items gets path grants like write_file.
Adversarial fuzzing and live runs on real YouTube workout captions found the
extractor failing honest models and occasionally losing items.

- An item is located by the source span of its identity value, not its whole
  quote. Neighbouring items' quotes may now overlap, which aborted about a
  quarter of real-caption runs; an omission-pass repeat whose quote names the
  item twice takes the unclaimed occurrence instead of vanishing into the
  first. Free text that repeats inside one quote is never merged.
- Matching ignores case and spacing but only on whole words ("arm" is not in
  "farm"); stored quotes stay the source's own text.
- A cloud page that exhausts its budget reasoning is retried once with
  reasoning off; malformed or truncated replies are retryable errors.
- The fields list stops before a save clause, an all-"not stated" item is
  rejected, CSV keeps colliding field names and escapes formulas, large
  exports validate right after saving, the prompt cap fits the query cap, a
  field-schema mismatch names the fields to resend, and the hand-edit guard
  matches case-insensitive filesystems.
…ripts

Live runs on real YouTube workout captions showed honest models failing whole
documents: instructors repeat names inside one quote, cloud reasoning models
exhaust their budget on dense pages, and models compose exercise names
("Big circles (arms)") rather than copying them.

- Merging no longer raises. Extractions are one item only at the same place
  (same name span, or quotes sharing half their text) with matching or nested
  names; different names stay separate items, differing descriptions are both
  kept, and anything too ambiguous keeps both entries marked
  "(may repeat item N)".
- A name copied from the quote locates the item; a composed label leaves the
  quote to locate it, and one sharing no words with its quote is kept but
  marked "(label not in quote)". Only the item's quote must be verbatim.
- A quote naming its item twice parses; an omission pass takes the unclaimed
  occurrence, so a missed repeat is never folded into the first.
- Errors name the offending field and value so a retry can fix it.
…ports

A 74K-character real caption file (the size of the original workshop) timed
out at 30 minutes because a cloud reasoning model spent 50-100 s per page
thinking, and a finished extraction then ran out of steps while the model
paged through a 60K-character export to "verify" it.

- Page calls ask cloud models for no reasoning; copying quotes needs none.
  The same file now finishes in about two minutes with comparable recall.
- The framework reads every exported inventory back in full and compares it
  exactly, so that counts as the readback; the model no longer pages through
  it, and a tampered file is still caught.
- An entry that states no field is ignored instead of failing its page.
A final planted-item fuzz found no lost items but a rare wrong merge: when a
quote named its item twice, the extraction was pinned to an occurrence already
found and its values landed on a different item of the same name.

- A quote naming its item twice takes an unclaimed occurrence and is never
  merged; a true re-report shows as a flagged possible repeat.
- A name with a trailing period still locates itself, a one-sided located
  name must sit inside the other quote, and free-text items at different
  name positions stay apart.
- The repeat flag also catches label drift ("march" vs "Seated marching").
- Free text normalises carriage returns, invalid Unicode is a retryable
  reply error, and the output-size error no longer calls the file a source.
Skills now load when the model calls load_skill rather than being guessed
before the turn, so that call is the main way extraction switches on.
# Conflicts:
#	hub/agents/gaia/npm/CHANGELOG.md
#	src/gaia/agents/base/agent.py
# Conflicts:
#	src/gaia/agents/base/agent.py
@github-actions github-actions Bot added tests Test changes agents labels Sep 25, 2026
@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Heads up before anyone rebases this: #4373 landed on main a few minutes after this branch was pushed, and the two changes disagree about what happens when a turn runs out of steps. Merging them mechanically loses items that were already extracted — the exact guarantee this PR exists to provide. I stopped rather than pick a winner.

What happens: #4373 added a closing model call at the step cap so the user hears what was found. That call now fills the final answer and marks it sealed, which skips the block this PR added just below it — the one that reports what is still unverified and re-attaches the inventory. So asking for every exercise in a long transcript, hitting the step limit, and getting back a bare "reached maximum steps" note with the 60 items already extracted thrown away.

Three of this PR's own tests catch it, which is how I found it:

  • test_a_finished_extraction_survives_the_step_limit
  • test_running_out_of_steps_keeps_the_extracted_inventory
  • test_step_exhaustion_after_write_returns_incomplete

Someone who owns both changes should decide whether the step-cap answer goes through the completion gate. I'd say yes — an answer that says "I saved it" should be checked the same way no matter which exit produced it — but that is a behavior change to #4373, not a merge conflict, so it is not mine to make.

🔍 Technical details

Where it breaks. In _process_query_impl, #4373's step-cap block sets verification_scope_applied = True after _with_verification_scope(self.finalize_answer(cap_answer, conversation)). The post-loop gate this PR adds is guarded by if not verification_scope_applied and not account_refused: and is therefore skipped, taking with it both self._extraction_ledger.gaps() and the self._extraction_ledger.render() append. The other inventory path — the "result" fallback that renders the ledger when final_answer is empty — is also dead now, because #4373 fills final_answer.

Status field. That part merges cleanly. #4373 added max_steps_reached and this PR added completion_gaps to the same ternary; both are exclusions from "success", so the resolution is "failed" if has_errors else ("success" if has_valid_answer and not completion_gaps and not max_steps_reached else "incomplete"). So the status stays honest at the cap; only the prose and the inventory are lost.

Test-harness note. The three tests script an exact number of stub model replies. #4373's closing call is one more, so two of them currently surface as status == "cancelled" or "The summary of what I found couldn't be written" rather than as a clean assertion on the inventory. Whoever fixes the behavior will need to add the extra scripted turn too — but the missing inventory is a real product gap underneath, not only a fixture mismatch.

State of this branch. It is merged with main through d417ae058 and green there (tests/unit/agents/: 1,663 pass / 51 skip, one Windows symlink-privilege failure). The #4373 merge is not included; that is why GitHub shows a conflict.

@github-actions

Copy link
Copy Markdown
Contributor

Request changes

This adds a framework-level completion gate and an exhaustive document-extraction path to the base agent loop. The engineering is careful — the heuristics are guarded, the merge logic never drops an item, and ~1,500 lines of new unit tests back it up. But the gate runs on every final answer of every agent, and the one measurement that would tell us it doesn't degrade ordinary turns hasn't been run.

What needs to happen before merge:

  1. Run the agent eval. The project requires gaia eval agent for anything that touches the agent loop's answer path, and this rewrites final answers outright — an unverified claim now gets replaced with "I couldn't verify completion" instead of being emitted with a footer. The test plan leaves this unchecked and marks it a maintainer decision. That decision is the merge gate; unit tests can't stand in for it.

  2. Confirm two behaviours are what you intended, because both are model-order-dependent and only show up on live inference:

    • When the user asks to "list every item" without naming a file, any file the agent reads during that turn is treated as a source it must exhaustively extract. An unrelated read for context — a README, a config — then makes the turn report incomplete. Your own test demonstrates this with a file the query never mentioned.
    • If the agent extracts successfully and then loads the document-extract skill (which the new gaia-voice routing line tells it to do), the finished inventory is discarded and has to be redone — up to a 30-minute re-run on a local model. Making the load order not matter, or keeping results when the instructions only grow, would remove the sharp edge.

Nothing here is a security concern, and the docs, changelog and manifest token count all move together with the code.

Real-world evidence

evidence-bundle.md was produced on the no-inference ubuntu lane and covers what that lane can reach. The new skill goes through the real gaia skill CLI end to end:

$ gaia skill audit hub/agents/gaia/python/gaia_agent/skills/document-extract
ALLOW ✅  document-extract 0.1.0
  Allowed at the 'community' tier: no findings.

$ gaia skill info document-extract
  consumes     : extract_document_items, save_extracted_items, read_file

The reworked agent core is exercised through the real CLI, and fails loudly as designed:

$ gaia prompt "List every item"
❌ Error: Lemonade server is not running or not accessible.
📥 Lemonade server is not installed on your system.
EXIT=1

Docs and the 702-token manifest claim both check out (1025/1025 internal links OK; tiktoken measures the gaia-voice body at exactly 702). Spot regression shows the Agent UI backend booting with the new core and six routes answering 200, and the memory read path answering normally after the store_turn(preserve_full=…) change.

The bundle marks these "pending strix-halo lane": the Agent UI screenshot of an extraction turn, extract_document_items / save_extracted_items end to end, the status: "incomplete" + completion_gaps contract, the preserve_full=True write path, and scripts/dev/validate_document_extraction.py. Per CI-lane policy the deferred screenshot isn't a blocker. But it does mean the entire behavioural surface of this PR is unverified here — my verdict on the two items above rests on static review, and the eval gate in point 1 is the thing that would actually close it.

🔍 Technical details

🟡 Important

1. Agent eval not run for an answer-path change (src/gaia/agents/base/agent.py:8239-8330)

CLAUDE.md lists "the agent-loop catchall" and error classification among the surfaces that require gaia eval agent before merge. This change is broader than that: _check_extraction_sources + CompletionEvidence.gaps() + ExtractionLedger.gaps() run at the answer seam on every turn, a gap injects a [check:completion] correction step, and a surviving gap replaces answer_candidate with incomplete_answer(...). It also changes process_query's status contract (success now additionally requires not completion_gaps) and adds completion_gaps / extraction_sources keys.

The PR's own unchecked box says it: "the completion gate changes final answers, so decide before merge." There is no committed flagship baseline, so you can't claim "no regression" — but a run still surfaces a category full of INFRA_ERROR or a cratered score, which is the signal that matters here.

2. Every read file becomes a mandatory extraction source when the query names no path (src/gaia/agents/base/extraction.py:787)

and (not self.requested or self.key(path) in self.requested)

With self.requested empty — the "list every action item in the attached transcript" shape, enabled by the document|transcript|workshop|meeting|attached|source keyword — every read_file / read_python_file / read_markdown_file target lands in self.sources, and gaps() (extraction.py:791-805) then demands an extract_document_items result for each one.

Your test states the behaviour exactly:

state = ExtractionLedger("List all planets", str(tmp_path))
state.activate_skill("Extract **every** exercise from the workshop transcript.")
state.observe("read_file", {"file_path": "workshop.txt"}, {"status": "success"})
assert state.gaps()

workshop.txt is never named in the query. The same mechanism fires for an orientation read the user never asked about, turning a good answer into status: "incomplete". Scoping sources to files the query or the extraction request actually references would keep the guarantee without the collateral.

3. activate_skill discards a completed extraction (src/gaia/agents/base/extraction.py:756-765)

if instructions not in self.query:
    self.query += "\nActive extraction instructions:\n" + instructions
    self.results.clear()

agent.py:5069 calls this on every tool result, with the body of whatever skill set is active (_extraction_skill_instructions, agent.py:1807). The new gaia-voice line — "When the user requests every item from a document, load document-extract" — makes a post-extraction load_skill a realistic ordering: the skill body isn't in self.query yet, so it is appended and the finished inventory is wiped. gaps() then reports the source as un-extracted and the model must re-run a call budgeted at up to MAX_SECONDS = 1800.

test_…_clears_results shows this is deliberate (new instructions invalidate prior results), and that's defensible for a changed request. It's the append-only case — instructions that only grow, with the same source and fields — that costs a full re-run for nothing.

🟢 Minor

4. Conversation search can now build a multi-megabyte tool payload (memory_store.py:148, memory.py:3391)

MAX_EXTRACTION_TURN_LENGTH = 256000 lifts the per-turn cap 64×. search_past_conversations clamps limit to 50 and returns full content, so one call can assemble ~12.8 MB before _redact_credentials regexes it and _truncate_tool_result pages it. Worth a per-result cap in the search tool.

5. A dropped extraction turn is invisible to the user (memory.py:2813-2825)

Over the limit, both the user and assistant turns are skipped and only logger.error records it. The follow-up ("what was the cue for Zephyr-40?") then finds nothing, with no user-facing explanation. Surfacing it in the answer would match the fail-loudly rule better than a log line.

6. Redundant locals in the dev benchmark (scripts/dev/validate_document_extraction.py:38-39)

base = root and label = "validation" are each used once; inline them.

7. Skill selection by bare substring (agent.py:1807)

selected = active | {name for name in skills if name in query}

A short skill name matches unrelated prose, and query grows with appended skill bodies, so a body mentioning another skill's name selects it too. A word-boundary match would be tighter.

Strengths

  • The merge logic genuinely never drops an item. reconcile_occurrence keeps both entries when the evidence is ambiguous and marks (may repeat item N) rather than guessing, differing descriptions are joined with /, and _resolve_occurrence takes an unclaimed occurrence instead of pinning values on the wrong item. That is the hard part of exhaustive extraction and it's done right.
  • Failure modes are loud and specific. Time budget, item count, prompt size, retained-text size, source-changed-during-read, oversized quote, quote-not-in-page — each raises with an actionable message, and ExtractionLedger.run's catch-all is a legitimate tool-boundary translation to a structured error, not a silent fallback.
  • Two small security touches that are easy to forget. CSV cells starting =+-@ are prefixed so an inventory can't open as a spreadsheet formula, and the extraction system prompt marks the source as untrusted data with no tools attached.
  • Doc surfaces move together. docs.json nav, the new guide, agent-system.mdx, the hub CHANGELOG.md / SKILL.md, and the gaia-agent.yaml token count are all updated in the same change, and the 702-token claim is measured rather than asserted.

One conflict, in the turn's result status. `main` (#4373) made a
step-capped turn report "incomplete" even when the closing call answered;
this branch made a turn with completion gaps report "incomplete" too.
Both conditions kept, with the branch's errors-first ordering: "success"
now needs a valid answer, no completion gaps, and no step cap.
When the step limit ran out mid-extraction, the closing summary was
handed straight to the user as a finished answer: a claim that a file had
been saved went unchecked, and the items already extracted were dropped.
The two behaviours were each correct alone — the closing call sealed its
own answer, and the completion gate only ran for turns that never sealed
one — so together the gate was skipped exactly on the turns that needed
it most.

The closing answer is now judged like any other: an unverified save or an
unfinished extraction comes back as incomplete, and the inventory
gathered so far is still attached. An answer with nothing to flag is
untouched, so a step-capped run still says what it found.

The gate itself moved into `_gate_unsealed_answer`, shared by the
step-cap path and the post-loop path that already used it. It now keeps
the extracted inventory even when there are no gaps, which the step cap
used to get from the max-steps fallback that no longer fires.
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Skill audit

Skill Verdict Claimed tier Cleared tiers Findings Rules
hub/agents/gaia/npm ✅ ALLOW experimental experimental 1 high body.injection.credential_request
hub/agents/gaia/python/gaia_agent/skills/document-extract ✅ ALLOW community experimental, community none —
hub/agents/gaia/python/gaia_agent/skills/gaia-voice ✅ ALLOW community experimental, community none —

✅ All audited skills cleared the tier they claim.

Per-finding detail is withheld here on purpose. Read it in the Security > Code scanning tab, or download the skill-audit-reports artifact from this run. Offending source text is withheld from CI everywhere — reproduce it locally with gaia skill audit <dir> --show-snippets.

@github-advanced-security

Copy link
Copy Markdown
Contributor

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@kovtcharov-amd

Copy link
Copy Markdown
Collaborator Author

Merge and tests are clean, but two items from the review above are still open, so I'm holding off on merging.

  • The step-cap/completion-gate conflict flagged in the comment above is already fixed on this branch (6a61dd422) — that part's resolved, with its own tests passing.
  • The bot review's required gaia eval agent run still hasn't happened, and this machine has no reachable Lemonade backend to run it (nothing answering on :4200 or :8000, no lemonade/llama-server process). gaia_honesty is the closest scenario set to this PR's own claim ("GAIA could claim it saved a file it never wrote").
  • The same review's two behavioral questions — does an unrelated file read during "list every item" wrongly become a mandatory extraction source, and does loading document-extract mid-turn discard an already-finished inventory — are still unanswered.
🔍 Technical details

Merge with current main (72 commits ahead at merge-base 201f85df7): one conflict, additive-only, in hub/agents/gaia/npm/CHANGELOG.md — two independent ### Added entries (this PR's + #4335's CLI-setup entry) under the same header. Resolved by keeping both. Repo-wide grep for stray <<<<<<</>>>>>>> markers: none. py_compile.compile(..., doraise=True) on agent.py, extraction.py, memory.py, memory_store.py, tool_grants.py: all clean, no duplicate-kwarg errors. The handful of duplicate def names inside agent.py (_compose_system_prompt, _get_system_prompt, etc.) are docstring override examples nested in the base Agent class, not merge duplication — checked by line context.

Tests: tests/unit/agents/ — 1,786 passed, 56 skipped, one failure (test_symlinked_directory_is_the_same_output), which is the same Windows symlink-privilege gap this PR's own test plan already reported, not a merge regression. python util/lint.py --black --isort: clean.

Eval: confirmed via curl -m 2 http://localhost:4200/api/v1/health and :8000 (both timed out) and ps aux | grep -i lemonade (nothing running) — no working local eval environment on this box, so nothing was faked here. Whoever has a Lemonade-capable machine should run gaia eval agent --category gaia_honesty (and ideally gaia_files/gaia_tool_selection for the extraction-source-scoping question) before this merges — the PR's own test plan already flags the same gap as an unchecked box.

I did not push this merge resolution or attempt the squash merge, since the outstanding items are a maintainer decision (eval run + the two behavior confirmations), not a mechanical one.

# Conflicts:
#	hub/agents/gaia/npm/CHANGELOG.md
@kovtcharov-amd
kovtcharov-amd added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 6494a1c Sep 27, 2026
45 of 59 checks passed
@kovtcharov-amd
kovtcharov-amd deleted the replicate/verify-completion-inventories-4145 branch September 27, 2026 02:24
kovtcharov-amd added a commit to kovtcharov/gaia that referenced this pull request Sep 28, 2026
Resolves overlapping turn-state init in process_query: main's amd#4374
renamed file_write_claim_reprompts to completion_corrections, this
branch adds cut_off_continuations alongside it.
kovtcharov added a commit to kovtcharov/gaia that referenced this pull request Sep 29, 2026
After finishing what the user asked, the flagship could start an
unrelated job and run until cancelled. In one TUI session a one-line
bugfix was done and verified in 40 s, followed by 16 minutes of "extract
every source into an inventory". The trigger was the `coding` skill: its
"find every call site" tip switched on document-inventory mode, so every
coding session was pushed toward it. Now only a skill built on the
extraction tool turns that mode on, and two general stop conditions end
work the request didn't ask for.

Closes amd#4448

- **Root cause:** a skill enables inventorying only by listing
`extract_document_items` in `tools_required`. None of the starter skills
do, while real "list every item in this transcript" requests still get
full, source-quoted extraction (amd#4374's guarantees are kept).
- **Work after the answer:** once the turn has answered, a tool call
that touches nothing the request touched isn't run, and the model is
told to finish and offer the work instead. A second such call ends the
turn.
- **A tool failing with new arguments each time** is stopped like an
identical repeat. The repeat guard used to miss it because the arguments
varied.

## Test plan
- [x] `pytest tests/unit/agents tests/unit/eval`: 3001 passed, 78
skipped, 1 failed. The failure,
`test_symlinked_directory_is_the_same_output`, also fails on untouched
`main` on Windows (symlink privileges).
- [x] New tests: turn scope (drift after the answer, failing-tool loop,
what counts as related); the coding skill's wording no longer enables
extraction while an extraction skill still does; code read along the way
isn't an inventory source; bench-harness hooks.
- [x] Before/after on DeepSeek V4.1 Flash via Fireworks, same machine,
`gaia eval tasks run --suite everyday --tasks
02-bugfix,01-refactor,b6-config-env --no-judge`:

  | | Before (`main`) | After |
  |---|---|---|
  | Passed / verified by tests | 3/3 · 3/3 | 3/3 · 3/3 |
  | Steps | 45 | 29 |
  | Tokens | 832k | 544k |
  | Time | 323 s | 127 s |

  n=1, but the deltas are far larger than run-to-run noise.
- [x] `python util/lint.py --black --isort`.
- [ ] Judged quality pass (`gaia eval tasks judge`) once the judge CLI
is signed in on the eval box.

---------

Co-authored-by: Kalin Ovtcharov <kalin.ovtcharov@amd.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents documentation Documentation changes tests Test changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(agent): a run can report success for work it never did

2 participants