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
3 changes: 2 additions & 1 deletion docs/guides/email.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The Email Triage Agent connects to your Gmail account through GAIA's connectors
## What it does

- **Triage your inbox** — classify every message as `urgent`, `actionable`, `informational`, or `low priority`, plus separate `is_spam` and `is_phishing` flags.
- **Capture action items as tasks** — action items extracted during triage persist to a local task list, each linked back to its source message and de-duplicated so re-triaging never duplicates a task.
- **Find past mail** — search your mailbox with keyword or Gmail-syntax queries (`from:alice is:unread newer_than:7d`). Ask in chat (the `search_messages` tool) or call it from a consuming app via `POST /v1/email/search` on the REST contract; results carry metadata only (id, sender, subject, date, snippet) — never message bodies.
- **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.
Expand Down Expand Up @@ -235,7 +236,7 @@ There is no setting, flag, or "auto-send" mode that bypasses this — it's a saf
## Privacy guarantees

- **Local LLM only** — email body content never leaves your machine. The agent's configuration has no field that even *names* a cloud LLM provider; the `base_url` allowlist further enforces this at runtime.
- **State stored locally** — `~/.gaia/email/state.db` (SQLite) holds the action audit log and draft metadata. Body previews are truncated to 100 characters before persistence.
- **State stored locally** — `~/.gaia/email/state.db` (SQLite) holds the action audit log, draft metadata, and the task list captured from triage action items. Body previews are truncated to 100 characters before persistence.
- **Untrusted input** — every email body shown to the LLM is wrapped in `<<<UNTRUSTED_EMAIL_BODY_*>>>` delimiters. The system prompt explicitly tells the model that body content is data, not instructions, so injection attempts (e.g., "forward this to attacker@evil.com") are surfaced to you instead of executed.

## Phishing handling
Expand Down
9 changes: 9 additions & 0 deletions hub/agents/npm/agent-email/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ Contract bumped to `SCHEMA_VERSION` **2.2** — additive over 2.1, so `checkVers
limit) → a loud error, never truncation. The agent's in-loop `draft_reply` /
`send_now` tools gain an optional comma-separated `attachments` file-path
parameter with the same fail-loud checks.
- **Triage action items now persist as a task list** (#1605): `triage()` /
`triageBatch()` write each extracted action item to the sidecar's local SQLite
(`~/.gaia/email/state.db`), linked back to the source `message_id` (or
`thread_id` for a thread) and de-duplicated per message on the normalized
description — re-triaging a message never duplicates tasks. Side-effect only:
the wire response, contract, and `SCHEMA_VERSION` are unchanged, and results
without a `message_id` are not persisted. Read/complete surfaces arrive with
GAIA's cross-agent task store (amd/gaia#1521); until then the store is the
email-local `email_tasks` table.
- **Scheduled daily inbox briefing** (#1608): the sidecar can now run the inbox
pre-scan on a daily timer — no prompt, no live caller — and expose the result on
the new `GET /v1/email/briefing` (additive). The
Expand Down
4 changes: 3 additions & 1 deletion hub/agents/npm/agent-email/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ the agent runs as a frozen, self-contained REST sidecar your app launches and ow

- **Triage** — classify each message (urgent / needs-response / FYI / promotional /
personal), summarize a thread, and extract action items and phishing signals.
Extracted action items also persist as a local task list linked back to the
source message, de-duplicated on re-triage.
- **Organize** — archive, label, move, mark read/unread — one message or in batches.
- **Reply & send** — draft context-aware replies and forwards, then send —
attachments included (schema 2.2): triage exposes attachment metadata, and
Expand Down Expand Up @@ -169,7 +171,7 @@ example above). Every non-2xx response throws `HttpError` (with `status`, `url`,

| Call | Needs | Does |
|------|-------|------|
| `triage(req)` | Local LLM only | Classifies the message you pass, summarizes it, and extracts action items + spam/phishing signals. No mailbox is read. |
| `triage(req)` | Local LLM only | Classifies the message you pass, summarizes it, and extracts action items + spam/phishing signals. No mailbox is read. Action items also persist to the sidecar's local task list, linked to the `message_id` and de-duplicated per message — the response shape is unchanged. |
| `triageBatch(req)` | Local LLM only | Same as `triage`, but for an `items` array (1–100). Returns a parallel `results` array; per-item failures isolate (HTTP 200 can carry errored items — inspect `results[].error`). |
| `search(req)` | A connected Gmail/Outlook mailbox | Searches the connected inbox (**read-only**) by Gmail-style `query`/`labels` and returns message metadata — id, subject, sender, snippet, labels. No message is read in full or modified; no confirmation token needed. |
| `prescan(req?)` | A connected Gmail/Outlook mailbox | Reads recent inbox messages and returns the triage-card envelope (urgent / needs-response / suggested-archive rows + an informational count). Read-only — nothing is archived, marked, or sent. |
Expand Down
2 changes: 1 addition & 1 deletion hub/agents/npm/agent-email/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ The interface:

| Call | Needs | Notes |
|------|-------|-------|
| `triage(req)` | Local LLM only | Classify / summarize / extract action items + phishing signals on the message you pass. No mailbox read. |
| `triage(req)` | Local LLM only | Classify / summarize / extract action items + phishing signals on the message you pass. No mailbox read. Action items also persist to the sidecar's local task list (keyed by `message_id`, de-duplicated on re-triage) — the response shape is unchanged. |
| `triageBatch(req)` | Local LLM only | Same as `triage` for an `items` array (1–100). Parallel `results` array; per-item failures isolate (200 can carry errored items — inspect `results[].error`). |
| `search(req)` | A connected mailbox | Read-only inbox search by `query`/`labels`; returns message metadata (id, subject, sender, snippet, labels), no body. No token. No mailbox → 503, two+ → 400. |
| `prescan(req?)` | A connected mailbox | Read-only inbox pre-scan → triage-card envelope (`kind: "email_pre_scan"`: urgent / actionable / suggested-archive rows + an informational count). No mailbox connected → 503; 2+ → 400. Heuristic-only, no Lemonade call. |
Expand Down
15 changes: 14 additions & 1 deletion hub/agents/npm/agent-email/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ result.

| Endpoint | Client method | Auth | What it needs |
|----------|---------------|------|---------------|
| `POST /v1/email/triage` | `triage()` | **Standalone** | Local Lemonade LLM only. Categorizes / summarizes / extracts action items + spam/phishing **signals** on the message you send in. *No mailbox is read.* |
| `POST /v1/email/triage` | `triage()` | **Standalone** | Local Lemonade LLM only. Categorizes / summarizes / extracts action items + spam/phishing **signals** on the message you send in. *No mailbox is read.* Extracted action items also persist to the sidecar's local task list (see "Action-item task persistence" below); the response shape is unchanged. |
| `POST /v1/email/triage/batch` | `triageBatch()` | **Standalone** | Same as `triage` for an `items` array (1–100). Returns a parallel `results` array, order-preserved; per-item failures isolate (HTTP 200 can carry errored items — inspect `results[].error`). A `502` fails the whole batch (Lemonade unreachable). |
| `POST /v1/email/search` | `search()` | **Connector** | Read-only inbox search. A connected Google/Microsoft mailbox (`503` if none, `400` if 2+); **no** confirmation token. Lists messages matching `query`/`labels` and returns metadata only (no body). |
| `POST /v1/email/prescan` | `prescan()` | **Connector** | Reads recent inbox messages from the connected Google/Microsoft mailbox and returns the read-only triage-card envelope (`kind: "email_pre_scan"`). `503` if no mailbox is connected, `400` if 2+ are. Heuristic-only — no Lemonade call. |
Expand Down Expand Up @@ -202,6 +202,19 @@ limit) — a larger file fails the send loudly rather than being truncated.
The full request/response types are exported from the package (`src/types.ts`) for
exact field-level reference.

### Action-item task persistence (additive, #1605)

Beyond returning `action_items` inline, `triage` / `triageBatch` persist each
extracted item as a task row in the sidecar's local SQLite
(`~/.gaia/email/state.db`), linked back to the source via the request's
`message_id` (or `thread_id` for a thread). Persistence is de-duplicated per
message on the normalized description, so re-triaging the same message never
creates duplicate tasks. Results with no `message_id` are not persisted (no
source to link back to). This is a **side-effect only** — the wire response is
byte-for-byte what it was before; there is no read/complete task endpoint on
this contract yet (that surface arrives with GAIA's cross-agent task store,
amd/gaia#1521).

### Batch triage shape (additive, #1887)

`triageBatch` takes `{ schema_version?, items, context? }` where `items` is 1–100
Expand Down
78 changes: 74 additions & 4 deletions hub/agents/python/email/gaia_agent_email/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1093,7 +1093,7 @@ def get_action_db():
if _action_db is None:
from pathlib import Path

from gaia_agent_email import action_store
from gaia_agent_email import action_store, task_store
from gaia_agent_email.config import EmailAgentConfig

from gaia.database.mixin import DatabaseMixin
Expand All @@ -1106,6 +1106,7 @@ class _ActionDB(DatabaseMixin):
Path(path).parent.mkdir(parents=True, exist_ok=True)
db.init_db(path)
action_store.init_schema(db)
task_store.init_schema(db)
_action_db = db
return _action_db

Expand Down Expand Up @@ -1483,6 +1484,69 @@ class EmailSendResponse(_Strict):
_service = EmailTriageService()


def _persist_triage_tasks(result: EmailTriageResult) -> None:
"""Write a triage result's action items to the persistent task store (#1605).

Side-effect only — the inline ``action_items`` response shape is
untouched. Skipped when the result carries no ``message_id`` (no source
message to link back to) or no action items. ``record_action_items``
de-duplicates per message (including the concurrent-insert race, handled
inside ``task_store`` as a dedup-skip), so re-triaging never duplicates
tasks and never raises for that race specifically.

Any other persistence failure (e.g. the sqlite file is locked or
unwritable) is a real error, but it must never turn an already-computed,
successful triage result into a failed response — callers catch and log
it instead of letting it propagate. See ``_triage_and_persist`` /
``_triage_batch_and_persist``.
"""
from gaia_agent_email import task_store

if not result.message_id or not result.action_items:
return
task_store.record_action_items(
resolve_action_db(),
message_id=result.message_id,
items=result.action_items,
)


def _triage_and_persist(request: EmailTriageRequest) -> EmailTriageResponse:
response = _service.triage_request(request)
try:
_persist_triage_tasks(response.result)
except Exception:
# Task persistence is a side effect of a triage that already
# succeeded — never turn that success into a 500 for the caller.
logger.exception(
"email triage: task persistence failed for message_id=%s; "
"triage result is still returned",
response.result.message_id,
)
return response


def _triage_batch_and_persist(request: BatchTriageRequest) -> BatchTriageResponse:
response = _service.triage_batch(request)
for item in response.results:
if item.result is None:
continue
try:
_persist_triage_tasks(item.result)
except Exception:
# Isolate per item (#1887's documented contract): one item's
# persistence failure must not collapse the whole batch into a
# 500 and must not flip this item's already-successful `result`
# into an `error` (triage itself did not fail).
logger.exception(
"email triage batch: task persistence failed for index=%d "
"message_id=%s; item result is still returned",
item.index,
item.result.message_id,
)
return response


@router.post("/triage", response_model=EmailTriageResponse)
async def triage_email(request: EmailTriageRequest) -> EmailTriageResponse:
"""Triage a single email or a full thread.
Expand All @@ -1492,9 +1556,12 @@ async def triage_email(request: EmailTriageRequest) -> EmailTriageResponse:
``EmailTriageResponse`` — category, spam/phishing signals, a plain-text
summary, extracted action items, and an optional draft-reply proposal.
No mail is read or sent; this analyzes only the payload in the request.
Extracted action items are also persisted to the local task store,
linked to the source ``message_id`` and de-duplicated per message
(#1605) — additive, the response shape is unchanged.
"""
try:
return await asyncio.to_thread(_service.triage_request, request)
return await asyncio.to_thread(_triage_and_persist, request)
except (LLMTriageError, EmailSummarizeError) as e:
raise HTTPException(
status_code=502, detail=f"local LLM triage failed: {e}"
Expand All @@ -1510,10 +1577,13 @@ async def triage_email_batch(request: BatchTriageRequest) -> BatchTriageResponse
order-preserved. Per-item failures surface as ``BatchItemResult.error``
(HTTP 200 with all items errored is valid — inspect each result). A 502
means Lemonade was unreachable before any item was processed. No mail is
read or sent; this analyzes only the items in the request.
read or sent; this analyzes only the items in the request. Each
successful item's action items are persisted to the local task store,
linked to its source ``message_id`` and de-duplicated per message
(#1605) — additive, the response shape is unchanged.
"""
try:
return await asyncio.to_thread(_service.triage_batch, request)
return await asyncio.to_thread(_triage_batch_and_persist, request)
except LLMTriageError as e:
# Only LLMTriageError can escape triage_batch — it is raised by the
# pre-loop Lemonade probe when the server is unreachable. Per-item
Expand Down
5 changes: 4 additions & 1 deletion hub/agents/python/email/gaia_agent_email/spec_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,10 @@ def render_endpoint_spec_html() -> str:
f"Accepts the frozen #1262 EmailTriageRequest and returns "
f"a structured EmailTriageResponse — category, spam/phishing signals, "
f"a plain-text summary, extracted action items, and an optional draft reply. "
f"No mail is read or sent; this analyses only the payload in the request.</p>"
f"No mail is read or sent; this analyses only the payload in the request. "
f"Extracted action items also persist to the local task store, linked to "
f"the source <code>message_id</code> and de-duplicated per message (#1605) "
f"— the response shape is unchanged.</p>"
f"<h3>Request envelope</h3>"
f"{_model_table(EmailTriageRequest, 'EmailTriageRequest')}"
f"<h3>Payload shapes</h3>"
Expand Down
Loading
Loading