Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/guides/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ The Email Triage Agent connects to your Gmail account through GAIA's connectors
- **Organize** — archive, label, mark read/unread, star/unstar. Reversible via the per-action undo log.
- **Soft-delete with undo** — `trash_message` records the action; `restore_message` reverses it within a 30-second window.
- **Draft + confirmed send** — generate replies (`draft_reply`) and forwards (`draft_forward`); `send_draft` and `send_now` require explicit user confirmation in the UI.
- **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.
- **Calendar** — list events, accept/decline invites, create events from email content (all calendar mutations gated by user confirmation).

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

`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.

### Voice-matched drafting

`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.

### Organize (reversible via the undo log)

`archive_message`, `mark_read`, `mark_unread`, `add_star`, `remove_star`, `label_message`, `move_to_label`
Expand Down
72 changes: 71 additions & 1 deletion hub/agents/python/email/gaia_agent_email/action_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ class — see ``test_email_agent_soft_delete.py``.
);
"""

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


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


def init_schema(db) -> None:
"""Create both tables if they don't exist, then run migrations. Idempotent."""
"""Create the tables if they don't exist, then run migrations. Idempotent."""
db.execute(EMAIL_ACTIONS_DDL)
db.execute(EMAIL_DRAFTS_DDL)
db.execute(EMAIL_VOICE_PROFILE_DDL)
_migrate_email_actions_mailbox(db)


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


# ---------------------------------------------------------------------------
# email_voice_profile API (#1607)
# ---------------------------------------------------------------------------


def save_voice_profile(db, *, mailbox: str, profile: Dict[str, Any]) -> None:
"""Upsert the voice profile for *mailbox* (one row per mailbox).

Update-then-insert (not delete-then-insert) so a failure between the
two statements can never lose the existing profile.
"""
row = {
"profile_json": json.dumps(profile),
"built_at": time.time(),
}
updated = db.update("email_voice_profile", row, "mailbox = :m", {"m": mailbox})
if not updated:
db.insert("email_voice_profile", dict(row, mailbox=mailbox))


def fetch_voice_profile(
db, *, mailbox: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Return the stored profile dict, or ``None`` when none has been built.

With ``mailbox=None`` returns the most recently built profile across
mailboxes — the common single-mailbox case and the system-prompt path.
"""
if mailbox:
row = db.query(
"SELECT profile_json FROM email_voice_profile WHERE mailbox = :m",
{"m": mailbox},
one=True,
)
else:
row = db.query(
"SELECT profile_json FROM email_voice_profile "
"ORDER BY built_at DESC LIMIT 1",
{},
one=True,
)
if row is None:
return None
return json.loads(row["profile_json"])


def delete_voice_profile(db, *, mailbox: Optional[str] = None) -> None:
"""Delete the profile for *mailbox*, or all profiles when ``None``."""
if mailbox:
db.delete("email_voice_profile", "mailbox = :m", {"m": mailbox})
else:
db.delete("email_voice_profile", "1 = 1", {})


__all__ = [
"BODY_PREVIEW_MAX_CHARS",
"EMAIL_ACTIONS_DDL",
"EMAIL_DRAFTS_DDL",
"EMAIL_VOICE_PROFILE_DDL",
"delete_voice_profile",
"fetch_batch_undoable",
"fetch_draft",
"fetch_undoable",
"fetch_voice_profile",
"init_schema",
"mark_draft_sent",
"mark_undone",
"record_action",
"record_draft",
"save_voice_profile",
]
15 changes: 14 additions & 1 deletion hub/agents/python/email/gaia_agent_email/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to
from gaia_agent_email.tools.read_tools import ReadToolsMixin
from gaia_agent_email.tools.reply_tools import ReplyToolsMixin
from gaia_agent_email.tools.summarize_tools import SummarizeToolsMixin
from gaia_agent_email.tools.voice_tools import VoiceToolsMixin
from gaia_agent_email.voice_profile import render_style_guidance

from gaia.agents.base.agent import Agent
from gaia.agents.base.console import AgentConsole
Expand Down Expand Up @@ -139,6 +141,9 @@ def __getattr__(self, name: str):
set_category_default, clear_session_preferences) — mutate persistent
classification preferences that survive across restarts. Confirm the
change in plain English.
- Style tools (build_voice_profile, clear_voice_profile) — learn or
forget the user's writing style from their Sent mail. Local-only:
reads mail, sends nothing; the profile is stored on-device.

PRE-SCAN BEHAVIOR:
When the user asks for a pre-scan, morning brief, triage view, or "what's
Expand Down Expand Up @@ -175,6 +180,7 @@ class EmailTriageAgent(
PreferenceToolsMixin,
PhishingToolsMixin,
ProfileToolsMixin,
VoiceToolsMixin,
):
"""Email Triage Agent — Gmail + Calendar through the connectors
framework, all body inference local on Lemonade.
Expand Down Expand Up @@ -332,7 +338,13 @@ def _create_console(self) -> AgentConsole:
return AgentConsole()

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

def process_query(self, *args, **kwargs):
# Zero the batch-organize counter per turn so a long-lived instance
Expand All @@ -357,6 +369,7 @@ def _register_tools(self) -> None:
self._register_preference_tools()
self._register_phishing_tools()
self._register_profile_tools()
self._register_voice_tools()
self.register_memory_tools()

# -- Phase 2 multi-inbox routing (#1603) -------------------------------
Expand Down
146 changes: 146 additions & 0 deletions hub/agents/python/email/gaia_agent_email/tools/voice_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
# SPDX-License-Identifier: MIT
"""Voice/style profile tools mixin for ``EmailTriageAgent`` (#1607).

``build_voice_profile`` samples the user's Sent mail through the mail
backend, derives a style profile (``voice_profile.analyze_sent_bodies``),
and persists it locally via ``action_store``. The agent's system prompt
picks the stored profile up on the next turn, so draft bodies composed for
``draft_reply``/``draft_forward`` match the user's own voice.

Both tools are local-only: reading Sent mail mutates nothing remote, and
the profile lives in the agent's SQLite ``state.db`` — no Sent content
leaves the device (derived features only are stored).
"""

from __future__ import annotations

import json
from typing import Any

from gaia_agent_email import action_store
from gaia_agent_email.gmail_backend import decode_message_body
from gaia_agent_email.verbose import log_tool_call
from gaia_agent_email.voice_profile import analyze_sent_bodies

from gaia.agents.base.tools import tool
from gaia.connectors.errors import ConnectorsError
from gaia.connectors.formatting import format_connector_error
from gaia.logger import get_logger

log = get_logger(__name__)

# Default Sent-mail sample. Big enough to smooth over one-off outliers,
# small enough to stay fast on first run (one get_message per sample).
DEFAULT_SAMPLE_SIZE = 50


def _envelope_ok(data: Any) -> str:
return json.dumps({"ok": True, "data": data}, default=str)


def _envelope_err(message: str) -> str:
return json.dumps({"ok": False, "error": message})


def build_voice_profile_impl(
gmail,
db,
*,
mailbox: str,
sample_size: int = DEFAULT_SAMPLE_SIZE,
debug: bool = False,
) -> dict:
with log_tool_call(
"build_voice_profile",
{"mailbox": mailbox, "sample_size": sample_size},
debug=debug,
) as st:
listing = gmail.list_messages(label_ids=["SENT"], max_results=sample_size)
refs = listing.get("messages", [])
if not refs:
raise ValueError(
f"no Sent messages found in mailbox '{mailbox}' — a voice "
"profile needs Sent history to learn from. Send a few emails "
"first, or connect a mailbox that has Sent mail."
)
bodies = []
for ref in refs:
msg = gmail.get_message(ref["id"])
body, _attachments = decode_message_body(msg.get("payload") or {})
if body.strip():
bodies.append(body)
profile = analyze_sent_bodies(bodies)
action_store.save_voice_profile(db, mailbox=mailbox, profile=profile)
st["result_summary"] = {"sample_count": profile["sample_count"]}
return dict(profile, mailbox=mailbox)


class VoiceToolsMixin:
"""Registers ``build_voice_profile`` and ``clear_voice_profile``."""

def _register_voice_tools(self) -> None:
db = self
agent = self
debug_flag = bool(getattr(self.config, "debug", False))

@tool
def build_voice_profile(
sample_size: int = DEFAULT_SAMPLE_SIZE, mailbox: str = ""
) -> str:
"""Learn the user's writing voice from their Sent mail.

Samples recent Sent messages, derives a local style profile
(greeting, sign-off, typical length, formality), and stores it
on-device. Future draft bodies should match this profile. Reads
mail only — sends nothing, mutates nothing remote.

``sample_size`` (optional, default 50) caps how many recent Sent
messages are analyzed. ``mailbox`` (optional) selects which
connected mailbox to learn from; defaults to the primary one.
"""
try:
if sample_size <= 0:
return _envelope_err(
f"sample_size must be positive, got {sample_size}"
)
if not agent._backends:
return _envelope_err(
"no mailbox is connected — connect Gmail or Outlook "
"via `gaia connectors` first, then retry"
)
provider = mailbox or next(iter(agent._backends))
backend = agent._backends.get(provider)
if backend is None:
return _envelope_err(
f"mailbox '{provider}' is not connected — connected "
f"mailboxes: {sorted(agent._backends)}"
)
profile = build_voice_profile_impl(
backend,
db,
mailbox=provider,
sample_size=sample_size,
debug=debug_flag,
)
return _envelope_ok(profile)
except ConnectorsError as exc:
return _envelope_err(format_connector_error(exc))
except Exception as exc:
log.exception("email tool error: %s", type(exc).__name__)
return _envelope_err(f"{type(exc).__name__}: {exc}")

@tool
def clear_voice_profile(mailbox: str = "") -> str:
"""Forget the learned voice profile.

Removes the stored style profile so drafts go back to neutral
phrasing. ``mailbox`` (optional) clears one mailbox's profile;
default clears all.
"""
try:
action_store.delete_voice_profile(db, mailbox=mailbox or None)
return _envelope_ok({"cleared": mailbox or "all"})
except Exception as exc:
log.exception("email tool error: %s", type(exc).__name__)
return _envelope_err(f"{type(exc).__name__}: {exc}")
Loading
Loading