Skip to content

Commit 1ff68ca

Browse files
authored
feat(email): voice/style-matched drafting from Sent history (#1607) (#1925)
Closes #1607. Draft replies came out as neutral boilerplate no matter how the user actually writes. Now the agent can learn the user's voice from their own Sent mail — usual greeting, sign-off, typical length, formality — and every drafted reply comes out sounding like them, while staying approval-gated (#1264): building the profile and drafting trigger zero send side-effects, with a regression test. Local-first by construction: only derived features are persisted to the agent's SQLite `state.db` (never raw Sent content), and greeting/sign-off extraction matches a closed vocabulary, so hostile text in a Sent body cannot reach the system prompt. Per the issue notes, the profile plugs into the draft-composition prompt (the LLM composes `draft_reply` bodies in the agent loop), not into `draft_reply` itself — the sidecar REST contract is unchanged. ## Test plan - [x] `PYTHONPATH=$PWD/hub/agents/python/email python -m pytest hub/agents/python/email/tests/test_email_voice_profile.py -q` — 16 new tests: analyzer (greeting/sign-off/length/formality, quoted-text stripping, curly apostrophes, long signature blocks), persistence round-trip + restart survival, SENT-label-only sampling, prompt injection before/after build, no-raw-content-persisted sentinel, and the no-send-side-effect acceptance test. - [x] Full email sweep (hub package tests + `tests/unit/agents/test_email_agent*.py` + `tests/integration/test_never_auto_send.py` + `tests/unit/agents/email/`) — 748 passed; the 2 failures are pre-existing environment skew, fail identically on a clean tree. - [x] `black`/`isort` clean on all touched files; `util/lint.py --all` passes. ## Deliberately deferred - **Judge-scored `style_match` eval AC**: needs the email eval harness extended (draft-quality judging against the #1230 corpus) plus a Strix Halo hardware run — shipping an unrunnable scenario file would be half-finished work. Proposing a follow-up issue for it. - **Multi-mailbox voice routing**: with several mailboxes connected, the prompt uses the most-recently-built profile rather than routing per-message; ties into the #1603 multi-inbox work.
1 parent 080e725 commit 1ff68ca

7 files changed

Lines changed: 801 additions & 2 deletions

File tree

docs/guides/email.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ The Email Triage Agent connects to your Gmail account through GAIA's connectors
1414
- **Organize** — archive, label, mark read/unread, star/unstar. Reversible via the per-action undo log.
1515
- **Soft-delete with undo**`trash_message` records the action; `restore_message` reverses it within a 30-second window.
1616
- **Draft + confirmed send** — generate replies (`draft_reply`) and forwards (`draft_forward`); `send_draft` and `send_now` require explicit user confirmation in the UI.
17+
- **Voice-matched drafting** — learn your writing style from your Sent mail (`build_voice_profile`) so drafts sound like you; the style profile is derived and stored locally, nothing leaves the device.
1718
- **Calendar** — list events, accept/decline invites, create events from email content (all calendar mutations gated by user confirmation).
1819

1920
## Setup
@@ -157,6 +158,10 @@ Senders you reply to quickly are automatically promoted to priority on the next
157158

158159
`profile_inbox` — asks "who emails me most?" and returns a frequency ranking of senders with their dominant category (e.g. *urgent*, *informational*) and the timestamp of their most recent message. Profiling is built from the interaction history the agent accumulates during triage, so it improves the more you use the agent.
159160

161+
### Voice-matched drafting
162+
163+
`build_voice_profile` — ask the agent to "learn my writing style" and it samples your recent Sent mail, derives a style profile (usual greeting, sign-off, typical length, formality), and stores it on-device. From then on, drafted replies come out in your voice instead of neutral boilerplate — still returned for your approval, never auto-sent. The profile keeps derived features only (never your Sent message content) and lives in the agent's local SQLite database; nothing leaves the device. `clear_voice_profile` forgets it.
164+
160165
### Organize (reversible via the undo log)
161166

162167
`archive_message`, `mark_read`, `mark_unread`, `add_star`, `remove_star`, `label_message`, `move_to_label`

hub/agents/python/email/gaia_agent_email/action_store.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ class — see ``test_email_agent_soft_delete.py``.
6868
);
6969
"""
7070

71+
# Voice/style profile derived from Sent mail (#1607). One row per mailbox.
72+
# ``profile_json`` holds DERIVED features only (greetings, sign-offs, length,
73+
# formality signals) — never raw Sent content. See ``voice_profile.py``.
74+
EMAIL_VOICE_PROFILE_DDL = """
75+
CREATE TABLE IF NOT EXISTS email_voice_profile (
76+
mailbox TEXT PRIMARY KEY,
77+
profile_json TEXT NOT NULL,
78+
built_at REAL NOT NULL
79+
);
80+
"""
81+
7182

7283
# 100 chars max — see plan A4 + adversarial S15. Email bodies routinely
7384
# carry MFA codes, password reset URLs, banking transaction summaries; a
@@ -76,9 +87,10 @@ class — see ``test_email_agent_soft_delete.py``.
7687

7788

7889
def init_schema(db) -> None:
79-
"""Create both tables if they don't exist, then run migrations. Idempotent."""
90+
"""Create the tables if they don't exist, then run migrations. Idempotent."""
8091
db.execute(EMAIL_ACTIONS_DDL)
8192
db.execute(EMAIL_DRAFTS_DDL)
93+
db.execute(EMAIL_VOICE_PROFILE_DDL)
8294
_migrate_email_actions_mailbox(db)
8395

8496

@@ -276,16 +288,74 @@ def fetch_draft(db, *, draft_id: str) -> Optional[Dict[str, Any]]:
276288
return result
277289

278290

291+
# ---------------------------------------------------------------------------
292+
# email_voice_profile API (#1607)
293+
# ---------------------------------------------------------------------------
294+
295+
296+
def save_voice_profile(db, *, mailbox: str, profile: Dict[str, Any]) -> None:
297+
"""Upsert the voice profile for *mailbox* (one row per mailbox).
298+
299+
Update-then-insert (not delete-then-insert) so a failure between the
300+
two statements can never lose the existing profile.
301+
"""
302+
row = {
303+
"profile_json": json.dumps(profile),
304+
"built_at": time.time(),
305+
}
306+
updated = db.update("email_voice_profile", row, "mailbox = :m", {"m": mailbox})
307+
if not updated:
308+
db.insert("email_voice_profile", dict(row, mailbox=mailbox))
309+
310+
311+
def fetch_voice_profile(
312+
db, *, mailbox: Optional[str] = None
313+
) -> Optional[Dict[str, Any]]:
314+
"""Return the stored profile dict, or ``None`` when none has been built.
315+
316+
With ``mailbox=None`` returns the most recently built profile across
317+
mailboxes — the common single-mailbox case and the system-prompt path.
318+
"""
319+
if mailbox:
320+
row = db.query(
321+
"SELECT profile_json FROM email_voice_profile WHERE mailbox = :m",
322+
{"m": mailbox},
323+
one=True,
324+
)
325+
else:
326+
row = db.query(
327+
"SELECT profile_json FROM email_voice_profile "
328+
"ORDER BY built_at DESC LIMIT 1",
329+
{},
330+
one=True,
331+
)
332+
if row is None:
333+
return None
334+
return json.loads(row["profile_json"])
335+
336+
337+
def delete_voice_profile(db, *, mailbox: Optional[str] = None) -> None:
338+
"""Delete the profile for *mailbox*, or all profiles when ``None``."""
339+
if mailbox:
340+
db.delete("email_voice_profile", "mailbox = :m", {"m": mailbox})
341+
else:
342+
db.delete("email_voice_profile", "1 = 1", {})
343+
344+
279345
__all__ = [
280346
"BODY_PREVIEW_MAX_CHARS",
281347
"EMAIL_ACTIONS_DDL",
282348
"EMAIL_DRAFTS_DDL",
349+
"EMAIL_VOICE_PROFILE_DDL",
350+
"delete_voice_profile",
283351
"fetch_batch_undoable",
284352
"fetch_draft",
285353
"fetch_undoable",
354+
"fetch_voice_profile",
286355
"init_schema",
287356
"mark_draft_sent",
288357
"mark_undone",
289358
"record_action",
290359
"record_draft",
360+
"save_voice_profile",
291361
]

hub/agents/python/email/gaia_agent_email/agent.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to
6060
from gaia_agent_email.tools.read_tools import ReadToolsMixin
6161
from gaia_agent_email.tools.reply_tools import ReplyToolsMixin
6262
from gaia_agent_email.tools.summarize_tools import SummarizeToolsMixin
63+
from gaia_agent_email.tools.voice_tools import VoiceToolsMixin
64+
from gaia_agent_email.voice_profile import render_style_guidance
6365

6466
from gaia.agents.base.agent import Agent
6567
from gaia.agents.base.console import AgentConsole
@@ -139,6 +141,9 @@ def __getattr__(self, name: str):
139141
set_category_default, clear_session_preferences) — mutate persistent
140142
classification preferences that survive across restarts. Confirm the
141143
change in plain English.
144+
- Style tools (build_voice_profile, clear_voice_profile) — learn or
145+
forget the user's writing style from their Sent mail. Local-only:
146+
reads mail, sends nothing; the profile is stored on-device.
142147
143148
PRE-SCAN BEHAVIOR:
144149
When the user asks for a pre-scan, morning brief, triage view, or "what's
@@ -175,6 +180,7 @@ class EmailTriageAgent(
175180
PreferenceToolsMixin,
176181
PhishingToolsMixin,
177182
ProfileToolsMixin,
183+
VoiceToolsMixin,
178184
):
179185
"""Email Triage Agent — Gmail + Calendar through the connectors
180186
framework, all body inference local on Lemonade.
@@ -332,7 +338,13 @@ def _create_console(self) -> AgentConsole:
332338
return AgentConsole()
333339

334340
def _get_system_prompt(self) -> str:
335-
return _SYSTEM_PROMPT
341+
# Voice/style-matched drafting (#1607): once a profile has been
342+
# built from Sent mail, every turn's prompt carries the style
343+
# guidance so draft bodies come out in the user's own voice.
344+
profile = action_store.fetch_voice_profile(self)
345+
if profile is None:
346+
return _SYSTEM_PROMPT
347+
return _SYSTEM_PROMPT + "\n" + render_style_guidance(profile)
336348

337349
def process_query(self, *args, **kwargs):
338350
# Zero the batch-organize counter per turn so a long-lived instance
@@ -357,6 +369,7 @@ def _register_tools(self) -> None:
357369
self._register_preference_tools()
358370
self._register_phishing_tools()
359371
self._register_profile_tools()
372+
self._register_voice_tools()
360373
self.register_memory_tools()
361374

362375
# -- Phase 2 multi-inbox routing (#1603) -------------------------------
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
2+
# SPDX-License-Identifier: MIT
3+
"""Voice/style profile tools mixin for ``EmailTriageAgent`` (#1607).
4+
5+
``build_voice_profile`` samples the user's Sent mail through the mail
6+
backend, derives a style profile (``voice_profile.analyze_sent_bodies``),
7+
and persists it locally via ``action_store``. The agent's system prompt
8+
picks the stored profile up on the next turn, so draft bodies composed for
9+
``draft_reply``/``draft_forward`` match the user's own voice.
10+
11+
Both tools are local-only: reading Sent mail mutates nothing remote, and
12+
the profile lives in the agent's SQLite ``state.db`` — no Sent content
13+
leaves the device (derived features only are stored).
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import json
19+
from typing import Any
20+
21+
from gaia_agent_email import action_store
22+
from gaia_agent_email.gmail_backend import decode_message_body
23+
from gaia_agent_email.verbose import log_tool_call
24+
from gaia_agent_email.voice_profile import analyze_sent_bodies
25+
26+
from gaia.agents.base.tools import tool
27+
from gaia.connectors.errors import ConnectorsError
28+
from gaia.connectors.formatting import format_connector_error
29+
from gaia.logger import get_logger
30+
31+
log = get_logger(__name__)
32+
33+
# Default Sent-mail sample. Big enough to smooth over one-off outliers,
34+
# small enough to stay fast on first run (one get_message per sample).
35+
DEFAULT_SAMPLE_SIZE = 50
36+
37+
38+
def _envelope_ok(data: Any) -> str:
39+
return json.dumps({"ok": True, "data": data}, default=str)
40+
41+
42+
def _envelope_err(message: str) -> str:
43+
return json.dumps({"ok": False, "error": message})
44+
45+
46+
def build_voice_profile_impl(
47+
gmail,
48+
db,
49+
*,
50+
mailbox: str,
51+
sample_size: int = DEFAULT_SAMPLE_SIZE,
52+
debug: bool = False,
53+
) -> dict:
54+
with log_tool_call(
55+
"build_voice_profile",
56+
{"mailbox": mailbox, "sample_size": sample_size},
57+
debug=debug,
58+
) as st:
59+
listing = gmail.list_messages(label_ids=["SENT"], max_results=sample_size)
60+
refs = listing.get("messages", [])
61+
if not refs:
62+
raise ValueError(
63+
f"no Sent messages found in mailbox '{mailbox}' — a voice "
64+
"profile needs Sent history to learn from. Send a few emails "
65+
"first, or connect a mailbox that has Sent mail."
66+
)
67+
bodies = []
68+
for ref in refs:
69+
msg = gmail.get_message(ref["id"])
70+
body, _attachments = decode_message_body(msg.get("payload") or {})
71+
if body.strip():
72+
bodies.append(body)
73+
profile = analyze_sent_bodies(bodies)
74+
action_store.save_voice_profile(db, mailbox=mailbox, profile=profile)
75+
st["result_summary"] = {"sample_count": profile["sample_count"]}
76+
return dict(profile, mailbox=mailbox)
77+
78+
79+
class VoiceToolsMixin:
80+
"""Registers ``build_voice_profile`` and ``clear_voice_profile``."""
81+
82+
def _register_voice_tools(self) -> None:
83+
db = self
84+
agent = self
85+
debug_flag = bool(getattr(self.config, "debug", False))
86+
87+
@tool
88+
def build_voice_profile(
89+
sample_size: int = DEFAULT_SAMPLE_SIZE, mailbox: str = ""
90+
) -> str:
91+
"""Learn the user's writing voice from their Sent mail.
92+
93+
Samples recent Sent messages, derives a local style profile
94+
(greeting, sign-off, typical length, formality), and stores it
95+
on-device. Future draft bodies should match this profile. Reads
96+
mail only — sends nothing, mutates nothing remote.
97+
98+
``sample_size`` (optional, default 50) caps how many recent Sent
99+
messages are analyzed. ``mailbox`` (optional) selects which
100+
connected mailbox to learn from; defaults to the primary one.
101+
"""
102+
try:
103+
if sample_size <= 0:
104+
return _envelope_err(
105+
f"sample_size must be positive, got {sample_size}"
106+
)
107+
if not agent._backends:
108+
return _envelope_err(
109+
"no mailbox is connected — connect Gmail or Outlook "
110+
"via `gaia connectors` first, then retry"
111+
)
112+
provider = mailbox or next(iter(agent._backends))
113+
backend = agent._backends.get(provider)
114+
if backend is None:
115+
return _envelope_err(
116+
f"mailbox '{provider}' is not connected — connected "
117+
f"mailboxes: {sorted(agent._backends)}"
118+
)
119+
profile = build_voice_profile_impl(
120+
backend,
121+
db,
122+
mailbox=provider,
123+
sample_size=sample_size,
124+
debug=debug_flag,
125+
)
126+
return _envelope_ok(profile)
127+
except ConnectorsError as exc:
128+
return _envelope_err(format_connector_error(exc))
129+
except Exception as exc:
130+
log.exception("email tool error: %s", type(exc).__name__)
131+
return _envelope_err(f"{type(exc).__name__}: {exc}")
132+
133+
@tool
134+
def clear_voice_profile(mailbox: str = "") -> str:
135+
"""Forget the learned voice profile.
136+
137+
Removes the stored style profile so drafts go back to neutral
138+
phrasing. ``mailbox`` (optional) clears one mailbox's profile;
139+
default clears all.
140+
"""
141+
try:
142+
action_store.delete_voice_profile(db, mailbox=mailbox or None)
143+
return _envelope_ok({"cleared": mailbox or "all"})
144+
except Exception as exc:
145+
log.exception("email tool error: %s", type(exc).__name__)
146+
return _envelope_err(f"{type(exc).__name__}: {exc}")

0 commit comments

Comments
 (0)