feat(email): render the triage list from the scan, not from the model - #2858
Conversation
Asking the chat model to retype a list the tools had already computed produced, across a night of live runs: invented numbering that pointed 'archive 3' at the wrong message, dropped items, sections merged so one message appeared twice under one number, and once no list at all — just '14 items need attention'. The categories stay model judgement: a heuristic, then the specific-ai-triage SLM, then an LLM fallback, all inside pre_scan_inbox. Transcribing that result is not a judgement, so it is rendered from needs_you, carrying the classifier's own reason per row. Grounding also moves ahead of the emission point (#2789). It ran on process_query's return value, which the REST/TUI stream never re-reads, so every correction fired, logged, and reached nobody on the surface users actually drive. A finalize_answer hook on the base agent lets one correction serve the stream, the console, and the return value.
The card spent a Gmail scan before the user had asked for anything, then landed again mid-turn as a partial list beside the full triage answer still being written — two overlapping views of one inbox, with no way to tell which to act on. The triage reply is now the single view.
|
Verdict: Request changes (small, test-shaped — the code is sound). This PR makes the email triage list deterministic: instead of asking the chat model to retype the worklist (which dropped, renumbered, and merged items across live runs), the numbered list is now rendered directly from the The direction is good and the reasoning in the comments is unusually clear. Two things to address before merge:
Real-world evidence
🔍 Technical details🟡 Missing tests for new user-facing logic — 🟡 🟢 Fragile dedup between 🟢 Doubled comment on 🟢 Stale comment after the cap bump ( Note (not blocking): a future Strengths
|
…lize_answer Addresses review: the answer-rewriting logic that now decides what every triage reply says had no coverage. Adds section ordering, ref numbering, per-item classifier reasons, sender-address handling, the lead-paragraph extraction, and the finalize_answer/process_query dedup that stops an append-style guard firing twice on one turn. Also fixes the pylint failure (unused finalize_answer default-hook argument), collapses a doubled comment on _ITEM_LINE_EMAIL_RE, corrects two comments that still claimed NEEDS_YOU_CAP is 5, and documents why rewrite_triage_answer keys on tool presence rather than parsing intent.
Closes #2789
Ask the email agent to triage your inbox and what came back was a coin flip. Across one evening of live runs against a real mailbox it produced: numbering that pointed
archive 3at a different message than the one labelled 3, items dropped, one message listed twice under a single number, addresses printed twice with amailto:twin, ten items run together on one line, and — twice — no list at all, just "14 items need attention." Every one of those came from the same place: the chat model was being asked to retype a list the tools had already computed.It doesn't anymore. The categories are still model judgement — a heuristic, then the
specific-ai-triageSLM, then an LLM fallback, all insidepre_scan_inbox. Transcribing that result isn't a judgement, so the breakdown is now rendered fromneeds_you, carrying the classifier's own reason on each row. The model writes the opening sentence and nothing else.This also unblocks every other deterministic correction in the agent.
ground_final_answerran onprocess_query's return value, which the REST/TUI stream never re-reads — so the calendar-conflict, attention-card, invite-claim and fabricated-attendee guards all fired, logged, and reached nobody on the surface users actually drive (#2789). Afinalize_answerhook on the base agent moves grounding ahead of the emission point, so one correction now serves the stream, the console, and the return value.The inbox pre-scan card is gone too: it spent a Gmail scan before the user asked for anything, then reappeared mid-turn as a partial list beside the full answer still being written — two overlapping views of one inbox with no way to tell which to act on.
Evidence
Before — same prompt, three consecutive runs:
After:
Numbers are the
refvaluesresolve_needs_you_referenceresolves against, soarchive 3now names exactly one message.Test plan
pytest hub/agents/email/python/tests/ -q— 1829 passed, 4 skippedPYTHONPATH=src pytest tests/unit/agents -q— 1381 passed, 25 skipped(cd tui && go test ./...)— all packages greenflake8 src/gaia tests— 0triage my inboxWhy the list is rendered rather than prompted
Both were tried. The skill was rewritten four times — naming the section order, naming the failing shape outright ("a reply that says '14 items need attention' and stops has failed"), forbidding raw addresses — and each time a live run ignored it. The two fixes that held were the ones moved into code.
Prompt text is also not free: the fuller wording pushed the post-tool envelope to 3511 tokens against a 3482 budget, and
test_the_whole_post_tool_turn_fits_the_window_with_skills_loadedfailed. Moving the mechanics into the renderer let the skill shrink from 22 lines describing a list to 4 lines saying "write one sentence, don't write the list" — and dropped ~450 tokens per turn that were being spent retyping ten rows.Deliberately out of scope
emailprescan.goand its tests are now unreachable from the TUI. Removing them is mechanical and belongs in its own PR.gaia.ui.sse_handlerstill mapspre_scan_inbox → email_pre_scan. That surface was not retested here, so it keeps its card; the sync test now pins the difference to exactly that one key so any other drift still fails.