From e586bdc7f86f1b38e2efe054f4006eb5e801f26e Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 1 Jul 2026 14:41:10 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(email):=20follow-up=20tracking=20?= =?UTF-8?q?=E2=80=94=20flag=20sent=20mail=20awaiting=20a=20reply=20(#1606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dropped thread is the inbox's biggest silent failure mode: you send a question, nobody answers, and nothing resurfaces it. The agent can now scan the Sent folder and flag every thread whose newest message is still the user's own once it is older than a configurable window (followup_window_days, default 3 days, or per call) via the new read-only find_awaiting_reply tool — message id, recipient, subject, and age, most overdue first. Detection only, per the #555 boundary: the module imports no send path (the unit tests assert both the module source and the backend transport calls stay read-only), and any actual chaser goes through the confirmation-gated reply tools at the user's request. Gmail-only for now — the Graph backend serves the inbox folder for unrecognized labels, so a Microsoft-only setup gets a loud refusal instead of a silently wrong scan. --- docs/guides/email.mdx | 5 +- hub/agents/npm/agent-email/CHANGELOG.md | 13 + hub/agents/npm/agent-email/README.md | 7 +- hub/agents/npm/agent-email/SKILL.md | 6 + hub/agents/npm/agent-email/SPEC.md | 7 +- .../python/email/gaia_agent_email/agent.py | 9 +- .../python/email/gaia_agent_email/config.py | 9 + .../gaia_agent_email/tools/followup_tools.py | 269 +++++++++++ hub/agents/python/email/specification.html | 11 +- .../agents/email/test_followup_tracking.py | 423 ++++++++++++++++++ 10 files changed, 747 insertions(+), 12 deletions(-) create mode 100644 hub/agents/python/email/gaia_agent_email/tools/followup_tools.py create mode 100644 tests/unit/agents/email/test_followup_tracking.py diff --git a/docs/guides/email.mdx b/docs/guides/email.mdx index a4aeb7e44..2dbab0d95 100644 --- a/docs/guides/email.mdx +++ b/docs/guides/email.mdx @@ -11,6 +11,7 @@ The Email Triage Agent connects to your Gmail account through GAIA's connectors ## What it does - **Triage your inbox** — classify every message as `urgent`, `actionable`, `informational`, or `low priority`, plus separate `is_spam` and `is_phishing` flags. +- **Follow-up tracking** — flag the emails *you* sent that never got a reply, once they're older than a configurable window (default 3 days). Read-only detection: the agent surfaces the overdue threads, it never auto-sends a chaser. - **Organize** — archive, label, mark read/unread, star/unstar. Reversible via the per-action undo log. - **Soft-delete with undo** — `trash_message` records the action; `restore_message` reverses it within a 30-second window. - **Draft + confirmed send** — generate replies (`draft_reply`) and forwards (`draft_forward`); `send_draft` and `send_now` require explicit user confirmation in the UI. @@ -147,7 +148,9 @@ Senders you reply to quickly are automatically promoted to priority on the next ### Read -`list_inbox`, `get_message`, `get_thread`, `search_messages`, `list_labels`, `triage_inbox`, `pre_scan_inbox` +`list_inbox`, `get_message`, `get_thread`, `search_messages`, `list_labels`, `triage_inbox`, `pre_scan_inbox`, `find_awaiting_reply` + +`find_awaiting_reply` ([#1606](https://github.com/amd/gaia/issues/1606)) scans your Sent folder and returns the threads whose newest message is still yours — i.e. nobody replied — once the send is older than the window (`window_days` per call, or the agent's `followup_window_days` config, default 3). Each hit carries the message id, recipient, subject, and age in days, most overdue first. Ask the agent *"who hasn't replied to me?"* or *"what am I still waiting on?"*. Detection only — sending an actual follow-up goes through the normal confirmation-gated reply tools, and only when you ask ([#555](https://github.com/amd/gaia/issues/555) tracks autonomous nudging). Gmail-only for now: the Outlook backend has no Sent-label mapping yet, so a Microsoft-only setup gets a clear error instead of a wrong answer. ### Session preferences (in-memory; wiped on agent restart) diff --git a/hub/agents/npm/agent-email/CHANGELOG.md b/hub/agents/npm/agent-email/CHANGELOG.md index e1efcb98b..bbc7b45b6 100644 --- a/hub/agents/npm/agent-email/CHANGELOG.md +++ b/hub/agents/npm/agent-email/CHANGELOG.md @@ -5,6 +5,19 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire `SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR bump is always at least a package MINOR bump with a migration note. +## Unreleased + +- **Follow-up tracking — flag sent mail awaiting a reply** (#1606): the agent can + now scan the Sent folder and surface every thread whose newest message is still + the user's own — i.e. nobody replied — once the send is older than a configurable + window (`followup_window_days`, default 3 days, or per call). The new read-only + `find_awaiting_reply` agent tool returns message id, recipient, subject, and age, + most overdue first. Detection only: it never drafts or sends a nudge (autonomous + follow-up sending remains #555, confirmation-gated). Gmail-only for now — a + Microsoft-only setup gets a loud error instead of a silently wrong scan. + Agent-loop surface (Agent UI natural-language queries); no new REST route, so the + REST contract and `SCHEMA_VERSION` are unchanged. + ## 0.3.0 Contract bumped to `SCHEMA_VERSION` **2.1** — additive, no triage shape change, so diff --git a/hub/agents/npm/agent-email/README.md b/hub/agents/npm/agent-email/README.md index 608a431d3..72e0dd6e4 100644 --- a/hub/agents/npm/agent-email/README.md +++ b/hub/agents/npm/agent-email/README.md @@ -21,6 +21,8 @@ the agent runs as a frozen, self-contained REST sidecar your app launches and ow - **Triage** — classify each message (urgent / needs-response / FYI / promotional / personal), summarize a thread, and extract action items and phishing signals. +- **Follow-up tracking** — flag sent threads still awaiting a reply after a + configurable window (read-only detection; the agent never auto-sends a chaser). - **Organize** — archive, label, move, mark read/unread — one message or in batches. - **Reply & send** — draft context-aware replies and forwards, then send. - **Calendar** — detect meeting requests, flag conflicts, RSVP, and create events @@ -217,8 +219,9 @@ When you need finer control, the steps are exported individually: As of `SCHEMA_VERSION` 2.1 this package exposes inbox **search** (read-only), the **archive** / phishing-**quarantine** mailbox actions (+ their undo), and calendar **view / create / respond**. The remaining mailbox **actions** (label, -move, mark read/unread) are part of the full agent but not yet exposed through -this package — see +move, mark read/unread) and the read-only **follow-up tracker** (flag sent mail +awaiting a reply, `find_awaiting_reply`, #1606) are part of the full agent but +not yet exposed through this package — see [`SPEC.md`](https://github.com/amd/gaia/blob/main/hub/agents/npm/agent-email/SPEC.md) for the complete surface. ## Running in production diff --git a/hub/agents/npm/agent-email/SKILL.md b/hub/agents/npm/agent-email/SKILL.md index ac43ad3dc..5bff8aa0a 100644 --- a/hub/agents/npm/agent-email/SKILL.md +++ b/hub/agents/npm/agent-email/SKILL.md @@ -124,6 +124,12 @@ reversible inside a 30s window via the ungated `unarchive` / `unquarantine`. Eve non-2xx response throws `HttpError` (`status`, `url`, `bodyText`) — handle it; there is no silent null. +The full agent (the natural-language loop behind GAIA's Agent UI) covers more than +this typed client: organize/label actions, calendar detect/conflicts, and read-only +**follow-up tracking** (`find_awaiting_reply` flags sent mail still awaiting a reply, +#1606). None of those have REST routes yet — don't look for them on this client; see +`SPEC.md` for the exact exposed surface. + ## 5. From a renderer (Electron / browser) The sidecar serves **same-origin only — no CORS**. A renderer on a different origin diff --git a/hub/agents/npm/agent-email/SPEC.md b/hub/agents/npm/agent-email/SPEC.md index 6307a7fab..bf5fb5f1d 100644 --- a/hub/agents/npm/agent-email/SPEC.md +++ b/hub/agents/npm/agent-email/SPEC.md @@ -282,9 +282,10 @@ phishing-**quarantine** mailbox actions plus their undo (`confirmAction` / `arch `unarchive` / `quarantine` / `unquarantine`), and calendar **view / create / respond** (`listCalendarEvents` / `previewCalendarEvent` / `createCalendarEvent` / `respondToCalendarEvent`). The full GAIA email agent does more on the live mailbox -(label, move, mark spam) and calendar (detect / conflicts); those remaining actions are -connector-gated by definition and are **not exposed through this package's REST API -yet**. +(label, move, mark spam), follow-up tracking (flag sent mail still awaiting a reply — +read-only `find_awaiting_reply`, #1606), and calendar (detect / conflicts); those +remaining capabilities are connector-gated by definition and are **not exposed through +this package's REST API yet**. ## Browser / Electron renderer (`./client`) diff --git a/hub/agents/python/email/gaia_agent_email/agent.py b/hub/agents/python/email/gaia_agent_email/agent.py index bbfe1ab5f..49487785e 100644 --- a/hub/agents/python/email/gaia_agent_email/agent.py +++ b/hub/agents/python/email/gaia_agent_email/agent.py @@ -47,6 +47,7 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to ) from gaia_agent_email.tools.calendar_tools import CalendarToolsMixin from gaia_agent_email.tools.delete_tools import DeleteToolsMixin +from gaia_agent_email.tools.followup_tools import FollowupToolsMixin from gaia_agent_email.tools.organize_tools import OrganizeToolsMixin from gaia_agent_email.tools.phishing_tools import PhishingToolsMixin from gaia_agent_email.tools.preference_tools import ( @@ -118,7 +119,11 @@ def __getattr__(self, name: str): ACTIONS: - Read tools (list_inbox, get_message, get_thread, search_messages, - list_labels, triage_inbox, pre_scan_inbox) — never require confirmation. + list_labels, triage_inbox, pre_scan_inbox, find_awaiting_reply) — never + require confirmation. find_awaiting_reply flags sent mail whose thread + never got a reply; it is DETECTION ONLY — never send a chaser from its + results unless the user explicitly asks, and any send stays + confirmation-gated. - Organize tools (archive_message, mark_read, mark_unread, add_star, remove_star, label_message, move_to_label) — reversible via the undo log; do not require per-action confirmation, but bulk operations @@ -167,6 +172,7 @@ class EmailTriageAgent( MemoryMixin, DatabaseMixin, ReadToolsMixin, + FollowupToolsMixin, OrganizeToolsMixin, ReplyToolsMixin, SummarizeToolsMixin, @@ -349,6 +355,7 @@ def _register_tools(self) -> None: _TOOL_REGISTRY.clear() self._reset_organize_counter() self._register_read_tools() + self._register_followup_tools() self._register_organize_tools() self._register_reply_tools() self._register_summarize_tools() diff --git a/hub/agents/python/email/gaia_agent_email/config.py b/hub/agents/python/email/gaia_agent_email/config.py index a7122e5e7..043ddcba0 100644 --- a/hub/agents/python/email/gaia_agent_email/config.py +++ b/hub/agents/python/email/gaia_agent_email/config.py @@ -97,6 +97,9 @@ class EmailAgentConfig: default), tracks ``mail_provider`` so a Microsoft-only user who set ``mail_provider="microsoft"`` gets the Outlook calendar too without separately configuring it. + - ``followup_window_days``: how many days a sent message may sit with no + inbound reply on its thread before ``find_awaiting_reply`` flags it + (#1606). Callers can override per call via the tool's ``window_days``. - ``gmail_backend`` / ``outlook_backend`` / ``calendar_backend``: eval seam — when set, the agent's tools use the injected backend instead of constructing the live one. ``gmail_backend`` is honored for @@ -122,6 +125,7 @@ class EmailAgentConfig: outlook_backend: Optional[Any] = None calendar_backend: Optional[Any] = None force_llm: bool = False + followup_window_days: int = 3 def validate(self) -> None: """Run startup-time invariants. Called from the agent's __init__. @@ -140,6 +144,11 @@ def validate(self) -> None: "cloud LLM endpoints are permitted (AC3). To use a " "non-default Lemonade port, set LEMONADE_BASE_URL." ) + if self.followup_window_days < 1: + raise ConfigurationError( + f"EmailAgentConfig.followup_window_days must be >= 1 " + f"(got {self.followup_window_days})." + ) def resolved_db_path(self) -> str: """Return the SQLite path with ``$HOME`` expanded. diff --git a/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py new file mode 100644 index 000000000..a8b2b23ec --- /dev/null +++ b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py @@ -0,0 +1,269 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Follow-up tracking — flag sent mail still awaiting a reply (#1606). + +Read-only DETECTION: scan the Sent folder, group by thread, and flag every +thread whose newest message is still the user's own once it is older than a +configurable window. Distinct from #555 (autonomous follow-up *sending*): +this module never drafts, schedules, or dispatches a nudge — it imports no +reply/send code path at all, a property the unit tests assert. + +Gmail-only for now: the Microsoft Graph backend has no SENT label mapping +(``list_messages`` serves the *inbox* folder for unrecognized labels), so +scanning it would silently return wrong results. Per the no-silent-fallback +rule the tool refuses a Microsoft-only setup with a loud error instead. +""" + +from __future__ import annotations + +import json +import time +from typing import Any, Dict, List, Optional + +from gaia_agent_email.tools.read_tools import extract_sender_email +from gaia_agent_email.verbose import log_tool_call + +from gaia.agents.base.tools import tool +from gaia.connectors.errors import ConnectorsError +from gaia.connectors.formatting import format_connector_error +from gaia.logger import get_logger + +log = get_logger(__name__) + +_MS_PER_DAY = 86_400_000 + +# Per-call ceiling on Sent-folder stubs enumerated per backend. Bounds an +# interactive call the same way DEFAULT_INBOX_SCAN_CEILING bounds triage. +DEFAULT_SENT_SCAN_CEILING = 100 + + +def _envelope_ok(data: Any) -> str: + return json.dumps({"ok": True, "data": data}, default=str) + + +def _envelope_err(message: str) -> str: + return json.dumps({"ok": False, "error": message}) + + +def _headers_of(msg: Dict[str, Any]) -> Dict[str, str]: + return { + (h.get("name") or "").lower(): h.get("value", "") + for h in (msg.get("payload") or {}).get("headers", []) + } + + +def _internal_date_ms(msg: Dict[str, Any], *, where: str) -> int: + """Millis-since-epoch of a message; loud error when unusable. + + A message without a parseable ``internalDate`` cannot be aged, and + guessing (e.g. treating it as 0) would mis-flag decade-old threads. + """ + raw = msg.get("internalDate") + try: + return int(raw) + except (TypeError, ValueError): + raise ValueError( + f"message {msg.get('id')!r} in {where} has no usable " + f"internalDate ({raw!r}); cannot compute reply age. The backend " + "must return Gmail API v1 message shapes." + ) from None + + +def find_awaiting_reply_impl( + gmail, + *, + window_days: int, + max_threads: int = 50, + now_ms: Optional[int] = None, + debug: bool = False, +) -> Dict[str, Any]: + """Scan the Sent folder and return threads still awaiting a reply. + + A thread is *awaiting reply* when its chronologically newest message was + sent by the user (no inbound message followed the user's last send) and + that send is at least ``window_days`` old. Read-only: only + ``get_user_email`` / ``list_messages`` / ``get_thread`` are called. + + Args: + gmail: Gmail backend (real or fake). + window_days: Minimum age in days before a sent message is flagged. + max_threads: Cap on distinct sent threads inspected per call. + now_ms: Clock override (millis since epoch) for deterministic + tests; ``None`` uses the real time. + debug: Pass-through to ``log_tool_call``. + + Returns: + ``{"window_days", "threads_scanned", "count", "awaiting_reply": + [{"message_id", "thread_id", "recipient", "subject", "sent_date", + "age_days"}]}`` — most overdue first. + """ + if window_days < 0: + raise ValueError(f"window_days must be >= 0 (got {window_days})") + me = (gmail.get_user_email() or "").strip().lower() + if not me: + raise ValueError( + "backend returned an empty user email address; cannot tell the " + "user's own sends apart from inbound replies. Reconnect the " + "mailbox (see `gaia connectors`)." + ) + if now_ms is None: + now_ms = int(time.time() * 1000) + + with log_tool_call( + "find_awaiting_reply", + {"window_days": window_days, "max_threads": max_threads}, + debug=debug, + ) as st: + listing = gmail.list_messages( + label_ids=["SENT"], max_results=DEFAULT_SENT_SCAN_CEILING + ) + thread_ids: List[str] = [] + seen: set[str] = set() + for stub in listing.get("messages", []): + tid = stub.get("threadId") + if tid and tid not in seen: + seen.add(tid) + thread_ids.append(tid) + + awaiting: List[Dict[str, Any]] = [] + scanned = 0 + for tid in thread_ids[:max_threads]: + scanned += 1 + thread = gmail.get_thread(tid) + messages = thread.get("messages", []) + if not messages: + raise ValueError( + f"thread {tid!r} from the Sent listing came back with no " + "messages; the backend thread view is inconsistent." + ) + entries = sorted( + ( + ( + _internal_date_ms(m, where=f"thread {tid!r}"), + extract_sender_email(_headers_of(m).get("from", "")) == me, + m, + ) + for m in messages + ), + key=lambda e: e[0], + ) + last_ms, last_from_me, last_msg = entries[-1] + if not last_from_me: + continue # newest message is inbound — the thread was answered + age_days = (now_ms - last_ms) / _MS_PER_DAY + if age_days < window_days: + continue + headers = _headers_of(last_msg) + awaiting.append( + { + "message_id": last_msg.get("id"), + "thread_id": tid, + "recipient": headers.get("to", ""), + "subject": headers.get("subject", ""), + "sent_date": headers.get("date", ""), + "age_days": round(age_days, 1), + } + ) + + awaiting.sort(key=lambda item: item["age_days"], reverse=True) + st["result_summary"] = {"count": len(awaiting), "threads_scanned": scanned} + return { + "window_days": window_days, + "threads_scanned": scanned, + "count": len(awaiting), + "awaiting_reply": awaiting, + } + + +class FollowupToolsMixin: + """Mixin that registers the read-only follow-up tracking tool. + + State-free at construction time — relies on the agent class having set + ``self._gmail``, ``self._backends``, ``self.config``, and + ``_remember_message_mailbox`` before ``self._register_followup_tools()``. + """ + + def _register_followup_tools(self) -> None: + debug_flag = bool(getattr(self.config, "debug", False)) + agent = self # captured for live access to config / backends + + @tool + def find_awaiting_reply(window_days: int = 0, max_threads: int = 50) -> str: + """Flag sent messages that never got a reply (read-only detection). + + Scans the Sent folder and returns every thread whose newest + message is still the user's own — nobody replied — once the sent + message is older than the window. Use when the user asks "who + hasn't replied to me?", "what am I still waiting on?", or wants + to chase overdue responses. Detection ONLY: this never drafts or + dispatches a nudge; any actual follow-up email goes through the + normal confirmation-gated reply tools at the user's request. + + Args: + window_days: Minimum age in days before a sent message counts + as awaiting a reply. 0 (the default) uses the configured + ``followup_window_days`` (3 unless overridden). + max_threads: Cap on how many sent threads to inspect + (default 50, max 100). + + Returns: + JSON envelope with ``{"awaiting_reply": [{message_id, + thread_id, recipient, subject, sent_date, age_days, + mailbox}], "count", "window_days", "threads_scanned"}`` — + most overdue first. + """ + try: + window = int(window_days or 0) + if window <= 0: + window = int(getattr(agent.config, "followup_window_days", 3)) + max_threads = max(1, min(int(max_threads or 50), 100)) + backends = agent._backends + google_backends = { + provider: backend + for provider, backend in backends.items() + if provider == "google" + } + if not google_backends: + return _envelope_err( + "follow-up tracking currently supports Gmail only — no " + "Google mailbox is connected. Connect one via " + "`gaia connectors`." + ) + merged: List[Dict[str, Any]] = [] + scanned = 0 + for provider, backend in google_backends.items(): + result = find_awaiting_reply_impl( + backend, + window_days=window, + max_threads=max_threads, + debug=debug_flag, + ) + scanned += result["threads_scanned"] + for item in result["awaiting_reply"]: + item["mailbox"] = provider + agent._remember_message_mailbox( + item.get("message_id"), provider + ) + agent._remember_message_mailbox( + item.get("thread_id"), provider + ) + merged.append(item) + merged.sort(key=lambda item: item["age_days"], reverse=True) + data: Dict[str, Any] = { + "window_days": window, + "threads_scanned": scanned, + "count": len(merged), + "awaiting_reply": merged, + } + skipped = sorted(set(backends) - set(google_backends)) + if skipped: + data["skipped_mailboxes"] = { + provider: "follow-up tracking is Gmail-only for now" + for provider in skipped + } + return _envelope_ok(data) + except ConnectorsError as exc: + return _envelope_err(format_connector_error(exc)) + except Exception as exc: + log.exception("email tool error: %s", type(exc).__name__) + return _envelope_err(f"{type(exc).__name__}: {exc}") diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index a4a078cca..8ee413fca 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -354,7 +354,7 @@

5 + schema 2.0

12Calendar — createWiredCreate events from email contextTier 2Manual 13Search past emailPlannedNatural-language / keyword search across the mailboxTier 0Manual 14Task extraction → listPlannedPersist action items into a trackable task listTier 0New mail - 15Follow-up trackingPlannedFlag sent mail awaiting a reply; nudge when overdueTier 0Scheduled + 15Follow-up trackingWiredFlag sent mail awaiting a reply (read-only detection; the overdue nudge stays with autonomy #555)Tier 0Manual 16Proactive daily briefingPlannedScheduled morning inbox + calendar + tasks briefTier 0Scheduled 17Scheduled send & snoozePlannedDefer a send; snooze a message out of the inboxTier 2Scheduled @@ -403,7 +403,8 @@

Capability → surface

9 · Quarantine (+ undo)✓ /v1/email/quarantine · /unquarantine (Gmail)—✓ quarantine_phishing_message Inbox search (read-only, #1781)/v1/email/search—✓ search_messages Calendar · view / create / respond (of capability 12)/v1/email/calendar/* (2.1)—✓ - calendar detect / conflicts (10–11)  ·  13–17 planned  ·  P1–P5 personalization——✓ agent only + 15 · Follow-up tracking (read-only, #1606)——✓ find_awaiting_reply + calendar detect / conflicts (10–11)  ·  13, 14, 16, 17 planned  ·  P1–P5 personalization——✓ agent only @@ -790,12 +791,12 @@

Each capability, in full

Tracking
#1605 · Milestone 40 (P3 stretch)
- +
-
15

Follow-up tracking

PlannedTier 0 · read-only
+
15

Follow-up tracking

WiredTier 0 · read-only
Use-case
Watch the messages the user has sent and flag the ones that have gone unanswered past a configurable window, so a dropped thread resurfaces instead of disappearing. The agent scans the Sent folder, matches each outbound message against later inbound replies on the same thread, and surfaces the still-open ones (recipient, subject, age) for a nudge. It only detects and surfaces; it never auto-sends a follow-up (that is a separate, confirmation-gated action).
Why it matters
The unanswered email you forgot you sent is the inbox's biggest silent failure mode — and a top-billed feature in every comparable assistant.
-
Backed by
Sent-folder scan + reply detection. Distinct from autonomy #555 (which proactively sends follow-ups). Highest-impact gap.
+
Backed by
find_awaiting_reply — Sent-folder scan + per-thread reply detection; the window is configurable (followup_window_days, default 3, or per call). Gmail-only for now. Distinct from autonomy #555 (which proactively sends follow-ups).
Guardrails
Tier 0 — read-only detection; never auto-sends.
Tracking
#1606 · Milestone 40 (P3 stretch)
diff --git a/tests/unit/agents/email/test_followup_tracking.py b/tests/unit/agents/email/test_followup_tracking.py new file mode 100644 index 000000000..1c9ca1d94 --- /dev/null +++ b/tests/unit/agents/email/test_followup_tracking.py @@ -0,0 +1,423 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Tests for follow-up tracking — ``find_awaiting_reply`` (#1606). + +Locks the issue's acceptance criteria: + +1. A sent message whose thread later received an inbound reply is NOT + flagged; one without a reply IS flagged once older than the window. +2. The detector is read-only — it triggers no ``send_*`` / draft side + effects (asserted against the fake backend's transport log AND the + module source, which must not reference any send path). +3. The window is configurable, and the awaiting-reply set carries + message_id, recipient, subject, and age. + +All detection is deterministic (no LLM); no Lemonade server is involved. +""" + +from __future__ import annotations + +import inspect +import json +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[4] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +if str(_REPO_ROOT / "src") not in sys.path: + sys.path.insert(0, str(_REPO_ROOT / "src")) + +pytest.importorskip("gaia_agent_email") + +from gaia_agent_email.tools import followup_tools # noqa: E402 +from gaia_agent_email.tools.followup_tools import ( # noqa: E402 + FollowupToolsMixin, + find_awaiting_reply_impl, +) + +from gaia.agents.base.agent import TOOLS_REQUIRING_CONFIRMATION # noqa: E402 +from gaia.agents.base.tools import _TOOL_REGISTRY # noqa: E402 +from tests.fixtures.email.fake_gmail import FakeGmailBackend # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +ME = "user@example.com" +NOW_MS = 1_760_000_000_000 # fixed "now" so ages are deterministic +DAY_MS = 86_400_000 + + +def _days_ago(days: float) -> int: + return int(NOW_MS - days * DAY_MS) + + +def _make_msg( + *, + msg_id: str, + thread_id: str, + from_: str, + to: str, + subject: str, + date_ms: int, + labels: list, +) -> Dict[str, Any]: + """Minimal Gmail-API-v1-shape message for FakeGmailBackend.add_message.""" + return { + "id": msg_id, + "threadId": thread_id, + "labelIds": list(labels), + "snippet": f"snippet of {msg_id}", + "internalDate": str(date_ms), + "payload": { + "mimeType": "text/plain", + "headers": [ + {"name": "From", "value": from_}, + {"name": "To", "value": to}, + {"name": "Subject", "value": subject}, + {"name": "Date", "value": "Mon, 1 Jun 2026 09:00:00 +0000"}, + ], + "body": {"data": "", "size": 0}, + }, + } + + +def _backend() -> FakeGmailBackend: + return FakeGmailBackend(user_email=ME) + + +def _add_sent(gmail, *, msg_id, thread_id, to, subject, date_ms): + gmail.add_message( + _make_msg( + msg_id=msg_id, + thread_id=thread_id, + from_=f"User <{ME}>", + to=to, + subject=subject, + date_ms=date_ms, + labels=["SENT"], + ) + ) + + +def _add_inbound(gmail, *, msg_id, thread_id, from_, subject, date_ms): + gmail.add_message( + _make_msg( + msg_id=msg_id, + thread_id=thread_id, + from_=from_, + to=f"User <{ME}>", + subject=subject, + date_ms=date_ms, + labels=["INBOX"], + ) + ) + + +# --------------------------------------------------------------------------- +# Acceptance criterion 1 — flagged vs. not flagged +# --------------------------------------------------------------------------- + + +def test_replied_thread_is_not_flagged(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Q3 numbers", + date_ms=_days_ago(5), + ) + _add_inbound( + gmail, + msg_id="r1", + thread_id="t1", + from_="Alice ", + subject="Re: Q3 numbers", + date_ms=_days_ago(4), + ) + + out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + assert out["count"] == 0 + assert out["awaiting_reply"] == [] + assert out["threads_scanned"] == 1 + + +def test_unreplied_sent_message_is_flagged_after_window(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Q3 numbers", + date_ms=_days_ago(5), + ) + + out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + assert out["count"] == 1 + item = out["awaiting_reply"][0] + assert item["message_id"] == "s1" + assert item["thread_id"] == "t1" + assert item["recipient"] == "alice@example.com" + assert item["subject"] == "Q3 numbers" + assert item["age_days"] == pytest.approx(5.0, abs=0.1) + + +def test_unreplied_but_inside_window_is_not_flagged(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Quick question", + date_ms=_days_ago(1), + ) + + out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + assert out["count"] == 0 + + +def test_multiple_own_sends_flag_only_the_latest(): + """Two of my sends on one thread, no reply -> one flag, on the newest.""" + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="bob@example.com", + subject="Contract draft", + date_ms=_days_ago(10), + ) + _add_sent( + gmail, + msg_id="s2", + thread_id="t1", + to="bob@example.com", + subject="Re: Contract draft", + date_ms=_days_ago(6), + ) + + out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + assert out["count"] == 1 + item = out["awaiting_reply"][0] + assert item["message_id"] == "s2" + assert item["age_days"] == pytest.approx(6.0, abs=0.1) + + +def test_results_sorted_most_overdue_first(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="a@example.com", + subject="older", + date_ms=_days_ago(9), + ) + _add_sent( + gmail, + msg_id="s2", + thread_id="t2", + to="b@example.com", + subject="newer", + date_ms=_days_ago(4), + ) + + out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + assert [i["message_id"] for i in out["awaiting_reply"]] == ["s1", "s2"] + + +# --------------------------------------------------------------------------- +# Acceptance criterion 2 — strictly read-only, no send path +# --------------------------------------------------------------------------- + +_READ_ONLY_BACKEND_CALLS = {"get_user_email", "list_messages", "get_thread"} + + +def test_detector_triggers_no_send_side_effects(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Q3 numbers", + date_ms=_days_ago(5), + ) + _add_inbound( + gmail, + msg_id="r2", + thread_id="t2", + from_="Carol ", + subject="FYI", + date_ms=_days_ago(2), + ) + + find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + called = {method for method, _ in gmail.transport.calls} + assert called <= _READ_ONLY_BACKEND_CALLS, ( + f"find_awaiting_reply_impl called non-read-only backend methods: " + f"{sorted(called - _READ_ONLY_BACKEND_CALLS)}" + ) + + +def test_module_references_no_send_path(): + """The module must not touch drafts/sends even by reference (#1606 AC).""" + source = inspect.getsource(followup_tools) + for forbidden in ( + "send_message", + "send_draft", + "send_now", + "create_draft", + "forward_message", + "reply_tools", + ): + assert forbidden not in source, ( + f"followup_tools references send-path symbol {forbidden!r}; " + "follow-up tracking must stay detection-only (see #555 for " + "autonomous follow-up sending)" + ) + + +# --------------------------------------------------------------------------- +# Fail-loudly invariants +# --------------------------------------------------------------------------- + + +def test_empty_user_email_fails_loudly(): + gmail = FakeGmailBackend(user_email="") + with pytest.raises(ValueError, match="empty user email"): + find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + +def test_negative_window_fails_loudly(): + with pytest.raises(ValueError, match="window_days"): + find_awaiting_reply_impl(_backend(), window_days=-1, now_ms=NOW_MS) + + +def test_unparseable_internal_date_fails_loudly(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="broken thread", + date_ms=_days_ago(5), + ) + # Corrupt a thread member that is NOT in the SENT listing, so the + # detector's own date parsing (not the fake's listing sort) hits it. + reply = _make_msg( + msg_id="r1", + thread_id="t1", + from_="Alice ", + to=f"User <{ME}>", + subject="Re: broken thread", + date_ms=_days_ago(4), + labels=["INBOX"], + ) + reply["internalDate"] = "not-a-number" + gmail.add_message(reply) + + with pytest.raises(ValueError, match="internalDate"): + find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) + + +# --------------------------------------------------------------------------- +# Production tool wiring (mixin registration) +# --------------------------------------------------------------------------- + + +class _Host(FollowupToolsMixin): + """Minimal agent stand-in satisfying the mixin's stated contract.""" + + def __init__(self, backends: Dict[str, Any], *, followup_window_days: int = 3): + self._backends = backends + self._gmail = next(iter(backends.values()), None) + self.config = SimpleNamespace( + debug=False, followup_window_days=followup_window_days + ) + self.remembered: list = [] + + def _remember_message_mailbox(self, message_id, provider): + self.remembered.append((message_id, provider)) + + +def _registered_tool(backends: Dict[str, Any], **host_kwargs): + _TOOL_REGISTRY.clear() + host = _Host(backends, **host_kwargs) + host._register_followup_tools() + return _TOOL_REGISTRY["find_awaiting_reply"]["function"] + + +def test_tool_envelope_and_config_window_via_registration(): + gmail = _backend() + # 40 days old — far past any window, so real wall-clock "now" stays valid. + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Overdue thing", + date_ms=_days_ago(40), + ) + tool_fn = _registered_tool({"google": gmail}, followup_window_days=3) + + out = json.loads(tool_fn()) + + assert out["ok"] is True + data = out["data"] + assert data["window_days"] == 3 + assert data["count"] == 1 + assert data["awaiting_reply"][0]["message_id"] == "s1" + assert data["awaiting_reply"][0]["mailbox"] == "google" + + +def test_tool_is_not_confirmation_gated(): + """Read-only detection must never require a confirmation token.""" + assert "find_awaiting_reply" not in TOOLS_REQUIRING_CONFIRMATION + + +def test_microsoft_only_setup_is_refused_loudly(): + """Graph has no SENT label mapping — scanning it would silently serve + the inbox folder, so the tool must refuse instead of degrade.""" + tool_fn = _registered_tool({"microsoft": object()}) + + out = json.loads(tool_fn()) + + assert out["ok"] is False + assert "Gmail only" in out["error"] + + +def test_mixed_mailboxes_scan_gmail_and_name_the_skip(): + gmail = _backend() + _add_sent( + gmail, + msg_id="s1", + thread_id="t1", + to="alice@example.com", + subject="Overdue thing", + date_ms=_days_ago(40), + ) + tool_fn = _registered_tool({"google": gmail, "microsoft": object()}) + + out = json.loads(tool_fn()) + + assert out["ok"] is True + assert out["data"]["count"] == 1 + assert "microsoft" in out["data"]["skipped_mailboxes"] From 298b34f5b50e74af3e027adc5fc462498ce47004 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 1 Jul 2026 14:50:13 -0700 Subject: [PATCH 2/3] test(email): add find_awaiting_reply to the expected-tool allowlist TestToolRegistry.test_no_unexpected_tool_set guards against tools that bypass confirmation logic; the new read-only follow-up tracker belongs in its expected set. --- tests/unit/agents/test_email_agent.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/agents/test_email_agent.py b/tests/unit/agents/test_email_agent.py index f783ec21d..c878158d9 100644 --- a/tests/unit/agents/test_email_agent.py +++ b/tests/unit/agents/test_email_agent.py @@ -152,6 +152,8 @@ class TestToolRegistry: "triage_inbox", "pre_scan_inbox", "profile_inbox", + # Follow-up tracking (#1606) — read-only, never confirmation-gated + "find_awaiting_reply", # Organize "archive_message", "mark_read", From 6e16e60a730a725d51f0277b4327de2028f56c94 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Wed, 1 Jul 2026 14:58:48 -0700 Subject: [PATCH 3/3] feat(email): surface follow-up scan truncation instead of implying exhaustiveness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sent listing is newest-first and capped at 100 stubs, so a heavy sender's oldest — most overdue — threads can fall outside one scan. The result now carries scan_truncated whenever a ceiling was hit (next page token, full listing page, or more threads than max_threads), the tool docstring tells the LLM to relay the incompleteness, and the max_threads cap is tied to DEFAULT_SENT_SCAN_CEILING so the two limits can't drift apart. Raised in the PR #1916 review. --- docs/guides/email.mdx | 2 +- .../gaia_agent_email/tools/followup_tools.py | 42 +++++++++++++++---- .../agents/email/test_followup_tracking.py | 39 +++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/docs/guides/email.mdx b/docs/guides/email.mdx index 2dbab0d95..c38bd8833 100644 --- a/docs/guides/email.mdx +++ b/docs/guides/email.mdx @@ -150,7 +150,7 @@ Senders you reply to quickly are automatically promoted to priority on the next `list_inbox`, `get_message`, `get_thread`, `search_messages`, `list_labels`, `triage_inbox`, `pre_scan_inbox`, `find_awaiting_reply` -`find_awaiting_reply` ([#1606](https://github.com/amd/gaia/issues/1606)) scans your Sent folder and returns the threads whose newest message is still yours — i.e. nobody replied — once the send is older than the window (`window_days` per call, or the agent's `followup_window_days` config, default 3). Each hit carries the message id, recipient, subject, and age in days, most overdue first. Ask the agent *"who hasn't replied to me?"* or *"what am I still waiting on?"*. Detection only — sending an actual follow-up goes through the normal confirmation-gated reply tools, and only when you ask ([#555](https://github.com/amd/gaia/issues/555) tracks autonomous nudging). Gmail-only for now: the Outlook backend has no Sent-label mapping yet, so a Microsoft-only setup gets a clear error instead of a wrong answer. +`find_awaiting_reply` ([#1606](https://github.com/amd/gaia/issues/1606)) scans your Sent folder and returns the threads whose newest message is still yours — i.e. nobody replied — once the send is older than the window (`window_days` per call, or the agent's `followup_window_days` config, default 3). Each hit carries the message id, recipient, subject, and age in days, most overdue first. Ask the agent *"who hasn't replied to me?"* or *"what am I still waiting on?"*. One scan inspects up to the 100 most-recent sent threads; when older sends exist beyond that, the result carries `scan_truncated: true` so the agent tells you the list may be incomplete rather than passing it off as exhaustive. Detection only — sending an actual follow-up goes through the normal confirmation-gated reply tools, and only when you ask ([#555](https://github.com/amd/gaia/issues/555) tracks autonomous nudging). Gmail-only for now: the Outlook backend has no Sent-label mapping yet, so a Microsoft-only setup gets a clear error instead of a wrong answer. ### Session preferences (in-memory; wiped on agent restart) diff --git a/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py index a8b2b23ec..24533be9d 100644 --- a/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py +++ b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py @@ -93,9 +93,11 @@ def find_awaiting_reply_impl( debug: Pass-through to ``log_tool_call``. Returns: - ``{"window_days", "threads_scanned", "count", "awaiting_reply": - [{"message_id", "thread_id", "recipient", "subject", "sent_date", - "age_days"}]}`` — most overdue first. + ``{"window_days", "threads_scanned", "count", "scan_truncated", + "awaiting_reply": [{"message_id", "thread_id", "recipient", + "subject", "sent_date", "age_days"}]}`` — most overdue first. + ``scan_truncated`` is True when older sends exist beyond the scan + ceiling, so callers can say the answer may be incomplete. """ if window_days < 0: raise ValueError(f"window_days must be >= 0 (got {window_days})") @@ -117,13 +119,22 @@ def find_awaiting_reply_impl( listing = gmail.list_messages( label_ids=["SENT"], max_results=DEFAULT_SENT_SCAN_CEILING ) + stubs = listing.get("messages", []) thread_ids: List[str] = [] seen: set[str] = set() - for stub in listing.get("messages", []): + for stub in stubs: tid = stub.get("threadId") if tid and tid not in seen: seen.add(tid) thread_ids.append(tid) + # The listing is newest-first, so hitting either ceiling means the + # OLDEST (most overdue) sends may be the ones left unscanned — never + # let that read as an exhaustive answer. + scan_truncated = ( + bool(listing.get("nextPageToken")) + or len(stubs) >= DEFAULT_SENT_SCAN_CEILING + or len(thread_ids) > max_threads + ) awaiting: List[Dict[str, Any]] = [] scanned = 0 @@ -166,11 +177,16 @@ def find_awaiting_reply_impl( ) awaiting.sort(key=lambda item: item["age_days"], reverse=True) - st["result_summary"] = {"count": len(awaiting), "threads_scanned": scanned} + st["result_summary"] = { + "count": len(awaiting), + "threads_scanned": scanned, + "scan_truncated": scan_truncated, + } return { "window_days": window_days, "threads_scanned": scanned, "count": len(awaiting), + "scan_truncated": scan_truncated, "awaiting_reply": awaiting, } @@ -209,14 +225,21 @@ def find_awaiting_reply(window_days: int = 0, max_threads: int = 50) -> str: Returns: JSON envelope with ``{"awaiting_reply": [{message_id, thread_id, recipient, subject, sent_date, age_days, - mailbox}], "count", "window_days", "threads_scanned"}`` — - most overdue first. + mailbox}], "count", "window_days", "threads_scanned", + "scan_truncated"}`` — most overdue first. When + ``scan_truncated`` is true, older sent threads exist beyond + the scan ceiling — tell the user the list may be incomplete. """ try: window = int(window_days or 0) if window <= 0: window = int(getattr(agent.config, "followup_window_days", 3)) - max_threads = max(1, min(int(max_threads or 50), 100)) + # Cap matches DEFAULT_SENT_SCAN_CEILING — the impl only + # enumerates that many sent stubs, so a higher cap here + # could never be honored; raise the two together. + max_threads = max( + 1, min(int(max_threads or 50), DEFAULT_SENT_SCAN_CEILING) + ) backends = agent._backends google_backends = { provider: backend @@ -231,6 +254,7 @@ def find_awaiting_reply(window_days: int = 0, max_threads: int = 50) -> str: ) merged: List[Dict[str, Any]] = [] scanned = 0 + truncated = False for provider, backend in google_backends.items(): result = find_awaiting_reply_impl( backend, @@ -239,6 +263,7 @@ def find_awaiting_reply(window_days: int = 0, max_threads: int = 50) -> str: debug=debug_flag, ) scanned += result["threads_scanned"] + truncated = truncated or result["scan_truncated"] for item in result["awaiting_reply"]: item["mailbox"] = provider agent._remember_message_mailbox( @@ -253,6 +278,7 @@ def find_awaiting_reply(window_days: int = 0, max_threads: int = 50) -> str: "window_days": window, "threads_scanned": scanned, "count": len(merged), + "scan_truncated": truncated, "awaiting_reply": merged, } skipped = sorted(set(backends) - set(google_backends)) diff --git a/tests/unit/agents/email/test_followup_tracking.py b/tests/unit/agents/email/test_followup_tracking.py index 1c9ca1d94..d609cc689 100644 --- a/tests/unit/agents/email/test_followup_tracking.py +++ b/tests/unit/agents/email/test_followup_tracking.py @@ -165,6 +165,7 @@ def test_unreplied_sent_message_is_flagged_after_window(): out = find_awaiting_reply_impl(gmail, window_days=3, now_ms=NOW_MS) assert out["count"] == 1 + assert out["scan_truncated"] is False item = out["awaiting_reply"][0] assert item["message_id"] == "s1" assert item["thread_id"] == "t1" @@ -241,6 +242,44 @@ def test_results_sorted_most_overdue_first(): assert [i["message_id"] for i in out["awaiting_reply"]] == ["s1", "s2"] +def test_scan_truncation_is_surfaced_not_silent(): + """Newest-first listing means the ceilings hide the OLDEST sends — + the result must say the scan was partial, never read as exhaustive.""" + gmail = _backend() + for i in range(3): + _add_sent( + gmail, + msg_id=f"s{i}", + thread_id=f"t{i}", + to=f"p{i}@example.com", + subject=f"thread {i}", + date_ms=_days_ago(5 + i), + ) + + # max_threads ceiling: 3 sent threads, only 2 inspected. + out = find_awaiting_reply_impl(gmail, window_days=3, max_threads=2, now_ms=NOW_MS) + assert out["threads_scanned"] == 2 + assert out["scan_truncated"] is True + + # Listing ceiling: a full page of sent stubs means older ones may exist. + from gaia_agent_email.tools.followup_tools import DEFAULT_SENT_SCAN_CEILING + + gmail2 = _backend() + for i in range(DEFAULT_SENT_SCAN_CEILING): + _add_sent( + gmail2, + msg_id=f"s{i}", + thread_id=f"t{i}", + to="x@example.com", + subject=f"thread {i}", + date_ms=_days_ago(4) + i, # unique dates, all past the window + ) + out2 = find_awaiting_reply_impl( + gmail2, window_days=3, max_threads=DEFAULT_SENT_SCAN_CEILING, now_ms=NOW_MS + ) + assert out2["scan_truncated"] is True + + # --------------------------------------------------------------------------- # Acceptance criterion 2 — strictly read-only, no send path # ---------------------------------------------------------------------------