diff --git a/docs/guides/email.mdx b/docs/guides/email.mdx
index 603831831..fa410a071 100644
--- a/docs/guides/email.mdx
+++ b/docs/guides/email.mdx
@@ -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.
@@ -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 `<<>>` 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
diff --git a/hub/agents/npm/agent-email/CHANGELOG.md b/hub/agents/npm/agent-email/CHANGELOG.md
index e25ca5e39..f1827546b 100644
--- a/hub/agents/npm/agent-email/CHANGELOG.md
+++ b/hub/agents/npm/agent-email/CHANGELOG.md
@@ -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
diff --git a/hub/agents/npm/agent-email/README.md b/hub/agents/npm/agent-email/README.md
index e225bdf4d..514806252 100644
--- a/hub/agents/npm/agent-email/README.md
+++ b/hub/agents/npm/agent-email/README.md
@@ -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
@@ -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. |
diff --git a/hub/agents/npm/agent-email/SKILL.md b/hub/agents/npm/agent-email/SKILL.md
index a6e3ea0ae..d98ad1964 100644
--- a/hub/agents/npm/agent-email/SKILL.md
+++ b/hub/agents/npm/agent-email/SKILL.md
@@ -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. |
diff --git a/hub/agents/npm/agent-email/SPEC.md b/hub/agents/npm/agent-email/SPEC.md
index 576cc3420..38b4ca935 100644
--- a/hub/agents/npm/agent-email/SPEC.md
+++ b/hub/agents/npm/agent-email/SPEC.md
@@ -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. |
@@ -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
diff --git a/hub/agents/python/email/gaia_agent_email/api_routes.py b/hub/agents/python/email/gaia_agent_email/api_routes.py
index d943c720c..e55ab946f 100644
--- a/hub/agents/python/email/gaia_agent_email/api_routes.py
+++ b/hub/agents/python/email/gaia_agent_email/api_routes.py
@@ -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
@@ -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
@@ -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.
@@ -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}"
@@ -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
diff --git a/hub/agents/python/email/gaia_agent_email/spec_html.py b/hub/agents/python/email/gaia_agent_email/spec_html.py
index 2a5770627..3f771b560 100644
--- a/hub/agents/python/email/gaia_agent_email/spec_html.py
+++ b/hub/agents/python/email/gaia_agent_email/spec_html.py
@@ -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.
"
+ 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 message_id and de-duplicated per message (#1605) "
+ f"— the response shape is unchanged."
f"Request envelope
"
f"{_model_table(EmailTriageRequest, 'EmailTriageRequest')}"
f"Payload shapes
"
diff --git a/hub/agents/python/email/gaia_agent_email/task_store.py b/hub/agents/python/email/gaia_agent_email/task_store.py
new file mode 100644
index 000000000..16af6364b
--- /dev/null
+++ b/hub/agents/python/email/gaia_agent_email/task_store.py
@@ -0,0 +1,177 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+"""
+Persistent task list captured from email triage (#1605).
+
+Triage already extracts ``action_items`` and returns them inline; this module
+persists those items as durable task rows so they survive the response —
+triage becomes follow-through, not just analysis. Rows live in the
+``email_tasks`` table of the same SQLite the action log uses
+(``EmailAgentConfig.resolved_db_path()``), each linked back to the source
+message via ``message_id``.
+
+L8 seam (#1521): the cross-agent Task & To-Do store does not exist in-tree
+yet. ``record_action_items`` is the single write entry point the REST surface
+calls; when the shared ``TaskStore`` lands, this function becomes the adapter
+that forwards each task with ``source_ref=message_id`` instead of writing the
+local table. Consumers should not query ``email_tasks`` directly outside this
+module.
+
+Dedup invariant: at most one task per ``(message_id, normalized description)``.
+Re-triaging a message re-extracts the same action items; those are skipped,
+never duplicated. Normalization collapses whitespace and lowercases, so
+wording must match — a genuinely reworded item is a new task. A UNIQUE index
+backs the invariant at the schema level.
+
+All public helpers are pure functions taking a ``DatabaseMixin``-typed first
+argument, mirroring ``action_store``. They never reach into the agent class.
+"""
+
+from __future__ import annotations
+
+import sqlite3
+import time
+import uuid
+from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional
+
+if TYPE_CHECKING: # pragma: no cover - typing only, avoids import at runtime
+ from gaia_agent_email.contract import ActionItem
+
+# ---------------------------------------------------------------------------
+# Schema
+# ---------------------------------------------------------------------------
+
+EMAIL_TASKS_DDL = """
+CREATE TABLE IF NOT EXISTS email_tasks (
+ task_id TEXT PRIMARY KEY,
+ message_id TEXT NOT NULL,
+ description TEXT NOT NULL,
+ description_norm TEXT NOT NULL,
+ due_hint TEXT,
+ item_type TEXT NOT NULL DEFAULT 'text',
+ url TEXT,
+ status TEXT NOT NULL DEFAULT 'open',
+ created_at REAL NOT NULL,
+ completed_at REAL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_email_tasks_msg_desc
+ ON email_tasks(message_id, description_norm);
+CREATE INDEX IF NOT EXISTS idx_email_tasks_status
+ ON email_tasks(status);
+"""
+
+
+def init_schema(db) -> None:
+ """Create the ``email_tasks`` table if it doesn't exist. Idempotent."""
+ db.execute(EMAIL_TASKS_DDL)
+
+
+# ---------------------------------------------------------------------------
+# API
+# ---------------------------------------------------------------------------
+
+
+def normalize_description(description: str) -> str:
+ """The dedup key for a task description: whitespace-collapsed, lowercased."""
+ return " ".join(description.split()).lower()
+
+
+def record_action_items(
+ db, *, message_id: str, items: Iterable["ActionItem"]
+) -> List[str]:
+ """Persist triage action items as tasks linked to ``message_id``.
+
+ Items already captured for this message (same normalized description) are
+ skipped, so re-triaging a message is idempotent. Returns the task_ids of
+ the rows actually created — ``[]`` when every item was a duplicate.
+
+ The pre-insert dedup check and the insert are not atomic, so two
+ concurrent triages of the same message can both pass the check and race
+ the UNIQUE index. That race is the dedup invariant firing, not a real
+ failure: ``sqlite3.IntegrityError`` from the ``idx_email_tasks_msg_desc``
+ constraint is caught and treated as a duplicate-skip. Any other
+ exception is not ours to interpret and propagates.
+ """
+ if not message_id:
+ raise ValueError(
+ "record_action_items requires a non-empty message_id — a task "
+ "without a source-message back-reference cannot be traced to its "
+ "email (#1605)."
+ )
+ existing = {
+ row["description_norm"]
+ for row in db.query(
+ "SELECT description_norm FROM email_tasks WHERE message_id = :m",
+ {"m": message_id},
+ )
+ }
+ created: List[str] = []
+ for item in items:
+ norm = normalize_description(item.description)
+ if norm in existing:
+ continue
+ existing.add(norm)
+ task_id = uuid.uuid4().hex
+ try:
+ db.insert(
+ "email_tasks",
+ {
+ "task_id": task_id,
+ "message_id": message_id,
+ "description": item.description,
+ "description_norm": norm,
+ "due_hint": item.due_hint,
+ "item_type": item.type,
+ "url": item.url,
+ "status": "open",
+ "created_at": time.time(),
+ "completed_at": None,
+ },
+ )
+ except sqlite3.IntegrityError:
+ # A concurrent triage of the same message won the race and
+ # already recorded this (message_id, description_norm) pair —
+ # the UNIQUE index invariant, not an error.
+ continue
+ created.append(task_id)
+ return created
+
+
+def list_tasks(
+ db, *, message_id: Optional[str] = None, status: Optional[str] = None
+) -> List[Dict[str, Any]]:
+ """Return task rows (oldest first), optionally filtered by source message
+ and/or status ('open' / 'done')."""
+ clauses = []
+ params: Dict[str, Any] = {}
+ if message_id is not None:
+ clauses.append("message_id = :m")
+ params["m"] = message_id
+ if status is not None:
+ clauses.append("status = :s")
+ params["s"] = status
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
+ rows = db.query(
+ f"SELECT * FROM email_tasks{where} ORDER BY created_at, task_id", params
+ )
+ return rows or []
+
+
+def mark_task_done(db, *, task_id: str) -> None:
+ """Mark a task done. Idempotent — the first completion timestamp is kept."""
+ db.update(
+ "email_tasks",
+ {"status": "done", "completed_at": time.time()},
+ "task_id = :id AND status != 'done'",
+ {"id": task_id},
+ )
+
+
+__all__ = [
+ "EMAIL_TASKS_DDL",
+ "init_schema",
+ "list_tasks",
+ "mark_task_done",
+ "normalize_description",
+ "record_action_items",
+]
diff --git a/hub/agents/python/email/openapi.email.json b/hub/agents/python/email/openapi.email.json
index 95fcc044e..1b68988de 100644
--- a/hub/agents/python/email/openapi.email.json
+++ b/hub/agents/python/email/openapi.email.json
@@ -2895,7 +2895,7 @@
},
"/v1/email/triage": {
"post": {
- "description": "Triage a single email or a full thread.\n\nAccepts the FROZEN #1262 ``EmailTriageRequest`` (a single-email or\nthread payload, discriminated on ``kind``) and returns the structured\n``EmailTriageResponse`` — category, spam/phishing signals, a plain-text\nsummary, extracted action items, and an optional draft-reply proposal.\nNo mail is read or sent; this analyzes only the payload in the request.",
+ "description": "Triage a single email or a full thread.\n\nAccepts the FROZEN #1262 ``EmailTriageRequest`` (a single-email or\nthread payload, discriminated on ``kind``) and returns the structured\n``EmailTriageResponse`` — category, spam/phishing signals, a plain-text\nsummary, extracted action items, and an optional draft-reply proposal.\nNo mail is read or sent; this analyzes only the payload in the request.\nExtracted action items are also persisted to the local task store,\nlinked to the source ``message_id`` and de-duplicated per message\n(#1605) — additive, the response shape is unchanged.",
"operationId": "triage_email_v1_email_triage_post",
"requestBody": {
"content": {
@@ -2937,7 +2937,7 @@
},
"/v1/email/triage/batch": {
"post": {
- "description": "Triage an array of emails or threads in a single request (#1887).\n\nAccepts a ``BatchTriageRequest`` (1..MAX_BATCH_SIZE ``EmailInput`` items)\nand returns a ``BatchTriageResponse`` — one ``BatchItemResult`` per item,\norder-preserved. Per-item failures surface as ``BatchItemResult.error``\n(HTTP 200 with all items errored is valid — inspect each result). A 502\nmeans Lemonade was unreachable before any item was processed. No mail is\nread or sent; this analyzes only the items in the request.",
+ "description": "Triage an array of emails or threads in a single request (#1887).\n\nAccepts a ``BatchTriageRequest`` (1..MAX_BATCH_SIZE ``EmailInput`` items)\nand returns a ``BatchTriageResponse`` — one ``BatchItemResult`` per item,\norder-preserved. Per-item failures surface as ``BatchItemResult.error``\n(HTTP 200 with all items errored is valid — inspect each result). A 502\nmeans Lemonade was unreachable before any item was processed. No mail is\nread or sent; this analyzes only the items in the request. Each\nsuccessful item's action items are persisted to the local task store,\nlinked to its source ``message_id`` and de-duplicated per message\n(#1605) — additive, the response shape is unchanged.",
"operationId": "triage_email_batch_v1_email_triage_batch_post",
"requestBody": {
"content": {
diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html
index d3ccf0fbe..8d1c701e5 100644
--- a/hub/agents/python/email/specification.html
+++ b/hub/agents/python/email/specification.html
@@ -353,7 +353,7 @@ 5 + schema 2.0
| 11 | Calendar — conflicts | Wired | Check the calendar in-line for overlaps | Tier 0 | Manual | — |
| 12 | Calendar — create | Wired | Create events from email context | Tier 2 | Manual | — |
| 13 | Search past email | Planned | Natural-language / keyword search across the mailbox | Tier 0 | Manual | — |
- | 14 | Task extraction → list | Planned | Persist action items into a trackable task list | Tier 0 | New mail | — |
+ | 14 | Task extraction → list | Wired | Persist action items into a trackable task list | Tier 0 | New mail | — |
| 15 | Follow-up tracking | Planned | Flag sent mail awaiting a reply; nudge when overdue | Tier 0 | Scheduled | — |
| 16 | Proactive daily briefing | Wired | Scheduled morning inbox brief, no prompt (calendar + tasks planned) | Tier 0 | Scheduled | ○ |
| 17 | Scheduled send & snooze | Planned | Defer a send; snooze a message out of the inbox | Tier 2 | Scheduled | — |
@@ -791,13 +791,13 @@ Each capability, in full
-
+
-
14
Task extraction → task list
PlannedTier 0 · read-only
-
Use-case
Promote the action items already extracted during triage from inline, per-message hints into a durable, cross-message task list the user can track to completion. Today triage returns action_items on each result; the planned work persists them — de-duplicated and linked back to the source message — into the shared task store so "what do I owe this week?" has a single answer. Turns triage from a labelling exercise into actual follow-through.
+
14
Task extraction → task list
WiredTier 0 · read-only
+
Use-case
Promote the action items already extracted during triage from inline, per-message hints into a durable, cross-message task list the user can track to completion. Triage still returns action_items on each result (unchanged); each item now also persists as a task row — de-duplicated per message and linked back to the source message_id — so "what do I owe this week?" has a single answer. Turns triage from a labelling exercise into actual follow-through.
Why it matters
Commitments buried across dozens of threads are the most common thing that silently slips; a single backed list is a named pillar across comparable tools.
-
Backed by
action_items from triage (capability 1) → the cross-agent task store (
#1521 /
#1525). Email side only produces/links tasks; it does not own the store.
-
Guardrails
Tier 0 to produce tasks; writes to the store are reversible.
+
Backed by
action_items from triage (capability 1) → the local
email_tasks store (same SQLite as the action log), written by
/v1/email/triage and
/v1/email/triage/batch. The email side only produces/links tasks; the unified read/complete surface lands with the cross-agent task store (
#1521 /
#1525), which this feeds.
+
Guardrails
Tier 0 — persistence is a local side-effect that never touches the mailbox or the wire response; results without a message_id are not persisted.
@@ -1320,7 +1320,7 @@ Endpoints
POST/v1/email/triage
-
Triage a single email or a full thread. Returns category, spam/phishing signals, summary, action items, and an optional draft proposal. Reads/sends no mail — analyzes only the payload.
+
Triage a single email or a full thread. Returns category, spam/phishing signals, summary, action items, and an optional draft proposal. Reads/sends no mail — analyzes only the payload. Extracted action items also persist to the local task store, linked to the source message_id and de-duplicated per message (capability 14) — the response shape is unchanged.
Engine selection
No query parameters. Triage runs on the local LLM (#1452); a deterministic heuristic short-circuits high-confidence cases (spam, promotional) as an internal optimization. The split is automatic — there is no caller-facing engine selector.
Request body — EmailTriageRequest
diff --git a/hub/agents/python/email/tests/test_email_task_store.py b/hub/agents/python/email/tests/test_email_task_store.py
new file mode 100644
index 000000000..da0476b40
--- /dev/null
+++ b/hub/agents/python/email/tests/test_email_task_store.py
@@ -0,0 +1,328 @@
+# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
+# SPDX-License-Identifier: MIT
+"""
+Task persistence from triage action items (#1605).
+
+Acceptance criteria under test:
+- Triaging a message with action cues creates task records linked to the
+ source ``message_id``.
+- Re-triaging the same message creates NO duplicate tasks.
+- The inline ``action_items`` response is unchanged (additive).
+
+Store-level tests exercise ``task_store`` directly against an in-memory
+DatabaseMixin; surface-level tests drive the real ``POST /v1/email/triage``
+route with a fake chat (no Lemonade) and an in-memory action DB injected via
+the existing ``resolve_action_db`` seam.
+"""
+
+from __future__ import annotations
+
+import json as _json
+import sqlite3
+import types as types_mod
+
+import pytest
+
+# The email agent ships as the standalone gaia-agent-email wheel (#1102);
+# skip cleanly when a framework-only env lacks it.
+pytest.importorskip("gaia_agent_email")
+
+from fastapi.testclient import TestClient # noqa: E402
+from gaia_agent_email import export_openapi, task_store # noqa: E402
+from gaia_agent_email.contract import ActionItem # noqa: E402
+
+from gaia.database.mixin import DatabaseMixin # noqa: E402
+
+
+class _DB(DatabaseMixin):
+ pass
+
+
+@pytest.fixture
+def db():
+ d = _DB()
+ d.init_db(":memory:")
+ task_store.init_schema(d)
+ return d
+
+
+# ---------------------------------------------------------------------------
+# Store level
+# ---------------------------------------------------------------------------
+
+
+def test_record_creates_tasks_linked_to_message(db):
+ items = [
+ ActionItem(description="Please review the Q3 budget.", due_hint="Friday"),
+ ActionItem(
+ description="Follow up at https://example.com/doc",
+ type="link",
+ url="https://example.com/doc",
+ ),
+ ]
+ created = task_store.record_action_items(db, message_id="m-1", items=items)
+ assert len(created) == 2
+
+ rows = task_store.list_tasks(db, message_id="m-1")
+ assert [r["description"] for r in rows] == [
+ "Please review the Q3 budget.",
+ "Follow up at https://example.com/doc",
+ ]
+ assert all(r["message_id"] == "m-1" for r in rows)
+ assert rows[0]["due_hint"] == "Friday"
+ assert rows[1]["item_type"] == "link"
+ assert rows[1]["url"] == "https://example.com/doc"
+ assert all(r["status"] == "open" for r in rows)
+
+
+def test_rerecord_same_message_creates_no_duplicates(db):
+ items = [ActionItem(description="Please review the Q3 budget.")]
+ first = task_store.record_action_items(db, message_id="m-1", items=items)
+ assert len(first) == 1
+
+ again = task_store.record_action_items(db, message_id="m-1", items=items)
+ assert again == []
+ assert len(task_store.list_tasks(db, message_id="m-1")) == 1
+
+
+def test_concurrent_duplicate_insert_raises_integrity_error_is_skipped(db, monkeypatch):
+ """A concurrent triage of the same message can win the pre-insert dedup
+ check race and hit the UNIQUE index on the actual INSERT. That must be
+ treated as the dedup invariant firing (skip), not re-raised as a failure
+ that would turn a successful triage into a 500 (bot review on #1917)."""
+ real_insert = db.insert
+ calls = {"n": 0}
+
+ def flaky_insert(table, data):
+ calls["n"] += 1
+ if calls["n"] == 1:
+ # Simulate another connection having already inserted the same
+ # (message_id, description_norm) row between our SELECT and INSERT.
+ raise sqlite3.IntegrityError(
+ "UNIQUE constraint failed: email_tasks.message_id, "
+ "email_tasks.description_norm"
+ )
+ return real_insert(table, data)
+
+ monkeypatch.setattr(db, "insert", flaky_insert)
+
+ created = task_store.record_action_items(
+ db,
+ message_id="m-race",
+ items=[
+ ActionItem(description="Please review the Q3 budget."),
+ ActionItem(description="A second, distinct item."),
+ ],
+ )
+
+ # The first item lost the race (IntegrityError -> skipped); the second
+ # item still gets recorded normally.
+ assert len(created) == 1
+ rows = task_store.list_tasks(db, message_id="m-race")
+ assert [r["description"] for r in rows] == ["A second, distinct item."]
+
+
+def test_non_integrity_error_on_insert_still_propagates(db, monkeypatch):
+ """Only the dedup-race IntegrityError is swallowed. Any other exception
+ (e.g. the sqlite file is locked/unwritable) is a real failure and must
+ still propagate loudly — no silent fallback."""
+
+ def broken_insert(table, data):
+ raise sqlite3.OperationalError("database is locked")
+
+ monkeypatch.setattr(db, "insert", broken_insert)
+
+ with pytest.raises(sqlite3.OperationalError, match="locked"):
+ task_store.record_action_items(
+ db, message_id="m-1", items=[ActionItem(description="x")]
+ )
+
+
+def test_dedup_key_is_normalized_description():
+ # Whitespace/case drift in a re-extracted item must not duplicate the task.
+ d = _DB()
+ d.init_db(":memory:")
+ task_store.init_schema(d)
+ task_store.record_action_items(
+ d, message_id="m-1", items=[ActionItem(description="Please review the doc")]
+ )
+ created = task_store.record_action_items(
+ d, message_id="m-1", items=[ActionItem(description=" please REVIEW the doc ")]
+ )
+ assert created == []
+ assert len(task_store.list_tasks(d, message_id="m-1")) == 1
+
+
+def test_same_item_different_messages_are_distinct_tasks(db):
+ item = [ActionItem(description="Please review the doc")]
+ task_store.record_action_items(db, message_id="m-1", items=item)
+ task_store.record_action_items(db, message_id="m-2", items=item)
+ assert len(task_store.list_tasks(db)) == 2
+ assert len(task_store.list_tasks(db, message_id="m-2")) == 1
+
+
+def test_record_without_message_id_fails_loudly(db):
+ with pytest.raises(ValueError, match="message_id"):
+ task_store.record_action_items(
+ db, message_id="", items=[ActionItem(description="x")]
+ )
+
+
+def test_mark_task_done_is_idempotent(db):
+ (task_id,) = task_store.record_action_items(
+ db, message_id="m-1", items=[ActionItem(description="Please reply")]
+ )
+ task_store.mark_task_done(db, task_id=task_id)
+ row = task_store.list_tasks(db, message_id="m-1")[0]
+ assert row["status"] == "done"
+ first_done = row["completed_at"]
+ assert first_done is not None
+
+ task_store.mark_task_done(db, task_id=task_id)
+ assert task_store.list_tasks(db, message_id="m-1")[0]["completed_at"] == first_done
+ assert task_store.list_tasks(db, status="open") == []
+
+
+# ---------------------------------------------------------------------------
+# REST surface — POST /v1/email/triage persists tasks as a side-effect
+# ---------------------------------------------------------------------------
+
+
+class _FakeChat:
+ """Deterministic chat stub: classification JSON for classify calls,
+ a fixed summary otherwise. No Lemonade needed."""
+
+ def send_messages(self, messages, system_prompt="", **kwargs):
+ resp = types_mod.SimpleNamespace()
+ first = messages[0].get("content", "") if messages else ""
+ resp.text = (
+ _json.dumps(
+ {"category": "NEEDS_RESPONSE", "confidence": 0.9, "reasoning": "t"}
+ )
+ if "Classify" in first
+ else "summary"
+ )
+ return resp
+
+
+def _triage_payload(message_id: str = "msg-77") -> dict:
+ return {
+ "payload": {
+ "kind": "single",
+ "principal": {"email": "me@example.com"},
+ "message": {
+ "message_id": message_id,
+ "from": {"name": "Alice", "email": "alice@example.com"},
+ "to": [{"email": "me@example.com"}],
+ "subject": "Budget",
+ "body": "Please review the Q3 budget and reply by Friday.",
+ },
+ }
+ }
+
+
+@pytest.fixture
+def triage_env(monkeypatch, db):
+ """Real /triage route, fake chat, in-memory task DB. Returns (client, db)."""
+ from gaia_agent_email import api_routes as email_routes
+
+ monkeypatch.setattr(
+ email_routes.EmailTriageService,
+ "_build_llm_chat",
+ lambda self, **kw: _FakeChat(),
+ )
+ monkeypatch.setattr(email_routes, "resolve_action_db", lambda: db)
+ return TestClient(export_openapi.build_app()), db
+
+
+def test_triage_endpoint_persists_linked_tasks(triage_env):
+ client, db = triage_env
+ resp = client.post("/v1/email/triage", json=_triage_payload())
+ assert resp.status_code == 200, resp.text
+
+ # Inline response unchanged (additive): action items still returned.
+ inline = resp.json()["result"]["action_items"]
+ assert inline, "expected the cue-bearing body to yield inline action items"
+
+ # ...and now also persisted, linked to the source message.
+ rows = task_store.list_tasks(db, message_id="msg-77")
+ assert [r["description"] for r in rows] == [i["description"] for i in inline]
+
+
+def test_retriage_endpoint_creates_no_duplicate_tasks(triage_env):
+ client, db = triage_env
+ assert client.post("/v1/email/triage", json=_triage_payload()).status_code == 200
+ count_after_first = len(task_store.list_tasks(db, message_id="msg-77"))
+ assert count_after_first > 0
+
+ assert client.post("/v1/email/triage", json=_triage_payload()).status_code == 200
+ assert len(task_store.list_tasks(db, message_id="msg-77")) == count_after_first
+
+
+def test_batch_triage_endpoint_persists_per_item_tasks(triage_env):
+ client, db = triage_env
+ item = _triage_payload("msg-batch-1")["payload"]
+ resp = client.post("/v1/email/triage/batch", json={"items": [item, item]})
+ assert resp.status_code == 200, resp.text
+
+ # Two batch items for the SAME message dedup into one set of tasks.
+ rows = task_store.list_tasks(db, message_id="msg-batch-1")
+ inline = resp.json()["results"][0]["result"]["action_items"]
+ assert [r["description"] for r in rows] == [i["description"] for i in inline]
+
+
+# ---------------------------------------------------------------------------
+# Persistence failures must not turn a successful triage into a 500
+# (bot review on #1917: sqlite3.IntegrityError from a concurrent duplicate
+# should not collapse the response; and /triage/batch's documented per-item
+# isolation must hold for persistence failures too, not just triage failures).
+# ---------------------------------------------------------------------------
+
+
+def test_single_triage_persistence_failure_does_not_500(triage_env, monkeypatch):
+ def boom(db, *, message_id, items):
+ raise sqlite3.OperationalError("database is locked")
+
+ monkeypatch.setattr(task_store, "record_action_items", boom)
+
+ client, _db = triage_env
+ resp = client.post("/v1/email/triage", json=_triage_payload("msg-boom"))
+
+ # Triage itself succeeded; persistence failing must not surface as a 500.
+ assert resp.status_code == 200, resp.text
+ assert resp.json()["result"]["action_items"]
+
+
+def test_batch_triage_persistence_failure_is_isolated_per_item(triage_env, monkeypatch):
+ """One item's persistence failure must not collapse the whole batch into
+ a 500, and must not flip that item's already-successful `result` into an
+ `error` — only the triage step, not the persistence side-effect, decides
+ result vs error for a batch item."""
+ real_record = task_store.record_action_items
+
+ def flaky_record(db, *, message_id, items):
+ if message_id == "msg-batch-boom":
+ raise sqlite3.OperationalError("database is locked")
+ return real_record(db, message_id=message_id, items=items)
+
+ monkeypatch.setattr(task_store, "record_action_items", flaky_record)
+
+ client, db = triage_env
+ item_ok = _triage_payload("msg-batch-ok")["payload"]
+ item_boom = _triage_payload("msg-batch-boom")["payload"]
+ resp = client.post(
+ "/v1/email/triage/batch", json={"items": [item_boom, item_ok]}
+ )
+
+ assert resp.status_code == 200, resp.text
+ results = resp.json()["results"]
+ # Both items still report a successful triage `result` (not `error`) —
+ # persistence failing for one is invisible to the triage contract.
+ assert results[0]["result"] is not None
+ assert results[0]["error"] is None
+ assert results[1]["result"] is not None
+ assert results[1]["error"] is None
+
+ # The item whose persistence failed has no task rows; the other does.
+ assert task_store.list_tasks(db, message_id="msg-batch-boom") == []
+ assert len(task_store.list_tasks(db, message_id="msg-batch-ok")) > 0
diff --git a/tests/test_api.py b/tests/test_api.py
index 9a53b4897..4f5eb5043 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1357,6 +1357,22 @@ def send_messages(self, messages, system_prompt="", **kwargs):
monkeypatch.setattr(
EmailTriageService, "_build_llm_chat", lambda self, **kw: _FakeChat()
)
+
+ # Triage persists action items to the task store (#1605); point it at
+ # an in-memory DB so tests never write to the real ~/.gaia.
+ from gaia_agent_email import api_routes as email_routes
+ from gaia_agent_email import task_store
+
+ from gaia.database.mixin import DatabaseMixin
+
+ class _TaskDB(DatabaseMixin):
+ pass
+
+ task_db = _TaskDB()
+ task_db.init_db(":memory:")
+ task_store.init_schema(task_db)
+ monkeypatch.setattr(email_routes, "resolve_action_db", lambda: task_db)
+
self.client = TestClient(app)
def test_single_email_in_structured_out(self):