Skip to content

perf(email): cut per-message scan cost so the 25-message ceiling can rise #2643

Description

@itomek

Raise the triage scan ceiling from 25 toward 50–100 by cutting per-message cost, without reintroducing the #2087 context overflow that forced 60→25 (#2089).

What is actually binding

Measured on a live Gmail mailbox this session, same query, same model:

Scanned Turn time Output tokens
25 15.1s / 16.8s ~179 / ~271
100 46.9s ~38

4× the messages → ~3× the time, while output tokens fell 5×. Generation cannot account for the growth, so the cost is per-message scan work. The mechanical explanation:

  • get_message is hardcoded format=full (hub/agents/email/python/gaia_agent_email/gmail_backend.py) — every scanned message pulls its entire body, even when the heuristic resolves it from a label without reading the body at all.
  • No batching anywhere in the Gmail backend — 100 messages is 100 sequential round-trips.

This matters for prioritising the work: body reduction happens after the fetch, so on its own it does not touch the dominant cost. It reduces tokens, which guards against #2087, but it will not make a 100-message scan feel faster.

Levers, in leverage order

1. Metadata-first fetch (largest win, and a prerequisite for the rest).
Add a metadata mode to get_message (Gmail format=metadata returns headers + labelIds, no body). Run the heuristic pass on metadata alone; fetch the full body only for messages the heuristic escalates. Most messages in a real inbox never need their body read.

2. Batch the fetches.
Gmail's batch endpoint accepts up to 100 subrequests. Turns a 100-message scan from 100 round-trips into 1–2. Purely mechanical, no classification risk.

3. List-Unsubscribe header instead of a body scan.
This is the sharp version of the "look for unsubscribe" idea: List-Unsubscribe is a standard header (RFC 2369) and arrives with format=metadata — no body fetch, no regex over prose, far more reliable than a string match. Note the codebase already has _OPT_OUT_PHRASES containing "unsubscribe" (tools/text_signals.py), but that serves a different purpose — detecting opt-out requests for the waiting-on-you detector — and should not be repurposed.

Also note Gmail's own classification is already the primary newsletter signal and is already free: LABEL_CATEGORY_PROMOTIONS / LABEL_CATEGORY_UPDATES (tools/triage_heuristics.py). The header adds coverage where Gmail mislabels; it does not replace the labels.

4. Body reduction for what does reach the LLM.
Reuse what exists rather than writing a second implementation — strip_quoted_text() and _SIGNOFF_RE / _SIGNOFF_SCAN_LINES already live in gaia_agent_email/voice_profile.py. This is the same machinery #2642 needs for stripping compliance banners; they should share one body-normalisation function, not three.

Two things deliberately excluded

Do not skip newsletters. The codebase already does the right thing here and it would be lost: triage_heuristics.py escalates when a CATEGORY_PROMOTIONS/CATEGORY_UPDATES message's body carries a deadline or commitment signal. That exact path fired this session on a NOTUS newsletter that legitimately needed review, and #2632 shows priority senders send newsletters too. So bulk mail gets a cheap path — label/header-only classification, no body fetch, no LLM — never an ignore list.

Do not strip stopwords or "reduce words". An LLM classifier is not a bag of words; degrading the prose costs comprehension for a modest token saving, and it is the change most likely to break classification subtly and be hardest to attribute afterwards. Token savings should come from removing non-content (quoted chains, signatures, banners), not from thinning real sentences.

Example to verify against

Input — a 100-message inbox where 80 carry CATEGORY_PROMOTIONS / List-Unsubscribe and 20 are human mail.

Expected

Metric Before Target
Full-body get_message calls 100 ≤ 20 (escalated only)
HTTP round-trips for the scan ~100 ≤ 5 (batched)
Envelope tokens at 100 messages over budget within envelope_budget_tokens()
Default max_messages 25 50–100

Assertions

  • a fake backend records zero full-body fetches for a message the heuristic resolves from labels alone
  • a CATEGORY_PROMOTIONS message whose body contains a deadline signal still escalates — the regression guard for the NOTUS case; requires the body, so the cheap path must be able to escalate into a body fetch
  • envelope token estimate at the new ceiling stays under envelope_budget_tokens() (gaia_agent_email/context_budget.py)
  • classification output is unchanged for a fixed corpus before/after the optimisation — the whole point is speed, not different answers

Check

.venv/bin/python -m pytest hub/agents/email/python/tests/ -q --no-header -k 'triage or prescan or budget or heuristic'

Acceptance criteria

  • Metadata-first scan with body fetch only on escalation.
  • Batched message fetches.
  • List-Unsubscribe used as a supplementary bulk signal, sourced from headers.
  • One shared body-normalisation path, used here and by fix(email): email compliance banner summarized as a participant ("AMD General") #2642.
  • Default ceiling raised to at least 50 with envelope tokens inside budget.
  • Classification unchanged on a fixed corpus; the NOTUS-style escalation case explicitly covered.
  • Measured before/after timings at 25 and 100 messages posted on the issue.

Relates to #2634 (the scan does not paginate, so raising the ceiling needs both changes), #2087 / #2089 (the overflow that set the current limit), #2642 (shared body normalisation).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestp1medium priority

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions