Conversation
…prevent reply clobber
The email adapter's `_thread_context` dict was keyed by sender address alone,
so two concurrent inbound messages from the same sender would overwrite
each other's `In-Reply-To` / `Message-ID` / `Subject` headers. Outbound
replies then threaded into the wrong conversation on the client side.
Reproducer: Alice sends email A; agent starts a slow reply; Alice sends
unrelated email B; agent finishes reply to A but populates it with B's
reply headers. Mail client threads the reply under B.
Changes:
- Rekey `_thread_context` from `Dict[str, Dict[str, str]]` to
`Dict[Tuple[str, str], Dict[str, Any]]`, keyed by `(sender, message_id)`.
- Stamp each entry with `last_seen_ts` (time.time()).
- Cap the dict at `_thread_context_max = 500` entries and add a
`_trim_thread_context()` trimmer that mirrors `_trim_seen_uids()`.
- Add `_lookup_thread_context(to_addr, thread_id=None)` helper:
- exact tuple match when `thread_id` is provided,
- otherwise the most-recent entry (by `last_seen_ts`) whose sender
matches — preserves today's "reply with the latest subject from
this sender" behavior for callers not yet plumbing thread_id
through.
- Update `_send_email`, `_send_email_with_attachment`, and
`get_chat_info` to route through the helper.
Tests:
- `test_concurrent_threads_no_clobber` — two inbound messages from the
same sender keep distinct entries; each resolves to its own ctx.
- `test_thread_context_trim_bounds_memory` — cap + trimmer keep the dict
bounded and preserve the most-recent entries.
- `test_lookup_helper_falls_back_to_latest_when_no_thread_id` — helper
reduces to "latest from this sender" when no thread_id hint is given
(cross-sender decoy entries are ignored).
- `test_lookup_helper_returns_empty_for_unknown_sender` — empty dict on
unknown sender.
- Existing tests updated to the new tuple-key shape.
This PR is a standalone data-correctness fix. A follow-up PR will use
the new tuple-key shape to route each email thread to its own gateway
session (Gmail IMAP thread-id keying).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an opt-in `platforms.email.extra.session_keying` setting that lets
the email adapter route each visible-to-user mail thread to its own
gateway session, keyed by Gmail's `X-GM-THRID` IMAP extension.
Background: today the email adapter omits `thread_id` when calling
`build_source(...)`, so every inbound message from a given sender
collapses into a single session forever. That produces four user-visible
problems:
1. Context poisoning across unrelated topics — memories the agent
persists while handling Email A bleed into replies for unrelated
Email B from the same sender.
2. Monotonic token-cost bloat — every new email's first turn replays
the entire sender's prior history.
3. Broken `/reset` semantics — resetting one thread destroys context
for every other concurrent thread from the same sender.
4. Reply-header clobber — addressed in PR NousResearch#11418 (a precursor PR
that this branch is stacked on).
Slack, Discord, Telegram, and Matrix already pass `thread_id` through.
This brings email parity for Gmail-backed mailboxes.
## Why Gmail-only
`X-GM-THRID` is Gmail's stable, server-assigned thread identifier,
returned in the same FETCH round-trip as the body:
UID FETCH <uid> (RFC822 X-GM-THRID)
It matches what users see as a thread in Gmail (web, mobile, Apple
Mail), inherits Gmail's threading heuristics (subject changes
mid-thread, forwards, cross-client replies), and is documented at
https://developers.google.com/gmail/imap/imap-extensions.
The alternative (RFC 5322 `References` walking + subject-hash) is ~150
LoC of error-prone parsing this PR explicitly defers; non-Gmail
deployments can stay on the default `sender` mode until a follow-up PR
adds a generic mode.
## Changes
- New `extra.session_keying` config: `"sender"` (default, unchanged)
or `"gmail_thread_id"`. Unknown values warn-and-degrade to `sender`.
- `connect()` runs `imap.capability()` and records `_has_gmail_ext`.
If `gmail_thread_id` is requested but `X-GM-EXT-1` is absent, the
adapter logs a warning and degrades to `sender` mode.
- `_fetch_new_messages` switches the FETCH payload to
`(RFC822 X-GM-THRID)` when Gmail mode is active, parses the THRID
via `_parse_gm_thrid`, and threads it through `msg_data`.
- `_dispatch_message` derives `thread_id`:
* `f"gthr-{thrid}"` when THRID is present, or
* `f"mid-{sha1(message_id)[:12]}"` when it's missing (treats the
message as its own thread root — always safe).
Then passes `thread_id` to `build_source(...)` and stores
`_thread_context` under the `(sender, thread_id)` tuple.
- `send()` extracts `thread_id` from `metadata["thread_id"]` and
forwards it to `_send_email`. `_send_email` and
`_send_email_with_attachment` accept `thread_id` and resolve via
`_lookup_thread_context(to_addr, thread_id)`.
- `send_image` and `send_document` were missing the `metadata`
parameter — extended their signatures and plumbed metadata through
`send`/`_send_email_with_attachment` so attachment replies thread
correctly in Gmail mode. (Without this fix replies with images would
fall back to the latest-subject heuristic.)
## Tests
- `TestSessionKeying` (new, 14 tests): default mode behavior, mode
validation, Gmail thread-id derivation (with and without THRID),
same-thrid → same key, distinct-thrid → distinct keys, capability
probe degradation, FETCH command shape per mode, THRID parser, and
metadata propagation through `send_image`/`send_document`.
- `TestEmailThreadIdSessionKey` in `test_session.py` (new, 3 tests):
regression that `build_session_key` for an email DM with thread_id
produces `agent:main:email:dm:<addr>:<thread_id>`.
Full email + session sweep: 154 passed, 0 failed.
Broader gateway sweep (excluding pre-existing flaky env-dependent
tests): 2816 passed, 0 failed.
## Migration / risk
- Default stays `sender`. Existing installs see zero behavior change.
- Opting a profile into `gmail_thread_id` produces a one-time
session-fragmentation event for active conversations: the next
message from a sender lands in a fresh thread session instead of
the pre-existing flat one. Existing sessions remain accessible at
their old keys; they just stop accumulating new messages.
- All seven Quinn clients run on Google Workspace, so the capability
probe is informational defense-in-depth rather than the production
fallback path.
Linked: NousResearch#11418 (precursor: `_thread_context`
clobber bugfix this PR builds on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… adapter Adds a "Session Keying" section to the Email user guide and a commented example to `cli-config.yaml.example` documenting the opt-in `platforms.email.extra.session_keying` setting. The docs explain: - Why the default collapses-all-mail-from-one-sender behavior can be a problem in busy mailboxes (context poisoning, token-cost bloat, broken /reset scope). - How Gmail's X-GM-THRID IMAP extension produces the same thread IDs Gmail's own clients use. - The X-GM-EXT-1 capability requirement and the graceful degrade-to- sender fallback for non-Gmail servers. - What to expect when migrating: a one-time session-fragmentation event per active conversation; older sessions stay accessible under their original keys. Pairs with NousResearch#11422 (feature) and NousResearch#11418 (precursor bugfix). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused Gmail-thread proposal. The underlying issue is still present: current main stores email reply context only by sender at plugins/platforms/email/adapter.py:867-870 and emits no thread_id at plugins/platforms/email/adapter.py:872-878.
Problems
- This needs a current-main port: the PR changes
gateway/platforms/email.py, but commit560010547moved the active adapter toplugins/platforms/email/adapter.py(whoseEmailAdapterbegins at line 422). - Please cover the newer batch-image path.
gateway/platforms/base.py:5075-5083passes thread metadata intosend_multiple_images, butplugins/platforms/email/adapter.py:1023-1031currently drops it before_send_email_with_attachments, which reads sender-global context at line 1047. A Gmail-thread session could therefore still produce a batched-image reply threaded to the wrong message.
Suggested changes
- Salvage both stacked changes into the bundled plugin and update the email tests' imports.
- Carry
metadata["thread_id"]throughsend_multiple_imagesand_send_email_with_attachments, with a regression test for that route.
Automated hermes-sweeper review.
| """Send a file as an email attachment.""" | ||
| """Send a file as an email attachment. | ||
|
|
||
| Accepts `metadata` so thread_id-scoped attachment replies thread |
There was a problem hiding this comment.
When salvaging this metadata plumbing onto current main, please carry the same thread_id through EmailAdapter.send_multiple_images() and _send_email_with_attachments(). The current base delivery path sends extracted image batches with metadata, so otherwise batched-image replies would still resolve sender-global reply context.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Three PRs address or document the same sender-wide email-session defect. #11422 adds opt-in Gmail X-GM-THRID session isolation plus thread-scoped outbound context and tests, #27508 derives thread IDs from RFC 5322 headers but leaves outbound state sender-wide, and #11423 documents #11422's configuration without implementing it.
Related pull requests
- #11422
related— (+718/-21) — preferred salvage: This is the most complete diff against the reported cause, adding Gmail-thread session keys, tuple-keyed reply context, metadata plumbing, bounded state, and regression tests. Before merge, port it from the removed gateway adapter to plugins/platforms/email/adapter.py and preserve thread metadata through the current send_multiple_images path, as required by the keep_open contributor review. - #11423 [closed]
related— (+48/-0) — fold into implementation: This closed PR documents the proposed gmail_thread_id mode and its migration behavior, but the blocking contributor objection was valid because the option did not exist on current main. Its documentation remains relevant only if incorporated alongside the ported implementation rather than reopened or merged independently. - #27508
duplicate— (+107/-4) — superseded by #11422: The RFC 5322 References-root approach targets the same session-collapse cause and improves References chaining, but its diff retains sender-wide outbound context, contains a broken Hermes-root fallback due to overwriting context before lookup, lacks regression tests, and targets the obsolete adapter path. Despite the keep_open reviews on #27508, the diff shows these unresolved correctness gaps, while #11422 already supplies the stronger context-keying and test foundation.
Duplicates
#11422 and #27508 substantially duplicate the per-thread email-session change, using Gmail X-GM-THRID and RFC 5322 root Message-IDs respectively; #11423 is the documentation companion to #11422.
Suggested consolidation
Merge #11422 after porting both stacked changes to plugins/platforms/email/adapter.py, carrying thread metadata through text, document, image, and batched-image sends, and folding in #11423's documentation; this explicitly resolves the contributor reviews rather than bypassing them. Then close #27508 as superseded by #11422, while considering its RFC 5322 References-chain extension as a separate follow-up for non-Gmail providers; keep #11423 closed because its blocking no-implementation objection is addressed by folding the docs into the implementation PR.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup11422 ["PRs duplicating each other"]
P11422["PR #11422 (open)"]
P27508["PR #27508 (open)"]
end
class P11422 open
class P27508 open
class P11422 target
click P11422 "https://github.com/NousResearch/hermes-agent/pull/11422"
click P27508 "https://github.com/NousResearch/hermes-agent/pull/27508"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 54 kB of PR diffs, 13 kB of issue/PR text, 5 kB of discussion (5 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
|
This only works with Gmail's own thread key (yes stating the obvious :) ), the X-GM-THRID IMAP extension. Would be better if this was universal. In my case Fastmail, but really any IMAP mailbox. As written, non-Gmail IMAP logs a warning and falls back to per-sender keying, so the mode does nothing there. Provider-independent options: normalized-subject keying (#26277, attempted in #45600), or the thread_root follow-up you mention, walking Message-ID/References. |
Summary
Opt-in
platforms.email.extra.session_keyingsetting that lets the email adapter route each visible-to-user mail thread to its own gateway session, keyed by Gmail'sX-GM-THRIDIMAP extension. Brings email parity with Slack / Discord / Telegram / Matrix, which already split sessions per thread.Stacked on #11418 (precursor
_thread_contextclobber bugfix). The diff below includes both commits; they should land in order.The user-visible problem
Today the email adapter omits
thread_idwhen building theSessionSource, so every inbound message from a given sender collapses into one shared session forever:/resetsemantics — resetting one thread destroys context for every other concurrent thread from the same sender.Why Gmail-only (for now)
X-GM-THRIDis Gmail's stable, server-assigned thread identifier, returned in the same FETCH round-trip as the body:It matches what users see as a thread in Gmail (web/mobile/Apple Mail), inherits Gmail's threading heuristics (subject changes mid-thread, forwards, cross-client replies), and is documented at https://developers.google.com/gmail/imap/imap-extensions.
The alternative (RFC 5322
Referenceswalking + subject-hash) is ~150 LoC of error-prone parsing; defer to a follow-up that adds a genericthread_rootmode for non-Gmail deployments. Default stayssender— non-Gmail users see zero change.Changes
extra.session_keyingvalue,\"sender\"(default) or\"gmail_thread_id\". Unknown values warn-and-degrade tosender.connect()runsimap.capability()and records_has_gmail_ext. Ifgmail_thread_idis requested butX-GM-EXT-1is absent, the adapter logs a warning and degrades tosendermode._fetch_new_messagesswitches the FETCH payload to(RFC822 X-GM-THRID)when Gmail mode is active. Parses the THRID via a small_parse_gm_thridhelper._dispatch_messagederivesthread_id:f\"gthr-{thrid}\"when THRID is present, orf\"mid-{sha1(message_id)[:12]}\"when it's missing (treats the message as its own thread root — always safe).Then passes
thread_idtobuild_source(...)and stores_thread_contextunder the(sender, thread_id)tuple.send()extractsthread_idfrommetadata[\"thread_id\"]and forwards it to_send_email._send_emailand_send_email_with_attachmentacceptthread_idand resolve via the helper added in fix(gateway): rekey email _thread_context by (sender, message_id) to prevent reply clobber #11418.send_image/send_documentextension: these methods were missing themetadataparameter — without that fix, attachment replies would dropthread_idand fall back to the latest-subject heuristic. Signatures extended; metadata plumbed throughsend/_send_email_with_attachment.Tests
TestSessionKeying(14 new tests): default-mode behavior, mode validation, Gmail thread-id derivation (with and without THRID), same-thrid → same key, distinct-thrid → distinct keys, capability probe degradation, FETCH payload shape per mode, THRID parser unit tests, metadata propagation throughsend_image/send_document.TestEmailThreadIdSessionKeyintests/gateway/test_session.py(3 new tests): regression thatbuild_session_keyfor an email DM with thread_id producesagent:main:email:dm:<addr>:<thread_id>.Local verification:
Migration / risk
sender. Existing installs: zero behavior change.gmail_thread_idcauses a one-time session fragmentation: the next message from a sender lands in a fresh thread session rather than the pre-existing flat one. Existing sessions remain accessible at their old keys; they simply stop accumulating new messages.X-GM-EXT-1.Test plan
agent:main:email:dm:<addr>:gthr-<digits>) appear in the supervisord log./resetscope: resetting thread A leaves thread B's session untouched.