feat(email): full-thread reading & comprehension#1352
Conversation
Add a standalone per-email summarize capability. Until now a summary only arose implicitly inside triage; there was no way to summarize one specific message and no length-bounded "key ask" guarantee. New SummarizeToolsMixin registers a summarize_message tool that reads the message body locally (reusing the production MIME decoder), wraps it in the agent's untrusted-input delimiters, and asks the local LLM for a concise one-or-two-sentence summary of the email's key ask or decision. The length bound is part of the contract: the result is hard-capped to a character limit at a word boundary. Empty model output or an LLM transport failure raises EmailSummarizeError rather than returning a silent empty summary. Closes #1267
The agent could only summarize the latest message in a thread; a decision made in an earlier message that the most recent reply didn't restate was invisible to the user. summarize_thread now reads every message in a thread, renders them oldest-first into one transcript, and summarizes the whole conversation — so non-latest content is reflected. Adds summarize_thread_impl + a summarize_thread @tool on ReadToolsMixin, reusing the per-email summarization contract from #1267 (shared system prompt, empty-output guard, word-boundary length bound, EmailSummarizeError). Fail-loud: an empty thread or LLM failure raises rather than silently collapsing to a latest-only summary. Each message body stays wrapped in the untrusted-input delimiters; messages are sorted defensively by internalDate. Links #1268.
|
✅ Eval-regression check (per the CLAUDE.md eval-trigger rule for tool-schema/prompt changes): ran Result: PASSED (24.5 min). Categorization accuracy still meets baseline-minus-tolerance, so the enlarged tool schema does not regress triage. No live-model regression from this change. |
SummaryThis PR adds Issues Found🟡 No total transcript length cap — context overflow on large threads (
|
|
This is a clean, well-scoped addition — full-thread comprehension works correctly: the 3-message seeded-out-of-order test (decision in message #1 not repeated in message #3) is the right proof-of-concept scenario. One design mismatch worth addressing before merge, two minor cleanups, and one nit. SummaryThe implementation correctly adds The one issue worth calling out before merge: the reused Issues🟡 Important —
|
|
Review feedback addressed in follow-up #1378 (this PR was squash-merged before the fixes landed). Each point:
New tests in #1378: cap-bounding, every-message-survives-under-cap, cap-disabled, per-message-floor, and thread-system-prompt-selected (14 total in the file, plus the tool-registry suite still green). One honesty note carried into #1378: |
Closes #1268 Before: the agent summarized only the latest message in a thread — decisions or asks raised earlier were invisible. After: `summarize_thread` reads the full thread via `get_thread`, renders an oldest-first transcript (each message individually wrapped against injection, not whole-thread-truncated), and summarizes that — so a decision made in the first message survives into the summary. Fails loudly on empty thread / missing chat / LLM error (never silently falls back to latest-only). ## Test plan - [x] 7 unit tests (`test_thread_comprehension.py`), LLM mocked: a 3-message thread where the decision lives only in message #1 (seeded out of order) reaches the prompt before the latest message; fail-loud on empty/missing/transport/empty-output - [x] `tests/unit/agents/email/` → 39 passed; adjacent suites 138 passed; lint clean - [ ] **Eval-trigger** (new thread-transcript prompt + tool): serial regression check pending **Stacked on #1267.** Base is `tmi/issue-1267-per-email-summarize`; I'll retarget to `tmi/infallible-payne-e3c628` once #1267 merges. (#1267 itself targets the integration branch — nothing targets main.)
…1268 follow-up) (#1378) Follow-up to #1268 / #1352 (merged), addressing the bot review feedback on `summarize_thread`. Before: a long thread fed every message's full body (per-message limit × N messages) into the prompt with no combined ceiling — a 50-message thread could reach hundreds of KB and overflow a local model's context window. And the thread path reused the single-email system prompt, whose "ONE or TWO sentences / SINGLE most important point" instruction fights a multi-message thread, so distinct decisions raised in different messages could be silently dropped from the summary. After: a `_THREAD_SYSTEM_PROMPT` lets `max_chars` bound the summary instead of a sentence cap, so every decision across the conversation can survive; and `_format_thread_for_summary` steers the combined body budget toward `max_total_transcript_chars` (default 24000) by shrinking the per-message budget rather than dropping the oldest messages — an early decision is never lost to make room. A per-message floor keeps each message readable, so the target is a soft target (floor × count), documented as such, not a false hard-ceiling promise. Also folds the reviewers' smaller asks: explicit `> 0` truncation guard (so `per_message_body_limit=0` no longer silently disables truncation), drop a redundant re-sort at the call site, a comment that `EmailSummarizeError.message_id` carries the thread_id here, and a test cleanup (module-level `import base64`). ## Test plan - [x] `pytest tests/unit/agents/email/test_thread_comprehension.py` — 14 tests incl. new cap-bounding, every-message-survives-under-cap, cap-disabled, per-message-floor, and thread-system-prompt-selected - [x] `pytest tests/unit/agents/test_email_agent.py` — tool registry / allowlist still green - [x] black + isort clean - [ ] **Eval gate (merge blocker):** `_THREAD_SYSTEM_PROMPT` is a new LLM-affecting prompt. The committed email eval scores triage *categorization* (unaffected by this change — triage uses `llm_triage.py`, not `summarize_thread`), so the #1230 baseline is not regressed; thread-summary *quality* has no scored eval scenario yet, so its correctness rests on the unit coverage above plus reviewer judgment. Flagging per the CLAUDE.md eval-trigger rule. Closes #1268 follow-up.
…1268 follow-up) (#1378) Follow-up to #1268 / #1352 (merged), addressing the bot review feedback on `summarize_thread`. Before: a long thread fed every message's full body (per-message limit × N messages) into the prompt with no combined ceiling — a 50-message thread could reach hundreds of KB and overflow a local model's context window. And the thread path reused the single-email system prompt, whose "ONE or TWO sentences / SINGLE most important point" instruction fights a multi-message thread, so distinct decisions raised in different messages could be silently dropped from the summary. After: a `_THREAD_SYSTEM_PROMPT` lets `max_chars` bound the summary instead of a sentence cap, so every decision across the conversation can survive; and `_format_thread_for_summary` steers the combined body budget toward `max_total_transcript_chars` (default 24000) by shrinking the per-message budget rather than dropping the oldest messages — an early decision is never lost to make room. A per-message floor keeps each message readable, so the target is a soft target (floor × count), documented as such, not a false hard-ceiling promise. Also folds the reviewers' smaller asks: explicit `> 0` truncation guard (so `per_message_body_limit=0` no longer silently disables truncation), drop a redundant re-sort at the call site, a comment that `EmailSummarizeError.message_id` carries the thread_id here, and a test cleanup (module-level `import base64`). ## Test plan - [x] `pytest tests/unit/agents/email/test_thread_comprehension.py` — 14 tests incl. new cap-bounding, every-message-survives-under-cap, cap-disabled, per-message-floor, and thread-system-prompt-selected - [x] `pytest tests/unit/agents/test_email_agent.py` — tool registry / allowlist still green - [x] black + isort clean - [ ] **Eval gate (merge blocker):** `_THREAD_SYSTEM_PROMPT` is a new LLM-affecting prompt. The committed email eval scores triage *categorization* (unaffected by this change — triage uses `llm_triage.py`, not `summarize_thread`), so the #1230 baseline is not regressed; thread-summary *quality* has no scored eval scenario yet, so its correctness rests on the unit coverage above plus reviewer judgment. Flagging per the CLAUDE.md eval-trigger rule. Closes #1268 follow-up.
Closes #1268
Before: the agent summarized only the latest message in a thread — decisions or asks raised earlier were invisible. After:
summarize_threadreads the full thread viaget_thread, renders an oldest-first transcript (each message individually wrapped against injection, not whole-thread-truncated), and summarizes that — so a decision made in the first message survives into the summary. Fails loudly on empty thread / missing chat / LLM error (never silently falls back to latest-only).Test plan
test_thread_comprehension.py), LLM mocked: a 3-message thread where the decision lives only in message Update installer and workflows/actions for CI/CD #1 (seeded out of order) reaches the prompt before the latest message; fail-loud on empty/missing/transport/empty-outputtests/unit/agents/email/→ 39 passed; adjacent suites 138 passed; lint cleanStacked on #1267. Base is
tmi/issue-1267-per-email-summarize; I'll retarget totmi/infallible-payne-e3c628once #1267 merges. (#1267 itself targets the integration branch — nothing targets main.)