fix(email-agent): stop meeting/invite answers from inventing what tools never said - #2833
Conversation
…ls never said detect_meeting_request and list_calendar_events gave the model no structured way to state who is attending an event or whether an invite was actually sent — asked "any meetings coming up?" or "did anyone send me an invite?", the model had nothing to fall back on but free composition, and live probes show it invents attendee names and invite claims that appear nowhere in the mailbox or the tool trace. Two changes, same "tool computes, model reports" pattern already used for the calendar-conflict and attention-card guards in this file: - list_calendar_events / detect_calendar_conflicts now surface each event's real attendees (previously discarded entirely) so there is grounded data to check an attendee claim against. - Two new deterministic post-hoc guards in answer_grounding.py: one flags an invite claimed as sent/received when no tool this turn could have confirmed one, the other flags a named attendee/invitee when the turn's own calendar tool result shows none. Both append a correction rather than discarding the rest of an otherwise-useful answer, matching the existing guards' shape. Tool docstrings for list_calendar_events, detect_calendar_conflicts, and detect_meeting_request now say this explicitly to the model too.
…e phrasing "an invite would be sent soon" and "nobody has sent you an invite" were false positives — the first is a hypothetical, not a completed-action claim, and "nobody" wasn't recognized as a negation (\bno\b doesn't match inside "Nobody"). Found by stress-testing the guard against phrasings beyond the two live-captured fabrications it was built from.
…tendees" find_fabricated_attendee_claim matched on the words "attendee"/"invitee" alone, so a model correctly reporting the real, empty attendees list (e.g. "no attendees are listed") tripped the same correction as actually inventing a name. Shares the invite guard's clause-negation check (renamed _INVITE_NEGATION_RE -> _CLAUSE_NEGATION_RE, now common infrastructure) so a denial is recognized as one. Also locks in a case raised independently while investigating this issue: the real, populated `organizer` field is not `attendees` — correctly describing who organized an event must never trip either guard. The model's actual failure mode there (mistaking organizer-is-self for "you sent an invite") is exactly what the invite-claim guard exists to catch.
… test The organizer-grounding test was built from the real captured value during live investigation. Fixtures and committed code must use synthetic data regardless of what a live capture contained -- the test's semantics hold for any address.
…ng fix Was missed in the original commits -- every other branch in this batch touched CHANGELOG.md while making an unrelated change and so picked up merge-conflict pressure that surfaced the omission; this one didn't touch any shared region and slipped through. Also merges origin/main to pick up amd#2580's now-merged CHANGELOG entry ahead of this one.
|
Carrying forward the review context from #2784, since comments don't move with a reopened PR. Read this before merging: the structural half of this fix (calendar events now carry their real 🔍 The rest of #2784's review trail
|
|
Verdict: Approve — clean, well-tested bugfix that follows the email agent's established grounding pattern. This PR closes #2766: when asked about upcoming meetings or invites, the agent could invent attendee names and claim invites were "sent/received" with nothing behind them. It fixes this two ways — the calendar tools now surface each event's real Real-world evidenceN/A in this run — no 🔍 Technical detailsStrengths
🟢 Minor — overlap with the success-claim guard (answer_grounding.py:697 vs :251) 🟢 Minor — Nothing else. Nits are within cap; no security concerns; no architecture/convention violations. |
|
Live TUI evidence on the merged head, at the issue's own condition. PASS, with the grading caveat spelled out below rather than rounded up. Setup: real Gmail mailbox, GAIA TUI built from this branch ( Turn 1 —
No invented attendee names, no invite claim, no organizer misread. This is the tool that can actually exhibit the defect, and it didn't. Turn 2 —
That is the exact discrimination #2766 is about, made explicitly and correctly. Neither guard fired — grepped the sidecar log across the whole session for every guard-fired warning: zero matches. So, as with the runs already in the description, this evidences the structural half of the fix (real How to read the grade, honestly. Turn 2 routed to Tool trace from the sidecar logUnrelated to this PR but found in the same session and worth linking so it isn't rediscovered: #2834 — a thread found in one turn can't be referenced in the next, because no tool result carries its id into the TUI's context. |
Asked about upcoming meetings or calendar invites, the email agent could invent them — attendee names and invite confirmations that exist nowhere in the mailbox or the tool trace. Part of this is misinterpretation, not invention: the agent reads the calendar's real
organizerfield correctly and then narrates "you sent an invite" from it, which isn't what that field means. The rest is thatlist_calendar_events/detect_meeting_requestnever gave the model a structured way to say who's attending or whether an invite was actually sent, so it composed one. Nowlist_calendar_events/detect_calendar_conflictssurface each event's realattendees(previously discarded), and two deterministic guards catch an invite claimed as sent/received or an attendee named for an event the tool result shows has none — the same "tool computes, model reports" pattern already used for this file's calendar-conflict and attention-card checks. Both guards are negation-aware (an honest "no attendees are listed" or "no invite was sent" is never corrected) and leave a correctly-reported organizer alone. These guards close the two specific fabrication patterns the issue measured (unconfirmed invite claims, invented attendees); they are not a general hallucination filter — a narrower fabrication shape they don't cover is filed separately as #2778.The REST surface passed the same probe cleanly in the original report while the TUI didn't. Investigation found this is not a TUI-vs-REST code difference — the TUI calls its tools fresh every turn, and the on-open card never reaches the model's context — but multi-turn conversation history: a fresh isolated question stays grounded on both surfaces every time; the same question as a follow-up in an ongoing session is where the fabrication risk lives, on either surface. Full writeup with the probe table: #2766 (comment).
Closes #2766
Test plan
Evidence below is graded on two levels, not one — a guard correcting a fabrication is real progress but is a different (weaker) result than the model never fabricating in the first place:
PASS — no fabricated invite claim and no fabricated attendee name in the model's own text; the guard never had to fire.
GUARDED — the model fabricated and a guard appended a correction. Better than
main, but not a clean run.python -m pytest hub/agents/email/python/tests/ -q— 1739 passed (39 new: 2 new guards' unit + wiring tests, a new attendees-field test file), no regressionspython -m pytest tests/unit/ -q— 8820 passed, 8 pre-existing failures with zero overlap with the files this PR touches (CLI-binary-on-PATH, hub-wheel-install, Claude-judge API key, VLM PDF extraction — unrelated tocalendar_tools.py/answer_grounding.py)python util/lint.py --all— clean on every line touched (two pre-existing black/isort drift spots in files this PR touches were left as-is — scope-clean, not missed;hub/isn't in this repo's CI lint path today)Mandatory eval for a tool-docstring change (CLAUDE.md's LLM-affecting-change rule): assessed, not run —
gaia eval benchmark(the hermetic email-triage harness) currently scores nothing on any branch, tracked in email-triage benchmark harness scores nothing — stale ctx envelope, id-mismatch scoring join, zero LLM escalation #2776. Live before/after evidence below substitutes.TUI + REST evidence, before (
main) and after (this branch), 3x multi-turn repeat each, at the issue's own condition — see belowEvidence
The deterministic evidence is the real proof, not the live runs. Two of the 39 new unit tests replay the exact text this issue's own live fabrications produced — "an invite has been confirmed as sent" (the ObjectWin HR text) and "Tomasz Iniewicz sent you invites" — and confirm both are caught and corrected by the new guards. That's repeatable and condition-independent, unlike a live LLM sample.
list_calendar_events/detect_calendar_conflictsalso now return each event's realattendees([]for every event in this mailbox) instead of discarding the field, which is what the live runs below show the model actually reading and citing.Live runs are corroboration, presented with the base rate — not as standalone proof. This defect is stochastic (temp=1.0 sampling) and condition-specific:
gemma4-it-e2b-FLM, ctx 32768, the default profile that hardware resolves to): 0 of 3 fabricated onmain, fresh multi-turn, 3 repeats. Doesn't reproduce there at all. Full table: issue comment.Gemma-4-E4B-it-GGUF, ctx 65536 — the issue's own condition), pooled across two machines: 3 of 6 fabricated onmain(Radeon 2/3, this Mac 1/3) — roughly a 50% base rate.Against a 50% base rate, three consecutive clean runs happen by chance alone about 1 time in 8 (0.5³ ≈ 12.5%) even with no fix at all. That is suggestive, not conclusive — stated plainly rather than presented as a clean "3/3, fix confirmed."
Before/after at the issue's own condition — GPU,
Gemma-4-E4B-it-GGUF, ctx 65536, confirmed viaGET /api/v1/healthbefore each capture. Multi-turn config: turn 1 "Any meetings coming up?", turn 2 (same session) "Did anyone send me a meeting invite?" — the config that reproduces.Before (
main@ e135b8e):list_calendar_eventssearch_messagessearch_messages1 of 3 fabricated on this machine — confirms reproduction at the issue's documented condition, same mechanism as the original report (real
organizerfield misread as "sent an invite").After (
issue-2766@ 2a766c9, same worktree, branch switched in place, TUI rebuilt and daemon/sidecar restarted for each state):list_calendar_eventslist_calendar_events(x2)list_calendar_events3 of 3 PASS — read against the ~12.5%-by-chance figure above, not as a standalone "fix confirmed." Neither guard fired live in any of the 3 runs (grepped the sidecar log for every guard-fired warning across the whole session: zero matches), so this result cannot be attributed to the guards catching anything — they were never exercised here. What most plausibly explains the clean runs is the structural half of the fix:
attendeesis now a real, visible[]in the tool's JSON, and all 3 runs explicitly cite it ("no attendees were listed," "none... list any named attendees besides the organizer") rather than silently omitting or inventing one. The docstring wording added alongside it is unproven by this run — a sibling issue in this same batch measured a tool-docstring instruction NOT reliably changing model behavior on this stack when it was the only mechanism, only becoming reliable once made structural, and nothing here contradicts that.A sharper comparison, conditioning on the tool actually used. The fabrication mechanism this fix targets is specifically the
organizerfield being misread insidelist_calendar_events' own output (run 1 above, and Radeon's earlier run 3). The two clean before-runs calledsearch_messagesinstead — a tool with noorganizerfield to misread, so they were never at risk and say nothing about whether the fix works. Conditioning on the tool that can actually exhibit the bug: before,list_calendar_eventsfabricated 1 of the 1 time it ran on turn 2; after, it fabricated 0 of 3. Still small-n, but it's a like-for-like comparison landing on exactly the field this PR changed, rather than diluted by runs the bug couldn't have touched.Worth a reviewer's eye, stated as an n=3 observation rather than a claim: tool selection for turn 2 also converged on
list_calendar_eventsafter the fix — 1 of 3 before, 3 of 3 after. This PR changed that tool's schema (addedattendees) and its docstring, and schema/docstring text is exactly what a tool-calling model routes on. So the docstring half may not be inert after all — it may be steering tool selection rather than preventing fabrication once called, which is a different mechanism than intended and the opposite of what #2763's measurement on this same stack would predict. Flagging this as something to watch, not something to conclude from n=3.One phrasing worth flagging rather than silently passing: run 2's "scheduled with Tomasz Iniewicz" is loose enough it could be misread as him being a co-attendee rather than the organizer — it doesn't name him in an attendee position or claim an invite, so it doesn't trip either of the issue's precise grading rules, but it's not the tightest possible phrasing either.
Guard 6's assumption, checked
The bot flagged (non-blocking) that guard 6 assumes no tool can confirm a genuinely-received invite. Checked: the assumption holds today —
list_calendar_events/detect_calendar_conflictsexposeorganizer.emailbut not Google'sorganizer.selfflag, andaccept_invite/decline_invite's result envelope carries no invite-provenance signal, so nothing callable today distinguishes "someone else invited you" from "you organized this."create_event_from_email(the guard's one exception) is the opposite direction — an outbound invite the agent sends, not an inbound one it receives.This is bigger than a rare edge case, so it's filed rather than left as a PR-body note: #2787.
organizer.self = false— someone else organized it, i.e. the user was invited — is the majority case for a typical work calendar, not the minority one; it's only invisible here because this PR's reference mailbox happens to be all self-organized (3 of 3 events,organizer.self = true), which is itself a corpus gap. As shipped, guard 6 will disclaim true "you received an invite" statements for most real users' calendars. The fix follows this PR's own pattern exactly: surfaceorganizer.selfthe same wayattendeeswas surfaced here, then ground guard 6 against it. Scoped out of this PR to avoid destabilizing an already-reviewed change, not because the gap is minor.Re-verified after merging
main(this PR's head)mainadvanced past #2784's last sync (#2782, #2788 both touch this package). Mergedupstream/maininto the branch — clean, no conflicts, net diff unchanged (still only the 5 files above).pytest hub/agents/email/python/tests/ -q— 1762 passed, 4 skipped on the merged head (was 1739 at fix(email-agent): stop meeting/invite answers from inventing what tools never said #2784; the delta ismain's own new tests from fix(email-agent): search_messages defaults to metadata-only, fixing context overflow on counting questions #2782/fix(email-agent): get_thread renders a table card instead of relying on model prose #2788, not new tests here)