fix(email-agent): stop meeting/invite answers from inventing what tools never said - #2784
fix(email-agent): stop meeting/invite answers from inventing what tools never said#2784itomek wants to merge 7 commits into
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.
|
Verdict: Approve with suggestions — safe to merge; one doc-sync gap worth closing first. This adds two new answer-grounding guards to the email agent (#2766): one that appends a disclaimer when the model claims a calendar invite was "sent/received/confirmed" without a mutation tool actually creating one, and one that flags invented attendee names for events whose real The one thing to fix before merge: this is a notable behavioral change to a hub package whose Worth a moment's thought (not blocking): guard 6 assumes no tool can confirm a received invite, so if the mailbox literally contains an invite email and the model correctly says "you received an invite," the disclaimer still gets appended. It's hedged ("may be a proposal") and append-only, so it softens rather than corrupts — just confirm that tradeoff is intended for genuinely-received-invite emails. Real-world evidenceThe evidence bundle (CI ubuntu lane, no inference) exercised the real surfaces this PR changes and is consistent with the diff — this supports the verdict.
🔍 Technical detailsIssues 🟡 Missing CHANGELOG entry for #2766 ( 🟢 Guard 6 disclaimer on genuinely-received invites ( Note on the negation heuristic ( Strengths
|
…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 #2580's now-merged CHANGELOG entry ahead of this one.
|
CHANGELOG entry added and pushed (484a4ef) — On guard 6's "genuinely-received invite" question: checked, and the assumption holds for what any tool in this package can currently establish. One real gap worth naming, not fixed in this PR: Google's raw API does return On the negation-heuristic note: agreed with the read — a modal appearing anywhere in the clause (e.g. "...and I will follow up") is a conservative miss, not an over-correction, consistent with the rest of the design. No change made. |
|
Filed the organizer.self gap as its own issue rather than leaving it as a PR-body note: #2787. It's larger than the earlier comment characterized — organizer.self=false (someone else organized it, i.e. genuinely invited) is the majority case for a typical calendar, not a rare one; it only reads as rare here because this PR's reference mailbox happens to be all self-organized, which the issue also calls out as a corpus gap. |
|
Re-verified on a build combining all five PRs from this run (this one plus #2774, #2782, #2783, #2788) — the calendar-attendees fix reaches the model and the user regardless of surface. The two answer-grounding guards are correct as deterministic logic, proven against real fabrication text, but a broader gap this run found (#2789) means their correction currently never reaches a live TUI (or, very likely, Agent UI) user — see below before merging on the current wording. How it was tested — three repeats, "did anyone send me a meeting invite?" as a follow-up turnLive GAIA TUI against a real Gmail mailbox (account redacted), integrated build at
Three repeats, three different outcomes (fabricated-and-guarded, clean, differently-shaped-but-correct) — that instability is itself worth noting: it's what a single clean run can't tell you, and it's consistent with the calendar-attendee logic itself being sound while the surrounding conversation is inherently variable. The calendar-side fix ( Combined-tree suite: 10636 passed, 8 pre-existing failures (parity-confirmed against clean Integrated tree: |
|
Closing in favour of #2833, which is the same branch and the same commits reopened from a fork.
#2833 carries the identical commits plus a clean merge of current |
Pull request was closed
…ls never said (amd#2833) > Supersedes amd#2784 — same branch, same commits, reopened from a fork because `itomek` no longer has write access to `amd/gaia`, which left amd#2784's head branch unpushable. Review history from amd#2784 still applies; the merge-blocking caveat from it is repeated in a comment below. 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 `organizer` field correctly and then narrates "you sent an invite" from it, which isn't what that field means. The rest is that `list_calendar_events`/`detect_meeting_request` never gave the model a structured way to say who's attending or whether an invite was actually sent, so it composed one. Now `list_calendar_events`/`detect_calendar_conflicts` surface each event's real `attendees` (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 amd#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: amd#2766 (comment). Closes amd#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. - [x] `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 regressions - [x] `python -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 to `calendar_tools.py`/`answer_grounding.py`) - [x] `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) - [x] 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 amd#2776. Live before/after evidence below substitutes. - [x] TUI + REST evidence, before (`main`) and after (this branch), 3x multi-turn repeat each, at the issue's own condition — see below ## Evidence **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_conflicts` also now return each event's real `attendees` (`[]` 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: - Ryzen AI NPU (`gemma4-it-e2b-FLM`, ctx 32768, the default profile that hardware resolves to): **0 of 3** fabricated on `main`, fresh multi-turn, 3 repeats. Doesn't reproduce there at all. Full table: issue comment. - GPU (`Gemma-4-E4B-it-GGUF`, ctx 65536 — the issue's own condition), pooled across two machines: **3 of 6** fabricated on `main` (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 via `GET /api/v1/health` before 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):** | Run | Turn 2 tool | Turn 2 result | Verdict | |---|---|---|---| | 1 | `list_calendar_events` | "Yes, you have three upcoming meetings/invitations on your calendar, all organized by Tomasz Iniewicz" — organizer misread as invite-sender | **FABRICATED** | | 2 | `search_messages` | "no such invitations were found" | PASS | | 3 | `search_messages` | "did not find any recent incoming meeting invitations" | PASS | 1 of 3 fabricated on this machine — confirms reproduction at the issue's documented condition, same mechanism as the original report (real `organizer` field misread as "sent an invite"). **After (`issue-2766` @ 2a766c9, same worktree, branch switched in place, TUI rebuilt and daemon/sidecar restarted for each state):** | Run | Turn 2 tool | Turn 2 result | Guard fired? | Verdict | |---|---|---|---|---| | 1 | `list_calendar_events` | "...none of these events currently list any other attendees in the details provided by your calendar system." | No | PASS | | 2 | `list_calendar_events` (x2) | "...scheduled with Tomasz Iniewicz... If you were asking about any pending or unaccepted invitation emails... let me know!" | No | PASS (no invite reported, no attendee named — see caveat below) | | 3 | `list_calendar_events` | "All of these were organized by Tomasz Iniewicz. However, none of the retrieved events currently list any named attendees besides the organizer." | No | PASS | 3 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: `attendees` is 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 `organizer` field being misread inside `list_calendar_events`' own output (run 1 above, and Radeon's earlier run 3). The two clean before-runs called `search_messages` instead — a tool with no `organizer` field 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_events` fabricated 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_events` after the fix — 1 of 3 before, 3 of 3 after. This PR changed that tool's schema (added `attendees`) 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 amd#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_conflicts` expose `organizer.email` but not Google's `organizer.self` flag, and `accept_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: **amd#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: surface `organizer.self` the same way `attendees` was 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) `main` advanced past amd#2784's last sync (amd#2782, amd#2788 both touch this package). Merged `upstream/main` into the branch — clean, no conflicts, net diff unchanged (still only the 5 files above). - [x] `pytest hub/agents/email/python/tests/ -q` — **1762 passed, 4 skipped** on the merged head (was 1739 at amd#2784; the delta is `main`'s own new tests from amd#2782/amd#2788, not new tests here)
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.