Conversation
The email adapter never set SessionSource.thread_id, so build_session_key() collapsed all mail from one address into a single session. Two unrelated conversations with the same person shared one context window. The same root cause drove a second symptom: outbound In-Reply-To/References came from _thread_context[to_addr] — one slot per sender holding "the last message received from this address" — so a cron job created in thread A delivered its result into whichever thread with that sender was most recently touched. Key both the session and the outbound reply context on Gmail's server-computed X-GM-THRID: - Fetch X-GM-THRID before the RFC822 fetch, which implicitly sets \Seen and would otherwise strip the flag that makes a retry possible. - Probe X-GM-EXT-1 once per IMAP session. Without the extension there is no thread id to fetch, so keep the previous sender-keyed behaviour and warn once. Without this probe a non-Gmail mailbox answers every lookup with BAD, burns the retry budget and drops all inbound mail. - Bound the retry: 5 consecutive failures on one UID logs under a grep-able prefix (UID and sender only, never the body), replies to the sender, marks seen and drops. - Rekey _thread_context from sender_addr to (sender_addr, thread_id). Also bring the outbound path into RFC 5322 conformance: - §3.6.4 References now carries the parent's chain plus the parent, instead of only the parent's Message-ID, so clients can nest deep threads. - §3.6.4 msg-id values from inbound mail are validated before being echoed. A CRLF-bearing Message-ID previously raised HeaderParseError at send time, making every reply in that thread fail permanently. - §3.6.4 uniqueness: Message-ID generation moves to email.utils.make_msgid(). thread_id flows through the existing build_source -> SessionSource -> build_session_key -> HERMES_SESSION_THREAD_ID -> cron origin pipeline; session.py and cronjob_tools.py are unchanged. Fixes NousResearch#11422 Relates to NousResearch#26277 Relates to NousResearch#27804 This change was written by an AI agent (Claude Opus 5, via Claude Code), under human direction and with human approval to submit it.
13 tasks
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This change was written by an AI agent (Claude Opus 5, via Claude Code), including this
description. A human directed the work, supplied the requirements and approved opening the
PR, but the code, tests and prose here are AI-authored. Please review accordingly — in
particular the design decisions called out under Open questions for maintainers, which are
judgement calls rather than mechanical changes.
What does this PR do?
The email adapter never set
SessionSource.thread_id, sobuild_session_key()collapsed allmail from one address into a single session (
agent:main:email:dm:<sender>). Two unrelatedconversations with the same person shared one context window.
The same root cause produced a second, more visible symptom: outbound
In-Reply-To/Referenceswere sourced from
_thread_context[to_addr]— one slot per sender, holding "the last messagereceived from this address". A cron job created in thread A delivered its result into whichever
thread with that sender had been touched most recently.
This keys both the session and the outbound reply context on Gmail's server-computed
X-GM-THRID, bringing email to parity with the per-thread isolation Telegram/Discord/Slackalready have.
Related Issue
Fixes #11422
This implements Revision 2 of an internal PRD; the revision's substance — the capability-probe
reconciliation with #11422, the RFC 5322 audit, and the scope calls on #26277 / #27804 — is
reproduced inline below rather than linked, so review needs nothing external.
Relates to #26277 (same user need, subject-based mechanism — see Scope below)
Relates to #27804 (this fixes the session-isolation half; the notification-volume half is
already fixed in-tree)
Type of Change
Changes Made
plugins/platforms/email/adapter.py_fetch_thread_id()issuesUID FETCH (X-GM-THRID)over the existingimaplibconnection. It runs before theRFC822fetch, because that fetch implicitly sets\Seen— doing it second would strip the message of the one flag that makes a retry possible._probe_gmail_ext()records whether the server advertisesX-GM-EXT-1once per IMAP session. Without it there is no thread id to fetch, so the adapterkeeps the previous sender-keyed behaviour and logs one warning. Without this probe, a
non-Gmail mailbox answers every thread lookup with
BAD, burns the retry budget, and dropsall inbound mail. Delegated mailboxes and some Workspace configs also omit the capability.
UNSEENfor the next poll cycle. After 5 consecutive failures on one UID: log under the grep-able
prefix
[Email][thread-lookup-failure](UID and sender only — never the body), reply to thesender, mark seen, drop. Strike counters are pruned when a UID leaves the
UNSEENset.thread_idflows through the existingbuild_source()→SessionSource.thread_id→build_session_key()→HERMES_SESSION_THREAD_ID→ cronoriginpath. No changes tosession.pyorcronjob_tools.py— that logic already handledthread_id; only the email adapter failedto populate it.
_thread_contextrekeyed fromsender_addrto(sender_addr, thread_id). A send naming no thread (standalone/home-channel) resolves to thecorrespondent's most recent thread; a send naming an unknown thread returns empty context
rather than borrowing another thread's anchor — borrowing is the bug being fixed.
RFC 5322 conformance (same file)
References— was emitting only the parent'sMessage-ID, discarding all earlierancestry, so clients could not nest a reply in a long thread. Now builds the full chain
(parent's
References+ parent'sMessage-ID), deduplicated, capped at 20 by keeping thethread root plus the most recent ancestors.
Message-IDis attacker-controlled and was echoed verbatiminto
In-Reply-To/References. A value containing CRLF reached Python's header setter andraised
HeaderParseErrorat send time, meaning every reply in that thread would failpermanently. Header injection was never possible (Python blocks it), but the availability bug
was real. Values are now trimmed to their first well-formed
<id-left@id-right>token, ordropped.
Message-IDgeneration moved from 48 bits of UUID hex toemail.utils.make_msgid().compliant; no change, but both now have regression tests.
Tests —
tests/gateway/test_email_thread_isolation.py(new, 817 lines / 38 tests), plus8 lines each in
test_email.pyandtest_send_multiple_images.py.Scope
#27804 has two halves and they have different owners. The session-isolation half is fixed
here. The notification-volume half (100–200 status emails per request) is already fixed
in-tree:
gateway/display_config.pyassigns email_TIER_MINIMAL, verified at runtime:Re-implementing throttling in the adapter would duplicate working behaviour and add dead code,
so this PR adds a regression guard pinning those defaults instead, while asserting an
operator can still opt back in per-platform.
#26277 is related but not fixed. It asks for opt-in isolation by normalized subject, on any
IMAP provider. This is Gmail-only, always-on where supported, and treats a mid-thread subject
rename as the same thread (
X-GM-THRIDis stable) where #26277 wants it to start a new one.Those are coherent but different products, so #26277 should stay open for non-Gmail providers.
Open questions for maintainers
platforms.email.extra.session_keying, defaultsender, so existing installs see zerochange. This PR applies it automatically wherever
X-GM-EXT-1is advertised. The capabilityprobe removes the correctness argument for a flag, but not the compatibility argument — on a
Gmail mailbox, existing senders get a one-time session fragmentation (old sessions remain at
their old keys and simply stop accumulating). Happy to add the flag if preferred.
…:dm:<addr>:gthr-<digits>; this emits bare digits.Trivial to change.
fix(email): preserve thread recipients and cursor state #90482), none merged. If a maintainer would rather see this folded into one of those, say so
and I'll close this in favour of it.
How to Test
scripts/run_tests.sh tests/gateway/test_email_thread_isolation.py tests/gateway/test_email.py tests/gateway/test_email_robustness.py tests/gateway/test_send_multiple_images.py -q(
agent:main:email:dm:<addr>:<thrid>); replying in one leaves the other's context untouched.deliver="origin"nests under thread A,not under whichever thread was most recently touched.
does not advertise X-GM-EXT-1warning — this is the regression the capability probe existsto prevent.
Checklist
Code
scripts/run_tests.shon the affected surface (1117 passed, 0 failed); whole-repo run in flight — see LogsDocumentation & Housekeeping
cli-config.yaml.exampleif I added/changed config keys — N/A, no new configCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
The regression the capability probe prevents — simulated non-Gmail IMAP server, before vs after:
RFC 5322 §3.6.4 References chain:
Header-injecting inbound Message-ID is now sanitized rather than fatal:
All figures below were measured on this commit, rebased onto current
main, usingscripts/run_tests.sh(the canonical CI-equivalent runner with per-file isolation).Email surface — the four files this touches:
Everything importing the changed code — 76 files referencing the email adapter,
build_session_key,DeliveryRouteror the cron scheduler:Note: the project's declared dev extra was incomplete in my environment —
pytest-asynciowasmissing, which fails every
@pytest.mark.asynciotest repo-wide. Installing the[dev]pinsfrom
pyproject.tomlwas required to get a meaningful run.