Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions hub/agents/email/python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,45 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/); the REST
contract version is tracked separately as
`gaia_agent_email.contract.SCHEMA_VERSION` (see `CONTRACT.md`).

## [Unreleased]

### Fixed

- **A `newer_than:`/`older_than:` search could report 0 messages for mail
that exists (#2830, Gmail mailboxes only).** The issue blamed
`from:"<brand>"` matching nothing on a display name — disproven: that
query matched fine. The real cause is `w` (weeks), which the model
reaches for but Gmail does not implement as a duration unit — Gmail
silently returns an empty result set instead of an error, so `newer_than:2w`
and a working `newer_than:14d` looked identical to the agent. Duration
values on `newer_than:`/`older_than:` are now validated and `w` converted
to days before the query reaches Gmail; an unrecognized unit now raises an
actionable error instead of a silent zero-result search. The `search`
REST endpoint's error path for a bad duration also stopped returning a
bare `500` and now surfaces the actionable `400`. The effective
(post-normalization) query and retry state are now logged for
`search_messages`, so a future zero-result report is diagnosable from
`~/.gaia/gaia.log` without reproducing it live.
**Outlook mailboxes are unaffected by this fix** — `outlook_backend.py`
sends the whole query as a single quoted Microsoft Graph `$search` phrase
and never parses Gmail operator syntax, so the corrected duration handling
has no effect there; operator search against Outlook was already
non-functional before and after this change.

### Changed

- **Email addresses are now redacted from verbose tool-call logs.** The
`tool_call` / `tool_result` records emitted for **every** tool previously
passed addresses through unscrubbed — `_REDACT_PATTERNS` matched MFA codes,
long URLs and JWT-shaped tokens, but nothing address-shaped. Since
`~/.gaia/gaia.log` is bundled by `gaia diagnostics` by default and the docs
ask users to attach that bundle to a public issue, a contact's address could
travel from a local search straight into a public bug report. Addresses now
render as `[REDACTED]`. This is deliberate privacy hardening with a
debuggability cost: a recipient in a verbose `send_email` log line, for
instance, is no longer readable, and logs written before this release still
contain the raw values.

## [0.6.0] - 2026-08-12

### Added
Expand Down
40 changes: 38 additions & 2 deletions hub/agents/email/python/gaia_agent_email/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1347,6 +1347,20 @@ def get_search_backend():
return LiveOutlookBackend(functools.partial(_get_outlook_token, provider))


def _normalize_search_query(query: Optional[str]) -> Optional[str]:
"""Normalize a search query's date/duration operators, or raise ``ValueError``.

Split out of ``_search_inbox`` so the route can map ONLY this failure to a
400 (#2830). Lazily imported for the same reason ``_search_inbox`` is: the
OpenAPI export must not pull in the live-mail machinery.
"""
from gaia_agent_email.tools.read_tools import normalize_gmail_date_operators

if not query:
return query
return normalize_gmail_date_operators(query)


def _search_inbox(
backend: Any,
*,
Expand Down Expand Up @@ -2122,6 +2136,20 @@ def _triage_batch_and_persist(request: BatchTriageRequest) -> BatchTriageRespons
# Passing an unconnected provider, or omitting it with 2+ accounts connected,
# is ambiguous — the backend resolvers refuse to guess (fail-loud, never a
# silent pick). Shared by every route that resolves a provider-bound backend.
# ``/search`` can 400 for a second reason: an unparseable date/duration
# operator value in ``query`` (#2830). Declared separately so the spec says
# which failures a caller can actually provoke.
_SEARCH_400 = {
400: {
"description": (
"Ambiguous or unknown account: the named provider is not connected, "
"or no provider was given while several accounts are connected. Also "
"returned when ``query`` carries an unparseable date or duration "
"operator value (e.g. ``newer_than:1.5w``)."
)
}
}

_AMBIGUOUS_PROVIDER_400 = {
400: {
"description": (
Expand Down Expand Up @@ -2201,7 +2229,7 @@ async def triage_email_batch(request: BatchTriageRequest) -> BatchTriageResponse
@router.post(
"/search",
response_model=EmailSearchResponse,
responses={**_CONNECTOR_ERROR_RESPONSES, **_AMBIGUOUS_PROVIDER_400},
responses={**_CONNECTOR_ERROR_RESPONSES, **_SEARCH_400},
)
async def search_inbox(
request: EmailSearchRequest,
Expand All @@ -2219,11 +2247,19 @@ async def search_inbox(
ambiguous mailbox counts). Backend auth / config / transport errors surface
as actionable 4xx/5xx, never a silent empty result.
"""
# Normalize here, not around the whole search: only an unparseable date /
# duration operator is the caller's fault (400). A ValueError raised deeper
# inside the search is a server fault and must not be reported as a bad
# request with an internal message attached (#2830).
try:
normalized_query = _normalize_search_query(request.query)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
try:
return await asyncio.to_thread(
_search_inbox,
backend,
query=request.query,
query=normalized_query,
labels=request.labels,
max_results=request.max_results,
page_token=request.page_token,
Expand Down
81 changes: 81 additions & 0 deletions hub/agents/email/python/gaia_agent_email/gmail_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
"""Gmail search-query grammar: ``newer_than:``/``older_than:`` durations (#2830).

Gmail silently returns zero results for a duration value it doesn't
understand — no error, indistinguishable from an empty mailbox. A 4B-class
model asked for "the last two weeks" reaches for ``newer_than:2w`` some of
the time; Gmail has no ``w`` unit, so that turn confidently reports "no
messages" for mail that is actually there. This module is the validator:
parse the value, normalize what Gmail silently rejects but a human clearly
meant, and raise an actionable ``ValueError`` for anything else — mirroring
``tools.read_tools._parse_gmail_date_value``'s contract for the sibling
``after:``/``before:``/``older:``/``newer:`` operators.

The accept-list below is measured directly against live Gmail, not read
from a doc — Gmail's own documentation omits ``h`` (hours) entirely, yet
``newer_than:12h`` is a working query.

A peer module rather than living in ``tools/read_tools.py`` (already very
large) — imported from there, which is the sole call site that wires it into
``normalize_gmail_date_operators``.
"""

from __future__ import annotations

import re

# Value grammar mirrors read_tools._DATE_OP_RE's quoted-string/bare-token
# fallback so a malformed value still reaches the validator instead of being
# silently passed through (`newer_than:"2w"`, `newer_than:-3d`) -- but it stops
# at Gmail's grouping punctuation: `(newer_than:7d)` must capture `7d`, never
# `7d)`. Stripping the bracket after the fact would drop it from the rewritten
# query and unbalance the expression, so it must never enter the match.
#
# Op group is exactly newer_than|older_than, not newer(?:_than)? -- the
# broader form would also match bare newer:/older: and misparse their
# already-correct date as a duration.
DURATION_OP_RE = re.compile(
r"""
\b(?P<op>newer_than|older_than):
(?P<val>
"[^"]*"
| [^\s)}\]]+
)
""",
re.IGNORECASE | re.VERBOSE,
)

# `\d+` (no sign, no dot) rejects "1.5w"/"-3d" by simply not matching --
# no separate range/type check needed after the fact.
_DURATION_VALUE_RE = re.compile(r"^(\d+)([A-Za-z]+)$")

# Passed through byte-identical, case preserved -- re-casing an already-valid
# value (e.g. "14D") is a no-op that only adds a way to be wrong.
_DURATION_PASSTHROUGH_UNITS = {"h", "d", "m", "y"}


def parse_gmail_duration_value(raw: str, *, op: str) -> str:
"""Parse one ``newer_than:``/``older_than:`` value into what Gmail accepts.

Accepts an integer count plus a case-insensitive ``h``/``d``/``m``/``y``
unit, returned byte-identical to how it was written. ``w`` (weeks) is
not a Gmail unit -- Gmail silently returns zero results for it rather
than an error -- so it is converted to the equivalent day count
(lossless: a week is exactly seven days). Anything else raises
``ValueError`` naming the accepted units and an example, per
``_parse_gmail_date_value``'s precedent.
"""
value = raw.strip().strip('"').strip()
m = _DURATION_VALUE_RE.fullmatch(value)
unit = m.group(2).lower() if m else ""
if m and unit in _DURATION_PASSTHROUGH_UNITS:
return value
if m and unit == "w":
return f"{int(m.group(1)) * 7}d"
raise ValueError(
f"search_messages: cannot parse duration value {raw!r} for the "
f"'{op}:' operator. Use an integer plus h/d/m/y — hours/days/"
f"months/years (e.g. {op}:14d for 14 days). 'w' (weeks) is not a "
f"Gmail unit; use the equivalent day count instead."
)
49 changes: 33 additions & 16 deletions hub/agents/email/python/gaia_agent_email/tools/read_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
skill_prompt_tokens,
)
from gaia_agent_email.gmail_backend import decode_message_body
from gaia_agent_email.gmail_query import DURATION_OP_RE, parse_gmail_duration_value
from gaia_agent_email.tools.envelope import _envelope_err, _envelope_ok

# Re-exported so the pre-scan tests can monkeypatch ``read_tools.make_llm_classifier``
Expand All @@ -67,6 +68,7 @@
)
from gaia_agent_email.tools.usage import aggregate_usage_stats
from gaia_agent_email.verbose import (
log_search_effective_query,
log_tool_call,
log_triage_decision,
log_triage_dispatch,
Expand Down Expand Up @@ -845,13 +847,17 @@ def _parse_gmail_date_value(raw: str, *, op: str) -> str:


def normalize_gmail_date_operators(query: str) -> str:
"""Rewrite date-operator values in ``query`` to Gmail's ``YYYY/MM/DD``.
"""Rewrite date-operator values in ``query`` to Gmail's ``YYYY/MM/DD``,
and duration-operator (``newer_than:``/``older_than:``) values to a unit
Gmail accepts.

Relative recency words (``after:today`` / ``newer:yesterday``) are rewritten
to the timezone-robust ``newer_than:`` window so a present same-day message
is reliably matched. Raises ``ValueError`` on an otherwise-unparseable value
— a loud error beats passing it through as free text and returning a false
zero-result.
zero-result. The two operator families never overlap (``_DATE_OP_RE``
excludes the ``_than`` forms), so applying both substitutions in sequence
is safe.
"""

def _sub(m: "re.Match[str]") -> str:
Expand All @@ -861,7 +867,12 @@ def _sub(m: "re.Match[str]") -> str:
return f"newer_than:{_RELATIVE_DAY_WINDOWS[bare]}"
return f"{op}:{_parse_gmail_date_value(m.group('val'), op=op)}"

return _DATE_OP_RE.sub(_sub, query)
def _duration_sub(m: "re.Match[str]") -> str:
op = m.group("op")
return f"{op}:{parse_gmail_duration_value(m.group('val'), op=op)}"

query = _DATE_OP_RE.sub(_sub, query)
return DURATION_OP_RE.sub(_duration_sub, query)


# Gmail search operators (a leading ``token:`` in the query). If a query
Expand Down Expand Up @@ -949,20 +960,26 @@ def search_messages_impl(
},
debug=debug,
) as st:
listing = gmail.list_messages(query=query, max_results=max_results)
stubs = listing.get("messages", [])
retried_query = None
# A literal-phrase query with zero hits is the #2114 failure mode:
# retry once as an operator query before giving up. Only when the
# user's query carried no operator of its own (else we'd second-guess
# an intentional ``from:`` search).
if not stubs and operator_retry and not has_gmail_operator(query):
retried_query = operatorize_query(query)
if retried_query != query:
listing = gmail.list_messages(
query=retried_query, max_results=max_results
)
stubs = listing.get("messages", [])
# ``finally`` so the effective query still reaches the log when the
# backend raises -- that is the reproduce-from-a-diagnostics-bundle
# case this line exists for.
try:
listing = gmail.list_messages(query=query, max_results=max_results)
stubs = listing.get("messages", [])
# A literal-phrase query with zero hits is the #2114 failure mode:
# retry once as an operator query before giving up. Only when the
# user's query carried no operator of its own (else we'd
# second-guess an intentional ``from:`` search).
if not stubs and operator_retry and not has_gmail_operator(query):
retried_query = operatorize_query(query)
if retried_query != query:
listing = gmail.list_messages(
query=retried_query, max_results=max_results
)
stubs = listing.get("messages", [])
finally:
log_search_effective_query(query=query, retried_query=retried_query)
if include_bodies:
full_msgs = [gmail.get_message(stub["id"]) for stub in stubs]
out = _format_messages_within_budget(
Expand Down
33 changes: 33 additions & 0 deletions hub/agents/email/python/gaia_agent_email/verbose.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
re.compile(r"\b\d{6,8}\b"), # MFA codes
re.compile(r"https?://\S{30,}"), # password reset URLs
re.compile(r"(?=[A-Za-z0-9_\-]{40,})(?=[A-Za-z0-9_\-]*\d)[A-Za-z0-9_\-]{40,}"),
# Email addresses -- mirrors agent.py's _AUTONOMY_ERROR_EMAIL_RE
# (#2625/C5); not imported from there since agent.py's tools/* mixins
# import this module, which would be circular (#2830).
re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"),
]


Expand Down Expand Up @@ -176,9 +180,38 @@ def log_tool_call(
)


def log_search_effective_query(
*, query: str, retried_query: Optional[str] = None
) -> None:
"""``search_messages``-scoped: the effective (post-normalization) query
and retry outcome, rendered into the log MESSAGE itself.

``tool_call``'s ``tool_args`` already carries the effective query
structured in ``extra`` (``query`` is reassigned before that context
opens), but nothing greps a log MESSAGE against ``extra`` fields -- a
false "0 messages" report (#2830) needs the query visible in the text
that ``gaia diagnostics`` bundles and users paste into issues.
"""
redacted_query = _redact(query)
retry_state = (
"none" if retried_query is None else f"retried_to={_redact(retried_query)!r}"
)
logger.info(
"search_messages effective_query=%r retry=%s",
redacted_query,
retry_state,
extra={
"stage": "search_query",
"effective_query": redacted_query,
"retry_state": retry_state,
},
)


__all__ = [
"log_triage_decision",
"log_triage_dispatch",
"log_tool_call",
"log_search_effective_query",
"logger",
]
2 changes: 1 addition & 1 deletion hub/agents/email/python/openapi.email.json
Original file line number Diff line number Diff line change
Expand Up @@ -4290,7 +4290,7 @@
"description": "Successful Response"
},
"400": {
"description": "Ambiguous or unknown account: the named provider is not connected, or no provider was given while several accounts are connected."
"description": "Ambiguous or unknown account: the named provider is not connected, or no provider was given while several accounts are connected. Also returned when ``query`` carries an unparseable date or duration operator value (e.g. ``newer_than:1.5w``)."
},
"403": {
"description": "Authorization failed — the account's auth is missing, expired, or revoked (reconnect via Settings → Connectors), or a confirmation gate rejected the request (mint a fresh confirmation token)."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,29 @@ def test_no_match_raises_not_found():
assert "nobody@nowhere.example" in str(exc.value)


# ---------------------------------------------------------------------------
# CUT invariant (#2830 plan item 6): the zero-result operator-query widener
# that search_messages_impl uses is deliberately NOT wired into this
# side-effecting draft_reply path -- a bad widen here would pick the wrong
# thread to reply to, not just show 0 results. An operator target that
# matches nothing must make exactly one backend call, with the query
# byte-identical to what was sent, and fail not-found -- never silently
# retry with a widened query.
# ---------------------------------------------------------------------------


def test_operator_target_zero_hits_makes_exactly_one_call_unchanged_query():
gmail = _backend() # empty mailbox
target = 'from:"The Neuron" newer_than:14d'
with pytest.raises(ValueError) as exc:
resolve_message_target({"google": gmail}, target=target)
text = str(exc.value).lower()
assert "no message" in text or "not found" in text
calls = _list_calls(gmail)
assert len(calls) == 1
assert calls[0][1]["query"] == target


# ---------------------------------------------------------------------------
# Explicit mailbox scoping
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading