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
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).
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:
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_messageis hardcodedformat=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.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(Gmailformat=metadatareturns 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-Unsubscribeheader instead of a body scan.This is the sharp version of the "look for unsubscribe" idea:
List-Unsubscribeis a standard header (RFC 2369) and arrives withformat=metadata— no body fetch, no regex over prose, far more reliable than a string match. Note the codebase already has_OPT_OUT_PHRASEScontaining"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_LINESalready live ingaia_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.pyescalates when aCATEGORY_PROMOTIONS/CATEGORY_UPDATESmessage'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-Unsubscribeand 20 are human mail.Expected
get_messagecallsenvelope_budget_tokens()max_messagesAssertions
CATEGORY_PROMOTIONSmessage 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 fetchenvelope_budget_tokens()(gaia_agent_email/context_budget.py)Check
.venv/bin/python -m pytest hub/agents/email/python/tests/ -q --no-header -k 'triage or prescan or budget or heuristic'Acceptance criteria
List-Unsubscribeused as a supplementary bulk signal, sourced from headers.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).