fix(email): "0 messages" for mail that exists — unsupported newer_than: duration unit - #2981
Conversation
Gmail silently returns zero results for a newer_than:/older_than: value it doesn't understand -- no error, indistinguishable from an empty mailbox. The model reaches for "2w" (weeks) some of the time when asked about "the last two weeks"; Gmail has no w unit, so that turn confidently reports "no messages" for mail that is actually there. normalize_gmail_date_operators now converts the unsupported w unit to the equivalent day count (lossless) and raises an actionable error for anything else unparseable, mirroring the existing date-operator validator. The new ValueError is also caught on the REST /search route (previously an uncaught 500) and the hermetic FakeGmailBackend fixture no longer accepts w itself, so tests see what Gmail actually does.
…n gap (#2830) Checkpoint follow-up on the duration-validation commit: the fake Gmail backend's rejection of 'w' worked by falling through to its free-text branch rather than an explicit exclusion, so a future refactor of that fallback could silently re-accept 'w' without any other test noticing -- exactly how the original bug survived. Pins that behavior directly, and documents the known (never-observed) space-after-colon gap where a duration value is left unvalidated, same shape as the sibling after:/before: operators.
) verbose.py's _redact matched MFA-shaped digit runs, long URLs, and JWT-shaped tokens, but never an email address -- the '.' and '@' characters break the 40-char contiguous run the token pattern requires. The gaia_agent_email logger propagates to the root handler GaiaLogger attaches unconditionally, writing to ~/.gaia/gaia.log, which `gaia diagnostics` bundles by default and the docs tell users to attach to a public GitHub issue. A search query routinely carries a contact's address (from:alice@example.com); this is a prerequisite for the upcoming effective-query logging so that surface doesn't put contacts' addresses on a path to public disclosure. Mirrors agent.py's already-reviewed _AUTONOMY_ERROR_EMAIL_RE pattern rather than importing it, since agent.py's tool mixins import this module (importing the other direction would be circular).
search_messages already passed the post-normalization query into log_tool_call's tool_args, but that only reaches the record's structured extra -- nothing greps a log line's rendered message against it. Add a search_messages-scoped log line that renders the effective query (after duration/date normalization) and whether the zero-result operator retry fired, so a false "0 messages" report is diagnosable straight from ~/.gaia/gaia.log. Redaction (68a16a7) already covers it. Kept to this one tool -- not a general log_tool_call change.
resolve_message_target (draft_reply's target resolution) carries its own copy of the zero-result operator-query gate search_messages_impl uses. Pin the invariant that stays true after #2830: an operator target with zero hits makes exactly one backend call, with the query byte-identical to what was sent, and fails not-found -- the widener is deliberately not wired into this side-effecting path, where a bad widen would pick the wrong thread to reply to. reply_tools.py source is untouched.
Names the corrected root cause -- the unsupported w duration unit Gmail silently zeroes, not the from:"<brand>" display-name premise the issue originally blamed -- and is explicit that Outlook is unaffected: outlook_backend.py sends the whole query as a quoted Graph $search phrase and never parses Gmail operator syntax, so this fix is inert there.
Real-world evidence — macOS/arm64, live Gmail,
|
Input to search_messages |
Effective query (new log line) | Result |
|---|---|---|
from:"The Neuron" newer_than:14d |
newer_than:14d |
4 |
from:"The Neuron" newer_than:2w |
newer_than:14d |
4 |
(same 2w query straight to the Gmail API, unfixed) |
— | 0 |
search_messages effective_query='from:"The Neuron" newer_than:14d' retry=none
INPUT 'from:"The Neuron" newer_than:14d' -> count=4
search_messages effective_query='from:"The Neuron" newer_than:2w' -> normalized
INPUT 'from:"The Neuron" newer_than:2w' -> count=4
Full agent turn, live, in-process (latency=679ms — a real Gmail round-trip):
tool_call name=search_messages
search_messages effective_query='from:"The Neuron" newer_than:14d' retry=none
tool_result name=search_messages ok=True latency=679ms
→ "There are 4 messages from The Neuron in your inbox over the last two weeks."
Verified against the mailbox independently: from:"The Neuron" newer_than:14d returns exactly 4 threads. The answer is correct.
Redaction and the loud-error path, same live code path:
search_messages effective_query='from:[REDACTED] newer_than:14d' retry=none
ValueError -> search_messages: cannot parse duration value '1.5w' for the
'newer_than:' operator. Use an integer plus h/d/m/y ...
Note the address is redacted while newer_than:14d survives — the diagnostic signal is preserved, which is the whole point of the log line.
Tiers
- Unit / integration —
2657 passed, 0 failed(hub/agents/email/python/tests/ tests/unit/agents/email/). One sdist test needspip install build; without it that single test errors on a missing module, unrelated to this change. - Real-world — above, on macOS/arm64 with live Gmail and a live local model.
- Not covered: the daemon-relay path (
gaia email -q) could not be exercised — the daemon-spawned agent subprocess is denied macOS Keychain access (-128) in a non-interactive session, though the same code works in-process. That is an environment limitation of this host, not a code path this PR changes.
One honest caveat
The trigger is stochastic — the model emits 2w only some of the time, so this run's agent turn happened to emit 14d. Rather than wait on model luck, the table above feeds the 2w string directly through the fixed code to the live API, which is the deterministic form of the same proof: the input that used to return 0 now returns 4.
|
Verdict: Approve with suggestions A "last two weeks" mail search used to come back empty even when the mail was there — Gmail doesn't understand weeks and says nothing about it. This PR converts weeks to days before the query is sent, rejects genuinely malformed durations with a message that tells you what to type instead, and makes the test mailbox stop accepting a unit real Gmail rejects. That last part is the good bit: the fake was lying, which is why the bug survived a green test suite. Nothing here blocks merge. Two things are worth a look before it lands:
Real-world evidenceStrong, and it carries the verdict. The bundle shows the real sidecar run over HTTP on both this branch and the merge-base, same request, same mailbox: Deferred, with the reason stated: the new log line and its redaction, and any Agent UI pixels, both need real inference and are marked pending the strix-halo lane. Those two surfaces rest on static review here. The verified surface is the one the bug actually lived on, so I'm treating the evidence as adequate for merge. 🔍 Technical details🟡 Important1. The handler wraps the entire Normalize before the (That needs 2. A bracketed duration operand now raises where it previously worked (
Cheapest guard is to strip the grouping characters in Worth a parametrized case ( 🟢 Minor3. The effective-query log is skipped on the path you most want it for ( 4. The CHANGELOG doesn't mention the new email-address redaction. 5. Underscore-prefixed names imported across modules ( 6. Hardcoded line count in a docstring ( 7. Worth confirming: does a new Strengths
|
The duration value pattern used a bare \S+ fallback, so Gmail's own grouping punctuation was swallowed into the value: `(newer_than:7d)` captured `7d)`, failed validation and raised. A query Gmail accepts became a hard error -- a regression introduced with the duration check. Excluding `)`/`}`/`]` from the capture rather than stripping them afterwards is what keeps the query intact: the bracket never enters the match, so the substitution rewrites only `newer_than:7d` and leaves the expression balanced. Stripping post-hoc would drop the bracket and unbalance it. The REST /search handler also caught ValueError around the whole search, so any internal ValueError would reach the caller as a 400 with an internal message attached. Only the query-normalization step can be the caller's fault, so that is all the 400 now covers; the route's declared 400 description names the new condition, and the OpenAPI artifact is regenerated to match. The effective-query log line moved into a finally block -- it sat after both list_messages calls, so a backend raise left no greppable query in the log, which is the reproduce-from-a-diagnostics-bundle case it exists for.
_REDACT_PATTERNS is package-wide, so this changes tool_call/tool_result output for every tool, not just search_messages -- a deliberate privacy win with a real debuggability cost that belongs in the entry.
|
All seven addressed — two code fixes, four doc/naming, one deliberate no-change. Pushed in On #2, I did not take the suggested fix. Good catch — this was a genuine regression I introduced, and it would have hard-failed queries that work on Per-finding detail
Why no What was wrong is that the route's 400 description only documented "ambiguous or unknown account", so the new condition was undeclared. That description now names it, and Not addressed, deliberately: Suite: 2666 passed, lint clean. |
Skill audit
✅ 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 |
Closes #2830
Asking the email agent "how many emails from X in the last two weeks?" would sometimes answer 0 for mail sitting in the inbox, then list thirteen of those same messages a turn later. The cause is that the model non-deterministically writes the time window as
newer_than:2w, andwis not a Gmail duration unit — Gmail returns an empty result set for it with no error, so the agent faithfully reports "no messages". Unsupported-but-unambiguous windows are now converted (2w→14d), genuinely unparseable ones raise an actionable error instead of a silent zero, and the effective query is written to the log so the next empty search takes one line to diagnose instead of a multi-turn investigation.Important
The root cause in the issue body is wrong, and this PR does not implement what it asks for. #2830 blames the zero-result operator-retry gate and asks for it to be widened. That premise is disproven by measurement, and the widening is deliberately not implemented — see below. This is AC 1's own stated alternative ("a stated, tested reason why an operator query must not be widened"), exercised knowingly.
Evidence
Reproduced live on
main, three adjacent turns in onegaia email -i -vsession against a real mailbox. Turns 1–2 emittednewer_than:14dand answered 13. Turn 3, the identical question as turn 1, emitted:The issue reports 191 ms empty vs 2209 ms populated; this run measured 184.9 ms empty. The instability is the model's unit choice, not the mailbox — six single-shot runs all emitted
14dand all passed, which is why this reads as "sometimes it says zero".After the fix, the same query is normalized and logged (real output):
Why the issue's stated cause is wrong, and the measured accept-list
Measured against the live mailbox and the exact senders the issue cites:
from:"The Neuron" newer_than:14dfrom:"The Neuron" newer_than:2wfrom:"Last Week in AI" newer_than:14dfrom:"Last Week in AI" newer_than:2wThe issue states
from:"Last Week in AI""legitimately matches nothing" because the brand is absent from the sender address. It returns 2 — Gmail'sfrom:matches display names. The premise is false on its own example.Gmail's real duration grammar, measured (its documentation omits
hentirely):14d,14D(case-insensitive),12h,336h,1m,1y2w,2W,1.5d, bare14Only
wis converted — a week is exactly seven days, so it is lossless. Already-valid values are never re-cased; rewriting14Dto14dwould be a no-op that only adds a way to be wrong.Why the operator-retry widening is NOT implemented
from:"<brand>"returns 2, not 0 (above).newer_than:2w; a segment-preserving widening keeps that term and still returns zero, and a whole-query wrap producesfrom:(from:"X" newer_than:2w) OR …, which also returns zero.subject:'Last Week in AI'and answered 1 instead of 2. Wrong, but non-zero, so a zero-result retry never fires.reply_tools.py:297-298carries an independent copy of the same gate on the side-effectingdraft_replytarget path, with no opt-out flag. A bad widen there attaches a draft to the wrong thread.operatorize_queryandhas_gmail_operatortherefore keep their exact behaviour,test_operator_query_never_retriedstays green, and a new test pins that the reply path still makes exactly one backend call for a zero-hit operator query. Follow-up issue to be filed for a targeted widening covering both call sites.Also in this PR
_redactnever matched email addresses.~/.gaia/gaia.logis bundled bygaia diagnosticsby default and the docs tell users to attach it to a GitHub issue, so logging queries without fixing this first would put contacts' addresses on a path to public disclosure. Fixed at the shared primitive, mirroringagent.py's existing_AUTONOMY_ERROR_EMAIL_RE(fix(email-agent): one per-message error aborts the whole autonomy cycle and discards the report #2625/C5). This is a prerequisite for the logging, not a drive-by.POST /v1/email/searchreturned a bare 500 for an unparseable operator value — the route caught only the fourConnectorsErrorsubtypes. Now an actionable 400.was a valid unit, modelling the bug as correct behaviour — plausibly why this shipped at all. It no longer accepts what Gmail rejects.Outlook is unaffected.
outlook_backend.pypasses the whole query as a quoted Graph$searchphrase and parses no Gmail operator, so the corrected duration is inert there. Outlook's operator search was already non-functional; out of scope here.Test plan
python -m pytest hub/agents/email/python/tests/ tests/unit/agents/email/ -q→ 2657 passed (needspip install buildfor the sdist test; otherwise 1 unrelated env failure)mainbefore the fix, and normalization + redaction + error path exercised after (output above), on macOS/arm64 withGemma-4-E4B-it-GGUFon Lemonade