diff --git a/docs/guides/email.mdx b/docs/guides/email.mdx index a4aeb7e44..254fe4322 100644 --- a/docs/guides/email.mdx +++ b/docs/guides/email.mdx @@ -143,6 +143,42 @@ Preferences are stored in process memory only — restarting the agent (or quitt Senders you reply to quickly are automatically promoted to priority on the next triage run — no explicit command needed. The agent measures how long it took you to reply to each sender (using the original message's receipt timestamp as the anchor) and promotes senders whose median reply latency falls below the threshold. Promotion is applied on-demand during triage, not on a background thread, and persists across agent restarts. Works across both connected mailboxes (Gmail and Outlook). +## Scheduled daily briefing (off by default) + +The same pre-scan can run on a schedule instead of only on demand — a morning +briefing that's ready before you ask. **It ships off by default**: nothing scans +your inbox until you explicitly enable the schedule. + +The email sidecar stores the schedule and serves the trigger; it does **not** run +a timer. A host scheduler — GAIA's autonomy engine once it lands, or any +cron-like runner today — fires the trigger at your configured time: + +```bash +# Enable once (the sidecar listens on 127.0.0.1:8131 by default): +curl -s -X PUT http://127.0.0.1:8131/v1/email/briefing/schedule \ + -H "Content-Type: application/json" \ + -d '{ "enabled": true, "time": "08:00", "max_messages": 25 }' + +# What the scheduler fires daily: +curl -s -X POST http://127.0.0.1:8131/v1/email/briefing/run +``` + +The run returns a `kind: "email_briefing"` envelope whose `pre_scan` field is the +same `email_pre_scan` card the Agent UI renders, and also persists the envelope to +`~/.gaia/email/briefing_latest.json` so a consumer can pick up the latest briefing +without having been the live caller (the interim delivery surface until push +delivery arrives with the autonomy engine). + +Two guarantees, both fail-loud: + +- **Disabled means disabled.** The trigger re-checks `enabled` itself — a disabled + schedule answers `409` before any mailbox access, so a stale or misfiring + scheduler can never scan a mailbox whose briefing you turned off. +- **A corrupt schedule file is an error, not a guess.** If + `~/.gaia/email/briefing.json` exists but can't be parsed, the endpoints return + `500` naming the file and the fix — the agent never silently falls back to + "disabled" (or worse, "enabled"). + ## Action surface ### Read @@ -286,9 +322,10 @@ isolated, and dogfoods the exact binary shipped to integrators. `GAIA_EMAIL_AGENT_MODE` only selects which *process* answers (`user` default / `dev`); there is no in-process fallback. Two surfaces run through it: -- **The `/v1/email/*` REST surface** — the full schema-2.1 contract (triage, - batch triage, search, inbox pre-scan, draft/send + confirm, archive/unarchive, - quarantine/unquarantine, calendar view/preview/create/respond, health, +- **The `/v1/email/*` REST surface** — the full schema-2.2 contract (triage, + batch triage, search, inbox pre-scan, the scheduled daily briefing, draft/send + + confirm, archive/unarchive, quarantine/unquarantine, calendar + view/preview/create/respond, health, version). This is exactly what third-party integrators consume, so the UI exercises the real product. The sidecar's connector OAuth **write** routes are never exposed (all grant writes stay on the backend's single-writer path). @@ -301,7 +338,7 @@ The sidecar is spawned lazily on first email use and tree-killed on shutdown; th REST surface and the chat agent share one sidecar process. **Chat tool surface (in-app email agent):** the sidecar-backed chat agent exposes -the tools the schema-2.1 REST contract serves today — inbox pre-scan, search, +the tools the REST contract serves today — inbox pre-scan, search, calendar view, and archive + undo. Tools that have no REST route yet (labels, stars, mark-read, move, trash/delete, summarize, profile, preferences, forward, send) are not exposed in the chat agent until their routes land; the underlying diff --git a/hub/agents/npm/agent-email/CHANGELOG.md b/hub/agents/npm/agent-email/CHANGELOG.md index e1efcb98b..2509e1dd3 100644 --- a/hub/agents/npm/agent-email/CHANGELOG.md +++ b/hub/agents/npm/agent-email/CHANGELOG.md @@ -5,6 +5,25 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire `SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR bump is always at least a package MINOR bump with a migration note. +## Unreleased + +Contract bumped to `SCHEMA_VERSION` **2.2** — additive, no existing shape change, +so `checkVersion` (MAJOR-only) keeps accepting 2.x clients. + +- **Scheduled daily inbox briefing** (#1608): the sidecar can now turn the + pre-scan into a scheduled morning briefing instead of only answering on + demand. New REST surface: `GET`/`PUT /v1/email/briefing/schedule` (persisted + schedule — **off by default**; enabling it is an explicit `PUT`) and + `POST /v1/email/briefing/run` (the trigger a host scheduler fires daily; the + sidecar stores the preference but runs no timer — that belongs to the host / + GAIA's autonomy engine #555). A disabled schedule makes the trigger a `409` + before any mailbox access, so a misfiring scheduler can never scan a mailbox + whose briefing is off. The run returns a `kind: "email_briefing"` envelope + whose `pre_scan` is byte-compatible with `prescan()`'s card, and atomically + persists it to `~/.gaia/email/briefing_latest.json` as the interim pull-based + delivery surface until push delivery lands with the autonomy engine. + REST-only for now — no typed client methods yet; drive it with `fetch`. + ## 0.3.0 Contract bumped to `SCHEMA_VERSION` **2.1** — additive, no triage shape change, so diff --git a/hub/agents/npm/agent-email/README.md b/hub/agents/npm/agent-email/README.md index 608a431d3..6f6efc215 100644 --- a/hub/agents/npm/agent-email/README.md +++ b/hub/agents/npm/agent-email/README.md @@ -1,6 +1,6 @@ # @amd-gaia/agent-email -[](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.1** · last updated **2026-06-26** +[](https://www.npmjs.com/package/@amd-gaia/agent-email) · contract `SCHEMA_VERSION` **2.2** · last updated **2026-07-01** **Eval scorecard (v0.3.0): aggregate 84.67 / 100** — within-one-bucket **acceptance** accuracy (3-run mean, 95% CI [83.4, 86.0]) over 100 of 220 labeled emails ([`./SCORECARD.md`](./SCORECARD.md)). Triage priority is ordinal, so the bar (#1437) credits exact-or-adjacent buckets — what users feel — not exact 4-way match (reported as a secondary, 0.46). The linked scorecard carries the full recipe, metrics + reported secondaries, run-to-run variance/CI, a per-category breakdown, the run environment, a worked recomputation, and reproduction steps. @@ -214,9 +214,13 @@ When you need finer control, the steps are exported individually: | `checkVersion(client)` | Throw if the sidecar's contract MAJOR differs from the client's. | | `shutdown(sidecar)` | Kill the whole process tree. | -As of `SCHEMA_VERSION` 2.1 this package exposes inbox **search** (read-only), -the **archive** / phishing-**quarantine** mailbox actions (+ their undo), and -calendar **view / create / respond**. The remaining mailbox **actions** (label, +As of `SCHEMA_VERSION` 2.2 this package exposes inbox **search** (read-only), +the **archive** / phishing-**quarantine** mailbox actions (+ their undo), +calendar **view / create / respond**, and a **scheduled daily briefing** (#1608): +an off-by-default schedule plus a `POST /v1/email/briefing/run` trigger that +turns the pre-scan into a morning briefing — REST-only for now (no typed client +methods yet; drive it with `fetch`), with the timer owned by your app or GAIA's +autonomy engine, not the sidecar. The remaining mailbox **actions** (label, move, mark read/unread) are part of the full agent but not yet exposed through this package — see [`SPEC.md`](https://github.com/amd/gaia/blob/main/hub/agents/npm/agent-email/SPEC.md) for the complete surface. diff --git a/hub/agents/npm/agent-email/SKILL.md b/hub/agents/npm/agent-email/SKILL.md index ac43ad3dc..8e8b89de1 100644 --- a/hub/agents/npm/agent-email/SKILL.md +++ b/hub/agents/npm/agent-email/SKILL.md @@ -124,6 +124,28 @@ reversible inside a 30s window via the ungated `unarchive` / `unquarantine`. Eve non-2xx response throws `HttpError` (`status`, `url`, `bodyText`) — handle it; there is no silent null. +**Scheduled daily briefing (schema 2.2, REST-only).** The sidecar can turn the +pre-scan into a scheduled morning briefing (#1608), but it does **not** run a +timer — your app (or GAIA's autonomy engine, once it lands) owns the schedule and +fires the trigger. No typed client methods yet; use `fetch` against the sidecar: + +```ts +// Enable once (ships OFF by default): +await fetch(`${base}/v1/email/briefing/schedule`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: true, time: "08:00", max_messages: 25 }), +}); +// What your scheduler fires daily — returns { result: { kind: "email_briefing", +// pre_scan: { kind: "email_pre_scan", … } } }; renders with the same card as prescan(). +const briefing = await fetch(`${base}/v1/email/briefing/run`, { method: "POST" }); +``` + +A disabled schedule makes the trigger a `409` **before any mailbox access** — a +misfiring scheduler can never scan a mailbox whose briefing is off. Each run also +persists the envelope to `~/.gaia/email/briefing_latest.json` for pull-based +consumers. + ## 5. From a renderer (Electron / browser) The sidecar serves **same-origin only — no CORS**. A renderer on a different origin diff --git a/hub/agents/npm/agent-email/SPEC.md b/hub/agents/npm/agent-email/SPEC.md index 6307a7fab..71c3ce932 100644 --- a/hub/agents/npm/agent-email/SPEC.md +++ b/hub/agents/npm/agent-email/SPEC.md @@ -2,7 +2,7 @@ Detailed reference for `@amd-gaia/agent-email`. For a quick start, see [`README.md`](./README.md); for an AI-assisted integration walkthrough, see -[`SKILL.md`](./SKILL.md). The contract version is `SCHEMA_VERSION` **2.1**. +[`SKILL.md`](./SKILL.md). The contract version is `SCHEMA_VERSION` **2.2**. ## Architecture @@ -53,6 +53,9 @@ result. | `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. | +| `GET /v1/email/briefing/schedule` | — (REST only, schema 2.2) | **Standalone** | Reads the persisted daily-briefing schedule (#1608). Ships **off by default**; an absent config reports as disabled, a corrupt config file is a `500` naming the file and the fix. | +| `PUT /v1/email/briefing/schedule` | — (REST only, schema 2.2) | **Standalone** | Replaces the schedule (full-document, contract-validated — bad `time`/`max_messages` → `422` before anything persists). The only way to turn the briefing on. | +| `POST /v1/email/briefing/run` | — (REST only, schema 2.2) | **Connector** | The scheduled briefing trigger. No body — the persisted schedule is the config. Disabled → `409` **before any mailbox access**; enabled → runs the pre-scan path, returns the `kind: "email_briefing"` envelope, and persists it to `~/.gaia/email/briefing_latest.json`. | | `POST /v1/email/draft` | `draft()` | **Standalone** | Nothing external — wraps your `(to, subject, body)` and returns a single-use confirmation token. | | `POST /v1/email/send` | `send()` | **Connector** | A valid `draft` confirmation token **and** a connected Google/Microsoft mailbox. The token gate fires first: no/invalid token → `403`; then `503` if no mailbox is connected, `400` if 2+ are. | | `POST /v1/email/confirm` | `confirmAction()` | **Standalone** | Nothing external — mints a single-use token for `"archive"`/`"quarantine"`, bound to that exact `(action, message_id)`. | @@ -109,6 +112,37 @@ const q = await client.quarantine({ await client.unquarantine({ action_id: q.action_id }); ``` +### Scheduled daily briefing (schema 2.2, #1608) + +The daily briefing turns the pre-scan into a scheduled morning summary. The +sidecar stores the preference and serves the trigger — it does **not** run a +timer. A host scheduler (GAIA's autonomy engine once it lands — #555 — or any +cron-like runner in your app) fires `POST /v1/email/briefing/run` at the +configured `time`: + +```bash +# Enable once (ships off by default): +curl -s -X PUT http://127.0.0.1:8131/v1/email/briefing/schedule \ + -H "Content-Type: application/json" \ + -d '{ "enabled": true, "time": "08:00", "max_messages": 25 }' + +# What the scheduler fires daily: +curl -s -X POST http://127.0.0.1:8131/v1/email/briefing/run +# → { "schema_version": "2.2", +# "result": { "kind": "email_briefing", "generated_at": "…", +# "schedule": { … }, "pre_scan": { "kind": "email_pre_scan", … } } } +``` + +The trigger re-checks `enabled` itself: a disabled schedule is a `409` **before +any mailbox access**, so a stale or misfiring scheduler can never scan a mailbox +whose briefing the user turned off. `result.pre_scan` is byte-compatible with +`prescan()`'s envelope, so a consumer that renders the pre-scan card renders the +briefing. Each run also atomically persists the envelope to +`~/.gaia/email/briefing_latest.json` — the interim pull-based delivery surface +until push delivery lands with the autonomy engine. These endpoints are +REST-only for now (no typed client methods yet); drive them with `fetch` or any +HTTP client. + ### Calendar (view / create / respond, schema 2.1) > **Confirmation gating — deliberate asymmetry.** `send` and calendar **create** @@ -276,12 +310,13 @@ connection through this package's API**, so connector-backed calls only work on machine where the mailbox is already connected in GAIA. Triage and draft, which need no connector, work anywhere. -As of `SCHEMA_VERSION` 2.1 this package's REST API exposes the read-only inbox +As of `SCHEMA_VERSION` 2.2 this package's REST API exposes the read-only inbox **search** and **pre-scan** (`search` / `prescan`), the **archive** and phishing-**quarantine** mailbox actions plus their undo (`confirmAction` / `archive` / -`unarchive` / `quarantine` / `unquarantine`), and calendar **view / create / respond** +`unarchive` / `quarantine` / `unquarantine`), calendar **view / create / respond** (`listCalendarEvents` / `previewCalendarEvent` / `createCalendarEvent` / -`respondToCalendarEvent`). The full GAIA email agent does more on the live mailbox +`respondToCalendarEvent`), and the **scheduled daily briefing** (REST-only, +`/v1/email/briefing/*`, #1608). The full GAIA email agent does more on the live mailbox (label, move, mark spam) and calendar (detect / conflicts); those remaining actions are connector-gated by definition and are **not exposed through this package's REST API yet**. @@ -328,9 +363,10 @@ editors autocomplete but your code never imports them. TypeScript types in `src/types.ts` mirror two Python sources of truth: -- `contract.py` — the triage request/response contract plus the schema-2.1 additions: - inbox search, mailbox actions (archive / quarantine + reversal), calendar, and - pre-scan (`SCHEMA_VERSION = "2.1"`). +- `contract.py` — the triage request/response contract plus the schema-2.1 additions + (inbox search, mailbox actions — archive / quarantine + reversal — calendar, and + pre-scan) and the schema-2.2 daily-briefing models (`SCHEMA_VERSION = "2.2"`). + The briefing endpoints are REST-only for now — no TS types/methods yet. - `api_routes.py` — the local draft/send confirmation handshake models. They are hand-written (vs. generated from `/openapi.json`) because the contract is diff --git a/hub/agents/npm/agent-email/src/lifecycle.ts b/hub/agents/npm/agent-email/src/lifecycle.ts index c59225bb9..e0e583662 100644 --- a/hub/agents/npm/agent-email/src/lifecycle.ts +++ b/hub/agents/npm/agent-email/src/lifecycle.ts @@ -287,7 +287,7 @@ function majorOf(version: string): number { } export interface VersionCheckOptions { - /** The apiVersion the client was built against. Default SCHEMA_VERSION ("2.1"). */ + /** The apiVersion the client was built against. Default SCHEMA_VERSION ("2.2"). */ expectedApiVersion?: string; } diff --git a/hub/agents/npm/agent-email/src/types.ts b/hub/agents/npm/agent-email/src/types.ts index 97dd263de..3e6cfe8d4 100644 --- a/hub/agents/npm/agent-email/src/types.ts +++ b/hub/agents/npm/agent-email/src/types.ts @@ -27,10 +27,13 @@ * confirm-token handshake (#1779) * - calendar view/create/respond (#1780) * - inbox pre-scan (POST /v1/email/prescan, #1778) + * Schema 2.2 (additive) adds the scheduled daily-briefing surface (#1608): + * GET/PUT /v1/email/briefing/schedule and POST /v1/email/briefing/run — + * REST-only for now; no typed client methods/types yet. */ /** Frozen contract version echoed by the server's `/version` endpoint. */ -export const SCHEMA_VERSION = "2.1" as const; +export const SCHEMA_VERSION = "2.2" as const; /** * The five-bucket triage taxonomy (schema 2.0 — contract.py: EmailCategory). diff --git a/hub/agents/npm/agent-email/test/browser-entry.test.ts b/hub/agents/npm/agent-email/test/browser-entry.test.ts index bb7777ce0..f9bf64251 100644 --- a/hub/agents/npm/agent-email/test/browser-entry.test.ts +++ b/hub/agents/npm/agent-email/test/browser-entry.test.ts @@ -58,7 +58,7 @@ describe("browser entry (./client)", () => { it("client-entry exports SCHEMA_VERSION and request/response types (runtime const)", async () => { const mod = await import("../src/client-entry.js"); - expect(mod.SCHEMA_VERSION).toBe("2.1"); + expect(mod.SCHEMA_VERSION).toBe("2.2"); }); it("client-entry does NOT export spawnSidecar (Node-only)", async () => { diff --git a/hub/agents/npm/agent-email/test/version-check.test.ts b/hub/agents/npm/agent-email/test/version-check.test.ts index 163720196..596fc58c7 100644 --- a/hub/agents/npm/agent-email/test/version-check.test.ts +++ b/hub/agents/npm/agent-email/test/version-check.test.ts @@ -18,9 +18,9 @@ function clientReturning(apiVersion: string, agentVersion = "0.2.0"): EmailClien } describe("checkVersion", () => { - it("accepts apiVersion 2.0 against the current 2.1 default (same MAJOR 2)", async () => { + it("accepts apiVersion 2.0 against the current 2.2 default (same MAJOR 2)", async () => { // checkVersion enforces MAJOR only, so a 2.0 sidecar stays compatible with - // this client's default-expected SCHEMA_VERSION (now 2.1). + // this client's default-expected SCHEMA_VERSION (now 2.2). const info = await checkVersion(clientReturning("2.0")); expect(info.apiVersion).toBe("2.0"); }); 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 321cf9f0d..33f53a416 100644 --- a/hub/agents/python/email/gaia_agent_email/api_routes.py +++ b/hub/agents/python/email/gaia_agent_email/api_routes.py @@ -53,12 +53,20 @@ from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import HTMLResponse +from gaia_agent_email.briefing import ( + BriefingConfigError, + load_schedule, + run_scheduled_briefing, + save_schedule, +) from gaia_agent_email.contract import ( ActionItem, BatchItemError, BatchItemResult, BatchTriageRequest, BatchTriageResponse, + BriefingSchedule, + BriefingScheduleResponse, CalendarCreateEventRequest, CalendarEvent, CalendarEventDateTime, @@ -73,6 +81,8 @@ EmailAddress, EmailArchiveRequest, EmailArchiveResponse, + EmailBriefingResponse, + EmailBriefingResult, EmailCategory, EmailMessage, EmailPreScanRequest, @@ -1534,6 +1544,96 @@ async def prescan_inbox( return EmailPreScanResponse(result=EmailPreScanResult.model_validate(out)) +@router.get("/briefing/schedule", response_model=BriefingScheduleResponse) +async def get_briefing_schedule() -> BriefingScheduleResponse: + """Read the persisted daily-briefing schedule (#1608). + + An absent config file is the documented default — disabled. A present + but corrupt file is a 500 with the fix (never silently reported as + "disabled", which would hide a schedule the user thinks is on). + """ + try: + schedule = load_schedule() + except BriefingConfigError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + return BriefingScheduleResponse(schedule=schedule) + + +@router.put("/briefing/schedule", response_model=BriefingScheduleResponse) +async def put_briefing_schedule(schedule: BriefingSchedule) -> BriefingScheduleResponse: + """Replace the persisted daily-briefing schedule (#1608). + + Full-document replace: the body is the complete schedule, validated by + the contract model (bad ``time``/``max_messages`` → 422 before anything + persists). This is how a host turns the briefing on — it ships OFF. + """ + try: + path = save_schedule(schedule) + except OSError as e: + raise HTTPException( + status_code=500, + detail=( + f"Could not persist the briefing schedule: {e}. Check that " + "~/.gaia/email/ is writable, then retry." + ), + ) from e + logger.info("briefing schedule replaced at %s (enabled=%s)", path, schedule.enabled) + return BriefingScheduleResponse(schedule=schedule) + + +@router.post("/briefing/run", response_model=EmailBriefingResponse) +async def run_briefing() -> EmailBriefingResponse: + """The scheduled daily-briefing trigger (#1608). + + A host scheduler (autonomy engine #555, cron, the GAIA UI scheduler) + calls this on its tick; no request body — the persisted schedule is the + configuration. The disabled check runs FIRST (409, actionable) so a + disabled schedule is a clean no-op that never resolves a mailbox + backend, let alone reads mail. When enabled, the run reuses the + pre-scan classification path via ``run_scheduled_briefing`` and both + returns the briefing envelope and persists it as the latest briefing + (the interim pull-based delivery surface until push delivery lands + with the autonomy engine). + """ + try: + schedule = load_schedule() + except BriefingConfigError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + if not schedule.enabled: + raise HTTPException( + status_code=409, + detail=( + "The daily briefing schedule is disabled (it ships off by " + "default). Enable it first via PUT /v1/email/briefing/schedule " + 'with {"enabled": true, "time": "HH:MM"}.' + ), + ) + backend = get_prescan_backend() + try: + out = await asyncio.to_thread( + run_scheduled_briefing, backend, schedule=schedule + ) + except ( + AuthRequiredError, + ScopeMismatchError, + ConnectionRevokedError, + ) as e: + raise HTTPException(status_code=403, detail=str(e)) from e + except ConfigurationError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except ConnectorsError as e: + raise HTTPException(status_code=502, detail=str(e)) from e + except OSError as e: + raise HTTPException( + status_code=500, + detail=( + f"Briefing ran but could not persist its envelope: {e}. Check " + "that ~/.gaia/email/ is writable, then retry." + ), + ) from e + return EmailBriefingResponse(result=EmailBriefingResult.model_validate(out)) + + @router.post("/draft", response_model=EmailDraftResponse) async def draft_reply(request: EmailDraftRequest) -> EmailDraftResponse: """Propose a reply and mint a confirmation token bound to its payload. diff --git a/hub/agents/python/email/gaia_agent_email/briefing.py b/hub/agents/python/email/gaia_agent_email/briefing.py new file mode 100644 index 000000000..75b4df124 --- /dev/null +++ b/hub/agents/python/email/gaia_agent_email/briefing.py @@ -0,0 +1,157 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Scheduled daily inbox briefing (#1608). + +The briefing CONTENT is the existing pre-scan envelope — this module adds +the SCHEDULED-JOB seam around ``pre_scan_inbox_impl``, not a new scan: + +- :func:`load_schedule` / :func:`save_schedule` persist the user's + :class:`~gaia_agent_email.contract.BriefingSchedule` (OFF by default) at + ``~/.gaia/email/briefing.json``. +- :func:`run_scheduled_briefing` is the trigger a host scheduler invokes. + It re-checks ``enabled`` itself, so a disabled schedule never touches the + mailbox regardless of who fires the trigger. + +The sidecar does NOT run a timer. Scheduling is owned by the host — the +autonomy engine (#555) once it lands, or any cron-like runner hitting +``POST /v1/email/briefing/run`` in the meantime. Push delivery is likewise +the host's job; until it exists, each run atomically persists its envelope +to ``~/.gaia/email/briefing_latest.json`` so a consumer can pull the most +recent briefing without having been the live caller. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from pydantic import ValidationError + +from gaia_agent_email.contract import BriefingSchedule + +from gaia.logger import get_logger + +log = get_logger(__name__) + + +class BriefingConfigError(ValueError): + """Raised when the persisted briefing schedule is unreadable or invalid.""" + + +def briefing_schedule_path() -> Path: + """Where the briefing schedule persists. + + ``Path.home()`` resolves at call time so test HOME/tmp isolation is + honored (same rationale as ``EmailAgentConfig.resolved_db_path``). + """ + return Path.home() / ".gaia" / "email" / "briefing.json" + + +def latest_briefing_path() -> Path: + """Where the most recent briefing envelope persists.""" + return Path.home() / ".gaia" / "email" / "briefing_latest.json" + + +def load_schedule(path: Optional[Path] = None) -> BriefingSchedule: + """Load the persisted schedule; an absent file is the documented default + (disabled — #1608 requires off-by-default). + + A file that exists but cannot be parsed or validated raises + :class:`BriefingConfigError` — never a silent fall-back to defaults, + which would quietly disable a briefing the user enabled (or worse, + re-enable one they disabled by hand-editing the file badly). + """ + p = Path(path) if path is not None else briefing_schedule_path() + if not p.exists(): + return BriefingSchedule() + try: + raw = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + raise BriefingConfigError( + f"Briefing schedule at {p} is unreadable: {e}. Fix or delete the " + "file, or overwrite it via PUT /v1/email/briefing/schedule." + ) from e + try: + return BriefingSchedule.model_validate(raw) + except ValidationError as e: + raise BriefingConfigError( + f"Briefing schedule at {p} is invalid: {e}. Expected " + '{"enabled": bool, "time": "HH:MM", "max_messages": 1-100}. ' + "Overwrite it via PUT /v1/email/briefing/schedule." + ) from e + + +def save_schedule( + schedule: BriefingSchedule, path: Optional[Path] = None +) -> Path: + """Persist ``schedule`` atomically (write-then-rename) and return the path.""" + p = Path(path) if path is not None else briefing_schedule_path() + p.parent.mkdir(parents=True, exist_ok=True) + tmp = p.with_name(p.name + ".tmp") + tmp.write_text(schedule.model_dump_json(indent=2), encoding="utf-8") + tmp.replace(p) + return p + + +def run_scheduled_briefing( + backend: Any, + *, + schedule: Optional[BriefingSchedule] = None, + schedule_path: Optional[Path] = None, + latest_path: Optional[Path] = None, + now: Optional[datetime] = None, +) -> Optional[Dict[str, Any]]: + """The scheduled-job trigger: pre-scan the inbox into a briefing envelope. + + This is the seam a host scheduler (autonomy engine #555, cron, the REST + trigger) invokes on its tick — no user prompt involved. When the schedule + is disabled (the default) it returns ``None`` WITHOUT touching the + mailbox: that is the documented contract of an off switch, logged so the + skip is observable, not a silent degradation. + + When enabled, the briefing reuses ``pre_scan_inbox_impl`` — the exact + classification path behind the agent-loop tool and ``POST + /v1/email/prescan`` — wraps it with run metadata (``kind: + "email_briefing"``), atomically persists it to :func:`latest_briefing_path` + as the interim pull-based delivery surface, and returns it. Scan and + write failures propagate to the caller. + """ + sched = schedule if schedule is not None else load_schedule(schedule_path) + if not sched.enabled: + log.info( + "daily briefing schedule is disabled — skipping (enable via " + "PUT /v1/email/briefing/schedule)" + ) + return None + + from gaia_agent_email.tools.read_tools import pre_scan_inbox_impl + + pre_scan = pre_scan_inbox_impl(backend, max_messages=sched.max_messages) + stamp = now if now is not None else datetime.now(timezone.utc) + envelope: Dict[str, Any] = { + "kind": "email_briefing", + "generated_at": stamp.isoformat(), + "schedule": sched.model_dump(), + "pre_scan": pre_scan, + } + + lp = Path(latest_path) if latest_path is not None else latest_briefing_path() + lp.parent.mkdir(parents=True, exist_ok=True) + tmp = lp.with_name(lp.name + ".tmp") + tmp.write_text(json.dumps(envelope, indent=2), encoding="utf-8") + tmp.replace(lp) + log.info("daily briefing generated and persisted to %s", lp) + return envelope + + +__all__ = [ + "BriefingConfigError", + "briefing_schedule_path", + "latest_briefing_path", + "load_schedule", + "save_schedule", + "run_scheduled_briefing", +] diff --git a/hub/agents/python/email/gaia_agent_email/contract.py b/hub/agents/python/email/gaia_agent_email/contract.py index 57874c53b..13eff62f5 100644 --- a/hub/agents/python/email/gaia_agent_email/contract.py +++ b/hub/agents/python/email/gaia_agent_email/contract.py @@ -63,7 +63,10 @@ # - archive + phishing-quarantine mailbox actions and their reversal (#1779) # - calendar view/create/respond (#1780) # - inbox pre-scan (#1778) -SCHEMA_VERSION = "2.1" +# 2.2 is additive over 2.1: the scheduled daily-briefing surface (#1608) — +# GET/PUT /v1/email/briefing/schedule and the POST /v1/email/briefing/run +# trigger. No existing shape changes, so 2.x consumers keep working. +SCHEMA_VERSION = "2.2" # Maximum number of items in a single batch request. Protects the single-tenant # local model slot from runaway batches. Enforced via Pydantic max_length. @@ -1177,6 +1180,90 @@ class EmailPreScanResponse(_Strict): result: EmailPreScanResult = Field(..., description="The pre-scan envelope.") +# --------------------------------------------------------------------------- +# Scheduled daily briefing (schema 2.2, #1608) +# --------------------------------------------------------------------------- + + +class BriefingSchedule(_Strict): + """The persisted daily-briefing schedule. OFF by default. + + The sidecar does not run a timer — ``time`` is the preference a HOST + scheduler (autonomy engine #555, OS cron, the GAIA UI scheduler) reads to + decide when to fire ``POST /v1/email/briefing/run``. The trigger itself + re-checks ``enabled``, so a stale or misconfigured scheduler can never + scan a mailbox whose briefing the user turned off. + """ + + enabled: bool = Field( + default=False, + description=( + "Master switch. False (the default) means the scheduled trigger " + "produces no briefing and never touches the mailbox." + ), + ) + time: str = Field( + default="08:00", + pattern=r"^([01]\d|2[0-3]):[0-5]\d$", + description=( + "Local wall-clock HH:MM the host scheduler should fire the " + "briefing trigger at, once per day." + ), + ) + max_messages: int = Field( + default=25, + ge=1, + le=100, + description=( + "How many recent inbox messages the briefing pre-scan reads. " + "Bounded like EmailPreScanRequest.max_messages." + ), + ) + + +class BriefingScheduleResponse(_Strict): + """Response envelope for the briefing-schedule read/replace endpoints.""" + + schema_version: str = Field( + default=SCHEMA_VERSION, description="Echoes the contract version." + ) + schedule: BriefingSchedule = Field( + ..., description="The currently persisted schedule." + ) + + +class EmailBriefingResult(_Strict): + """One scheduled briefing: the pre-scan envelope plus run metadata. + + ``pre_scan`` is byte-compatible with what ``POST /v1/email/prescan`` + returns (``kind == "email_pre_scan"``) — same classification path, so a + consumer that already renders the pre-scan card renders the briefing. + """ + + kind: Literal["email_briefing"] = Field( + default="email_briefing", + description="Envelope discriminator for the briefing card.", + ) + generated_at: str = Field( + ..., description="UTC ISO-8601 timestamp of when the briefing ran." + ) + schedule: BriefingSchedule = Field( + ..., description="The schedule that produced this briefing." + ) + pre_scan: EmailPreScanResult = Field( + ..., description="The inbox pre-scan envelope (kind: email_pre_scan)." + ) + + +class EmailBriefingResponse(_Strict): + """Top-level briefing-trigger response envelope (#1608).""" + + schema_version: str = Field( + default=SCHEMA_VERSION, description="Echoes the contract version." + ) + result: EmailBriefingResult = Field(..., description="The briefing envelope.") + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -1228,6 +1315,11 @@ def parse_response(data: dict) -> EmailTriageResponse: "EmailPreScanRequest", "EmailPreScanResult", "EmailPreScanResponse", + # Scheduled daily briefing (schema 2.2, #1608) + "BriefingSchedule", + "BriefingScheduleResponse", + "EmailBriefingResult", + "EmailBriefingResponse", "EmailSearchRequest", "EmailSearchResultItem", "EmailSearchResponse", 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 18a7f69b6..7918ddd5f 100644 --- a/hub/agents/python/email/gaia_agent_email/spec_html.py +++ b/hub/agents/python/email/gaia_agent_email/spec_html.py @@ -35,6 +35,8 @@ BatchItemResult, BatchTriageRequest, BatchTriageResponse, + BriefingSchedule, + BriefingScheduleResponse, CalendarCreateEventRequest, CalendarEvent, CalendarEventDateTime, @@ -49,6 +51,8 @@ EmailAddress, EmailArchiveRequest, EmailArchiveResponse, + EmailBriefingResponse, + EmailBriefingResult, EmailCategory, EmailMessage, EmailPreScanRequest, @@ -616,6 +620,56 @@ def render_endpoint_spec_html() -> str: response_sections=[("CalendarRespondResponse", CalendarRespondResponse)], ) + briefing_get_block = _endpoint_block( + path="/v1/email/briefing/schedule", + method="GET", + description=( + "Read the persisted daily-briefing schedule (#1608). Ships OFF by " + "default — an absent config is reported as disabled. A corrupt " + "config file is a 500 naming the file and the fix, never silently " + "reported as disabled." + ), + request_sections=[], + response_sections=[ + ("BriefingScheduleResponse", BriefingScheduleResponse), + ("BriefingSchedule", BriefingSchedule), + ], + ) + + briefing_put_block = _endpoint_block( + path="/v1/email/briefing/schedule", + method="PUT", + description=( + "Replace the persisted daily-briefing schedule (#1608) — the only " + "way to turn the briefing on. Full-document replace, validated by " + "the contract model (bad time/max_messages is a 422 before " + "anything persists). The sidecar stores the preference; the HOST " + "scheduler (autonomy engine #555, cron, the GAIA UI scheduler) " + "owns the timer and fires the /run trigger at the configured time." + ), + request_sections=[("BriefingSchedule", BriefingSchedule)], + response_sections=[("BriefingScheduleResponse", BriefingScheduleResponse)], + ) + + briefing_run_block = _endpoint_block( + path="/v1/email/briefing/run", + description=( + "The scheduled daily-briefing trigger (#1608). No request body — " + "the persisted schedule is the configuration. Disabled schedule → " + "409 before any mailbox access (a disabled briefing never reads " + "mail). Enabled → runs the same pre-scan classification path as " + "/v1/email/prescan, returns the briefing envelope, and persists it " + "to ~/.gaia/email/briefing_latest.json as the interim pull-based " + "delivery surface until push delivery lands with the autonomy " + "engine." + ), + request_sections=[], + response_sections=[ + ("EmailBriefingResponse", EmailBriefingResponse), + ("EmailBriefingResult", EmailBriefingResult), + ], + ) + body = f"""
@@ -645,6 +699,20 @@ def render_endpoint_spec_html() -> str: {prescan_block} ++ A host scheduler turns the pre-scan into a scheduled morning briefing (#1608). + The schedule ships OFF by default; the sidecar stores the preference and + serves the trigger — the timer and push delivery belong to the host + (autonomy engine #555). +
+ +{briefing_get_block} + +{briefing_put_block} + +{briefing_run_block} + {search_block} {draft_block} diff --git a/hub/agents/python/email/openapi.email.json b/hub/agents/python/email/openapi.email.json index 073f82f74..eefaebbc1 100644 --- a/hub/agents/python/email/openapi.email.json +++ b/hub/agents/python/email/openapi.email.json @@ -146,7 +146,7 @@ "type": "array" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -171,7 +171,7 @@ "type": "array" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -183,6 +183,56 @@ "title": "BatchTriageResponse", "type": "object" }, + "BriefingSchedule": { + "additionalProperties": false, + "description": "The persisted daily-briefing schedule. OFF by default.\n\nThe sidecar does not run a timer — ``time`` is the preference a HOST\nscheduler (autonomy engine #555, OS cron, the GAIA UI scheduler) reads to\ndecide when to fire ``POST /v1/email/briefing/run``. The trigger itself\nre-checks ``enabled``, so a stale or misconfigured scheduler can never\nscan a mailbox whose briefing the user turned off.", + "properties": { + "enabled": { + "default": false, + "description": "Master switch. False (the default) means the scheduled trigger produces no briefing and never touches the mailbox.", + "title": "Enabled", + "type": "boolean" + }, + "max_messages": { + "default": 25, + "description": "How many recent inbox messages the briefing pre-scan reads. Bounded like EmailPreScanRequest.max_messages.", + "maximum": 100.0, + "minimum": 1.0, + "title": "Max Messages", + "type": "integer" + }, + "time": { + "default": "08:00", + "description": "Local wall-clock HH:MM the host scheduler should fire the briefing trigger at, once per day.", + "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$", + "title": "Time", + "type": "string" + } + }, + "title": "BriefingSchedule", + "type": "object" + }, + "BriefingScheduleResponse": { + "additionalProperties": false, + "description": "Response envelope for the briefing-schedule read/replace endpoints.", + "properties": { + "schedule": { + "$ref": "#/components/schemas/BriefingSchedule", + "description": "The currently persisted schedule." + }, + "schema_version": { + "default": "2.2", + "description": "Echoes the contract version.", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "schedule" + ], + "title": "BriefingScheduleResponse", + "type": "object" + }, "CalendarCreateEventRequest": { "additionalProperties": false, "description": "Create a calendar event. Shared by the preview (token-mint) and create\n(token-consume) endpoints.\n\nCreating an event is a Tier-2 (externally visible) mutation, so it is gated\nby the same single-use confirmation-token handshake as ``/v1/email/send``:\nPOST this to ``/calendar/events/preview`` to mint a token bound to the exact\nevent, then echo the token in ``confirmation_token`` to ``/calendar/events``.", @@ -248,7 +298,7 @@ "title": "Provider" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -435,7 +485,7 @@ "title": "Location" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -475,7 +525,7 @@ "type": "string" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -506,7 +556,7 @@ "type": "array" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -542,7 +592,7 @@ "title": "Provider" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -582,7 +632,7 @@ "type": "boolean" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -699,7 +749,7 @@ "type": "string" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -812,7 +862,7 @@ "type": "string" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -833,6 +883,60 @@ "title": "EmailArchiveResponse", "type": "object" }, + "EmailBriefingResponse": { + "additionalProperties": false, + "description": "Top-level briefing-trigger response envelope (#1608).", + "properties": { + "result": { + "$ref": "#/components/schemas/EmailBriefingResult", + "description": "The briefing envelope." + }, + "schema_version": { + "default": "2.2", + "description": "Echoes the contract version.", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "result" + ], + "title": "EmailBriefingResponse", + "type": "object" + }, + "EmailBriefingResult": { + "additionalProperties": false, + "description": "One scheduled briefing: the pre-scan envelope plus run metadata.\n\n``pre_scan`` is byte-compatible with what ``POST /v1/email/prescan``\nreturns (``kind == \"email_pre_scan\"``) — same classification path, so a\nconsumer that already renders the pre-scan card renders the briefing.", + "properties": { + "generated_at": { + "description": "UTC ISO-8601 timestamp of when the briefing ran.", + "title": "Generated At", + "type": "string" + }, + "kind": { + "const": "email_briefing", + "default": "email_briefing", + "description": "Envelope discriminator for the briefing card.", + "title": "Kind", + "type": "string" + }, + "pre_scan": { + "$ref": "#/components/schemas/EmailPreScanResult", + "description": "The inbox pre-scan envelope (kind: email_pre_scan)." + }, + "schedule": { + "$ref": "#/components/schemas/BriefingSchedule", + "description": "The schedule that produced this briefing." + } + }, + "required": [ + "generated_at", + "schedule", + "pre_scan" + ], + "title": "EmailBriefingResult", + "type": "object" + }, "EmailCategory": { "description": "The five-bucket triage taxonomy (schema 2.0, #1615). Values MUST match\n``triage_heuristics.ALL_CATEGORIES`` — the contract tests assert this.", "enum": [ @@ -1004,7 +1108,7 @@ "type": "integer" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -1022,7 +1126,7 @@ "description": "The pre-scan envelope." }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -1187,7 +1291,7 @@ "type": "boolean" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -1259,7 +1363,7 @@ "title": "Query" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -1310,7 +1414,7 @@ "title": "Query" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -1515,7 +1619,7 @@ "title": "Payload" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Contract version. Mismatch lets a consumer fail loudly.", "title": "Schema Version", "type": "string" @@ -1545,7 +1649,7 @@ "description": "The structured analysis." }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -1703,7 +1807,7 @@ "type": "integer" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -1771,7 +1875,7 @@ "type": "boolean" }, "schema_version": { - "default": "2.1", + "default": "2.2", "description": "Echoes the contract version.", "title": "Schema Version", "type": "string" @@ -2201,7 +2305,7 @@ "info": { "description": "REST surface for the GAIA Email Triage agent (/v1/email/*). Cross-implementation contract for the npm client and native builds.", "title": "GAIA Email Agent API", - "version": "2.1" + "version": "2.2" }, "openapi": "3.1.0", "paths": { @@ -2247,6 +2351,90 @@ ] } }, + "/v1/email/briefing/run": { + "post": { + "description": "The scheduled daily-briefing trigger (#1608).\n\nA host scheduler (autonomy engine #555, cron, the GAIA UI scheduler)\ncalls this on its tick; no request body — the persisted schedule is the\nconfiguration. The disabled check runs FIRST (409, actionable) so a\ndisabled schedule is a clean no-op that never resolves a mailbox\nbackend, let alone reads mail. When enabled, the run reuses the\npre-scan classification path via ``run_scheduled_briefing`` and both\nreturns the briefing envelope and persists it as the latest briefing\n(the interim pull-based delivery surface until push delivery lands\nwith the autonomy engine).", + "operationId": "run_briefing_v1_email_briefing_run_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailBriefingResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Run Briefing", + "tags": [ + "email" + ] + } + }, + "/v1/email/briefing/schedule": { + "get": { + "description": "Read the persisted daily-briefing schedule (#1608).\n\nAn absent config file is the documented default — disabled. A present\nbut corrupt file is a 500 with the fix (never silently reported as\n\"disabled\", which would hide a schedule the user thinks is on).", + "operationId": "get_briefing_schedule_v1_email_briefing_schedule_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BriefingScheduleResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Briefing Schedule", + "tags": [ + "email" + ] + }, + "put": { + "description": "Replace the persisted daily-briefing schedule (#1608).\n\nFull-document replace: the body is the complete schedule, validated by\nthe contract model (bad ``time``/``max_messages`` → 422 before anything\npersists). This is how a host turns the briefing on — it ships OFF.", + "operationId": "put_briefing_schedule_v1_email_briefing_schedule_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BriefingSchedule" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BriefingScheduleResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Put Briefing Schedule", + "tags": [ + "email" + ] + } + }, "/v1/email/calendar/events": { "get": { "description": "View calendar events on the primary calendar (read-only).\n\n``time_min`` / ``time_max`` are optional RFC 3339 bounds. ``provider``\n(google|microsoft) is required only when more than one account is connected;\nwith exactly one, it is inferred. Reaches whichever calendar the user\nconnected. Fails loudly (403 + reconnect CTA) if the calendar scope is missing.", diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index a4a078cca..0bd4d75fe 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -3,7 +3,7 @@ -