Skip to content

Commit ab41b11

Browse files
authored
fix(email): stop the assistant from narrating what the turn's tools don't support (#2659)
The email agent used to say things its own tools hadn't earned: a confident "archived" or "starred" with zero tool calls that turn, a pre-scan claiming "no urgent or actionable items" while its own scan result held some, unread counts described as spanning "across your connected mailboxes" when the number is per-INBOX-scoped, and internal payload scaffolding — a `[shown to the user]` marker, `[suggested_archives]`-style field names, raw provider ids, undecoded `\uXXXX` escapes — leaking into replies instead of being summarized. It now either backs a claim with a matching tool call from that same turn, or replaces the claim with an honest, data-grounded fallback; `check_followups` also returns an explicit `count` so a long list has nothing left to miscount. Root cause for the unicode leak: every `json.dumps` call in the shared agent loop (`src/gaia/agents/base/agent.py`) that builds model-visible tool-result text was missing `ensure_ascii=False`, so non-ASCII characters escaped as literal `\uXXXX` text — and could inflate a payload's measured length enough to trigger truncation it didn't need. Fixed at every call site there, which benefits every agent built on the shared loop, not just email; the blast-radius check (`tests/unit/ -k "agent_loop or tool_result or truncat"`) is green. This PR also now folds in #2656's calendar-conflict grounding (merged to main after this branch), which independently added the same kind of check — a post-hoc verdict the model narrated without actually calling `detect_calendar_conflicts` — but wired as a second, separate pass over the final answer. Left as two passes, one turn could get two independent disclaimers appended to a single reply, so the calendar check now lives in this PR's shared `answer_grounding.py` framework as one more rule, behind the same single hook. ## Test plan - [x] `PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring .venv/bin/python -m pytest tests/unit/agents/email/ tests/unit/email/ hub/agents/email/python/tests/ -q` — 2414 passed - [x] `PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring .venv/bin/python -m pytest tests/unit/ -q -k "agent_loop or tool_result or truncat"` — 100 passed, 0 failed (proves the shared-loop unicode fix doesn't regress other agents) - [x] `go build ./... && go test ./...` in `tui/` — all packages green - [x] `python util/lint.py --all` — all checks pass (MyPy warnings pre-existing, non-blocking) - [x] Every guard function mutation-tested, including the newly-folded calendar-conflict rule: revert the fix alone, confirm its test fails, restore, confirm green again - [x] Dedicated composition test proves the calendar-conflict and attention-card checks — both append-only and independent — fire together on the same turn rather than one short-circuiting the other - [ ] Dispatcher: 3 fresh-process `check_followups` runs against a 22-item mailbox (#2622 AC2), a 10+-turn TUI session reproducing the empty-tool-trace narration (#2621 AC1), both capturing the trailing "N steps · N tools" line Closes #2621 Closes #2622 Closes #2636 Closes #2637
1 parent 7a93bfb commit ab41b11

14 files changed

Lines changed: 1872 additions & 117 deletions

File tree

hub/agents/email/python/CHANGELOG.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,55 @@ contract version is tracked separately as
103103

104104
### Fixed
105105

106+
- **The assistant no longer narrates things the current turn's own tools
107+
don't support (#2621, #2622, #2636, #2637).** Four related honesty
108+
defects, all guarded by one new mechanism: a mutation ("archived",
109+
"starred", "marked read", "moved to Trash", ...) was sometimes narrated
110+
as done with zero tool calls in that turn (observed 7 times in one long
111+
session, correlating with conversation length); `check_followups`
112+
reported fewer awaiting-reply items than its own intact result actually
113+
held, dropping a different subset on each of 3 fresh runs; a pre-scan's
114+
framing sentence could claim "no urgent or actionable items" while its
115+
own scan result carried non-empty urgent/actionable lists, or describe
116+
a per-INBOX-scoped `total_unread` as spanning "across your connected
117+
mailboxes"; and internal render/envelope scaffolding — a
118+
`[shown to the user]` context marker, `[suggested_archives]`-style
119+
envelope field names, raw provider message ids, undecoded `\uXXXX`
120+
escapes — occasionally leaked into user-facing prose instead of being
121+
summarized. New `gaia_agent_email.answer_grounding` module runs
122+
deterministic post-checks on the final answer text at the
123+
`process_query` output boundary: an ungrounded success claim or a
124+
claim contradicted by the turn's own tool result gets replaced with a
125+
grounded fallback, and scaffolding leaks get stripped in place.
126+
`check_followups` now also returns an explicit `count` field so nothing
127+
is left to miscount. The system prompt's pre-scan coverage note was
128+
rewritten to state `scanned`/`total_unread` as two separate facts
129+
rather than a "X of Y unread" fraction (matching the attention card's
130+
own wording), and to forbid the cross-mailbox phrasing outright. Also
131+
root-caused the unicode-escape leak: every `json.dumps` call in the
132+
shared agent loop that builds model-visible tool-result text was
133+
missing `ensure_ascii=False`, so non-ASCII characters in email subjects
134+
reached the model as literal escape sequences — as a side effect, this
135+
could also inflate a payload's measured length enough to trigger
136+
truncation it did not actually need. Fixed at every call site in
137+
`src/gaia/agents/base/agent.py`, benefiting every agent built on it, not
138+
just email.
139+
- **Chat prose no longer contradicts the attention card already on screen
140+
(#2636, the other half of the fix above).** The bug as originally filed
141+
wasn't the pre-scan guard's territory: the attention card the Go TUI
142+
renders (`GET /v1/email/attention`, sections MEETING PROPOSALS/NEEDS
143+
REVIEW/ACTION ITEMS) could show real items while the same turn's answer
144+
said "no urgent or actionable items found" — because that view is never
145+
a tool call (`build_attention_view_impl` has no `@tool` wrapper; it only
146+
serves the TUI's on-open render), so the model generating the answer had
147+
no way to see it. `answer_grounding` now also reconciles the final
148+
answer against the same in-process cache the card was rendered from
149+
(extracted into a small new `attention_cache.py` so this stays possible
150+
without pulling FastAPI into the dependency-light grounding module), and
151+
appends — rather than replaces — a correction naming the card and its
152+
coverage when the two disagree. Declines to correct once that cache is
153+
older than its own freshness window (120s), so a card the user has since
154+
cleared can't get "corrected" back into looking unresolved.
106155
- **`gaia email autonomy kill` now actually stops a scheduled cycle, not
107156
just a REST/CLI session's (#2649).** The scheduler builds a brand-new,
108157
stateless agent from environment variables on every fire and never

hub/agents/email/python/gaia_agent_email/agent.py

Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to
4545
task_store,
4646
trust,
4747
)
48+
from gaia_agent_email.answer_grounding import ground_final_answer
4849
from gaia_agent_email.config import ConfigurationError, EmailAgentConfig
4950
from gaia_agent_email.model_select import (
5051
NPU_EMAIL_MODEL_ID,
@@ -61,12 +62,7 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to
6162
)
6263
from gaia_agent_email.supervision import is_daemon_supervised
6364
from gaia_agent_email.tools.briefing_tools import BriefingToolsMixin
64-
from gaia_agent_email.tools.calendar_tools import (
65-
CalendarToolsMixin,
66-
_listed_event_count_from_conversation,
67-
append_conflict_grounding_correction,
68-
response_has_ungrounded_conflict_claim,
69-
)
65+
from gaia_agent_email.tools.calendar_tools import CalendarToolsMixin
7066
from gaia_agent_email.tools.connection_tools import ConnectionToolsMixin
7167
from gaia_agent_email.tools.delete_tools import DeleteToolsMixin
7268
from gaia_agent_email.tools.followup_tools import FollowupToolsMixin
@@ -221,7 +217,11 @@ def _detect_targeted_mailboxes(query: str) -> set:
221217
require confirmation.
222218
check_followups flags sent mail still awaiting a reply; it only reports —
223219
never draft or send a follow-up nudge unless the user explicitly asks, and
224-
any send remains confirmation-gated.
220+
any send remains confirmation-gated. Its result's ``count`` field is the
221+
exact size of ``awaiting_reply`` — state that number verbatim and list
222+
EVERY entry individually; never summarize, merge, or silently drop entries
223+
to make a long list feel shorter, and never report a count you arrived at
224+
by eyeballing the list yourself.
225225
list_waiting_on_you flags INBOUND mail awaiting the user's reply (the
226226
opposite direction from check_followups) — it only reports, and only
227227
qualifies a message when it has both a genuine ask/meeting-time signal
@@ -279,6 +279,17 @@ def _detect_targeted_mailboxes(query: str) -> set:
279279
forget the user's writing style from their Sent mail. Local-only:
280280
reads mail, sends nothing; the profile is stored on-device.
281281
282+
A TOOL CALL IS THE ONLY WAY SOMETHING HAPPENED:
283+
Never tell the user a mutation (archived, starred, marked read/unread,
284+
trashed, labeled, moved, quarantined, restored, sent, forwarded, scheduled,
285+
snoozed, ...) is done, in progress, or confirmed unless you called the
286+
matching tool THIS turn and its envelope came back ``ok``. If you intend to
287+
perform an action, call the tool FIRST — its result, not your own
288+
narration, is what tells the user it happened. A long conversation may
289+
contain earlier replies where you said "X has been done"; that phrasing
290+
from a prior turn is never a reason to reuse it for a new request without
291+
placing a new, matching tool call first.
292+
282293
PRE-SCAN BEHAVIOR:
283294
When the user asks for a pre-scan, morning brief, triage view, or "what's
284295
in my inbox", call ``pre_scan_inbox``. The chat surface renders a
@@ -308,7 +319,15 @@ def _detect_targeted_mailboxes(query: str) -> set:
308319
only covered a fraction. When a mailbox failed (``degraded`` is true /
309320
``mailbox_errors`` is non-empty), say so plainly — e.g. "Outlook couldn't
310321
be scanned (token expired); results below are Gmail only." Never phrase a
311-
partial scan as if it were a whole-inbox claim.
322+
partial scan as if it were a whole-inbox claim, and state which of your
323+
own tools' results you're summarizing (a pre-scan, a briefing, a search)
324+
so the reader knows what the coverage note refers to.
325+
326+
Never claim "no urgent items" / "no actionable items" / "nothing needs
327+
you" unless the corresponding list in the result you just received (
328+
``urgent``, ``actionable``, ``needs_review``) is actually empty — a
329+
message you are calling out as needing a closer look is not "nothing",
330+
so name it instead of folding it into an all-clear sentence.
312331
313332
ALWAYS write at least one sentence of plain prose in your final answer. A
314333
render payload (a ```email_pre_scan fence or any raw JSON) must NEVER stand
@@ -405,8 +424,16 @@ def _detect_targeted_mailboxes(query: str) -> set:
405424
OUTPUT:
406425
Tool results come back as JSON envelopes ``{"ok": true, "data": ...}``
407426
or ``{"ok": false, "error": "..."}``. Summarize tool output briefly for
408-
the user — do not recite raw JSON. Write plain text only: use Unicode
409-
symbols directly (→, ≤, ×), never LaTeX/TeX markup like $\\rightarrow$.
427+
the user in your own words — never recite raw JSON, envelope field names
428+
(``suggested_archives``, ``needs_review``, ``totals``, ...), or raw
429+
provider message ids; describe the sender/subject instead, since a
430+
message id has no reader value. Earlier turns may carry a bracketed note
431+
about what a card already showed the user, added so YOU can resolve
432+
"that one" back to a message — that note is for your own reference only,
433+
never something to quote or repeat verbatim in a new reply. Write plain
434+
text only: use Unicode symbols directly (→, ≤, ×), never LaTeX/TeX markup
435+
like $\\rightarrow$, and never leave a backslash-u escape sequence
436+
unresolved — always write the actual character it represents.
410437
"""
411438

412439

@@ -460,24 +487,6 @@ def _sub(m: "re.Match[str]") -> str:
460487
return _LATEX_CMD_RE.sub(_sub, text)
461488

462489

463-
def _tool_names_from_conversation(conversation: List[Dict[str, Any]]) -> List[str]:
464-
"""Every tool name invoked this turn, in call order.
465-
466-
Mirrors the base ``Agent``'s own max-steps tool tally
467-
(``_generate_max_steps_message``) — same shape, read for a different
468-
purpose (grounding checks rather than a step-limit summary).
469-
"""
470-
names: List[str] = []
471-
for msg in conversation:
472-
if msg.get("role") != "assistant":
473-
continue
474-
for call in msg.get("tool_calls") or []:
475-
name = ((call or {}).get("function") or {}).get("name")
476-
if name:
477-
names.append(name)
478-
return names
479-
480-
481490
# Redact common credential/token shapes out of a per-row autonomy failure's
482491
# exception text before it leaves the process (#2625 — adversarial C5).
483492
# Provider/HTTP client exceptions routinely embed request/response text —
@@ -1019,17 +1028,12 @@ def process_query(self, user_input: str, *args, **kwargs):
10191028
# consumers never see raw TeX in the final answer (#2115).
10201029
if isinstance(result, dict) and isinstance(result.get("result"), str):
10211030
result["result"] = _normalize_plain_text_answer(result["result"])
1022-
# Flag a conflict verdict the model narrated itself instead of
1023-
# getting from detect_calendar_conflicts (#2571).
1024-
conversation = result.get("conversation") or []
1025-
tool_names = _tool_names_from_conversation(conversation)
1026-
listed_event_count = _listed_event_count_from_conversation(conversation)
1027-
if response_has_ungrounded_conflict_claim(
1028-
result["result"], tool_names, listed_event_count
1029-
):
1030-
result["result"] = append_conflict_grounding_correction(
1031-
result["result"]
1032-
)
1031+
if isinstance(result, dict):
1032+
# Single deterministic post-check hook: success-claim / negative-
1033+
# claim / cross-mailbox / scaffolding-leak / calendar-conflict
1034+
# (#2571) / attention-card (#2636) guards all live in
1035+
# answer_grounding.py.
1036+
result = ground_final_answer(result)
10331037
return result
10341038

10351039
def _mailbox_target_guard(self, user_input: str) -> Optional[Dict[str, Any]]:

0 commit comments

Comments
 (0)