Skip to content

Repository files navigation

email-assistant

An agentic email assistant built on LangChain 1.0 create_agent(). A supervisor agent orchestrates specialist worker agents to triage an inbox, summarize threads, draft contextual replies, and schedule follow-ups — with human approval on every outgoing action and memory that improves its behavior over time.

Integrations (Mail, Calendar) are mocked behind swappable adapter interfaces, so the whole thing runs end-to-end without real accounts. The demo is a notebook (notebooks/demo.ipynb).

What it does

  • Triage — read, classify (a structured {intent, priority} via the classify tool), label, and route messages.
  • Summarize — condense a thread to TL;DR + the key points.
  • Draft — compose a contextual reply (returns the text; does not send).
  • Schedule — work out follow-up reminders / calendar events.
  • Human-in-the-loop — approve / edit / reject before anything is saved or scheduled, and a structural gate that hides a commit tool until its work was actually done.
  • Learns over time — preferences, contacts, and standing facts persist across conversations and are recalled into later turns (including preferences stated while editing an action).

Architecture (high level)

A supervisor create_agent whose tools are the four worker agents (agents-as-tools) plus its own gated outgoing-action tools. One process() / resume() per turn; the supervisor's loop composes the workers. Workers are read/compose-only — only the supervisor commits, and only behind approval.

adapters/      — Mail & Calendar ABCs + in-memory mocks
tools/         — @tool closures over a client + registry (name -> tool)
memory/        — SemanticMemoryStore (long-term) + MemoryRecallMiddleware (read path)
factory.py     — build workers, wrap as tools, build the supervisor (+ middleware)
config.py      — YAML loader -> typed AppConfig (env interpolation)
llm_factory.py — named model specs -> instances (memoized)
prompts.py     — structured-output schemas + prompts (decide / consolidate)
logging_setup.py — setup_logging() + redact()
assistant.py   — EmailAssistant: from_config_dir / process / decide / resume

See docs/architecture.md for the component breakdown, the execution flow, and diagrams of the HITL loop and the memory read/write paths.

Setup

uv sync --extra dev        # create .venv and install deps + dev tools
cp .env.example .env        # then fill in your Azure OpenAI vars

.env needs Azure OpenAI credentials and two deployments — a chat model and an embeddings model (used for long-term memory). The deployment names live in config/llm_factory.yaml.

Run

The entry point is the notebook — it tours config, adapters/tools, assembly, the end-to-end process() flow, human-in-the-loop approval, and long-term memory:

uv run jupyter lab notebooks/demo.ipynb

Or drive it directly:

from email_assistant import EmailAssistant
from email_assistant.adapters.mail import MockMailClient
from email_assistant.adapters.calendar import MockCalendarClient

assistant = EmailAssistant.from_config_dir(
    "config",
    mail_client=MockMailClient.from_json("data/seed_inbox.json"),
    calendar_client=MockCalendarClient(),
)

turn = await assistant.process("Summarize the Q3 budget thread (t1).", thread_id="demo")
print(turn.reply)

# Human-in-the-loop: process -> decide -> resume
turn = await assistant.process("Draft a reply to Alice and save it.", thread_id="t2")
if turn.status == "interrupted":
    decisions = await assistant.decide("looks good — sign it 'Kind Regards, Anastasis'", turn.pending)
    turn = await assistant.resume("t2", decisions, note="sign it 'Kind Regards, Anastasis'")

process() / resume() return a TurnResult — either completed (with reply) or interrupted (with pending actions awaiting approval).

Configuration

All config is YAML under config/, loaded into one typed AppConfig (config.load_config). Model settings live once in llm_factory.yaml and agents reference a config by key; secrets use ${VAR} / ${VAR:-default} / $VAR interpolation so nothing sensitive is committed.

  • llm_factory.yaml — named model specs (class_path + config), incl. the embeddings model.
  • supervisor_agent.yaml — supervisor prompt, llm key, owned outgoing tools, the hitl subset gated for approval, and gates (outgoing tool → the worker whose output it needs, so a commit tool stays hidden until that work was done this turn).
  • worker_agents.yaml — per worker: title (→ tool name), description (routing signal), prompt, llm key, tools.
  • memory.yaml — declarative backing store + embeddings key, user_id, top_k.
  • classifier.yaml — the llm key for the message classifier (intent/priority taxonomy is single-sourced in classifier.py).

Memory

  • Short-term — an InMemorySaver checkpointer on the supervisor, keyed by thread_id: a conversation continues across calls and HITL pauses are resumable.
  • Long-termSemanticMemoryStore, a vector-indexed store namespaced (user_id, category) over preferences / contacts / facts.
    • Write — extraction is folded into the supervisor turn: it returns structured {response, memory}, and each candidate is consolidated (search neighbours → add / update-supersede / noop) so the store stays clean rather than append-only. Runs automatically when a turn completes — no separate reflection call.
    • ReadMemoryRecallMiddleware semantically searches the store with the latest user message and injects the top-k hits into the prompt (per-call, not persisted to history). This is what lets a brand-new thread apply a preference stated once before.
    • Learning from edits — a preference stated while editing an action (e.g. a sign-off during approval) reaches only decide(). resume(note=reply) replays it as its own no-action turn so it's recorded and learned, not lost.

Observability

  • Logging — stdlib logging configured once via setup_logging(); secret-looking values are masked by redact(). Every step is logged: model instantiation, worker/supervisor/registry/memory build, assembly, per-turn process start/done with thread_id, worker invocations, tool calls, and memory recall/write/consolidate. See docs/sample-logs.txt for a full captured run (regenerate offline with uv run python scripts/sample_run.py).
  • Tracing — LangSmith works via the standard LANGSMITH_* env vars; the key runs are named agent:Supervisor, llm:Decision, and llm:Memory for readable traces.

Testing

uv run pytest tests/ -q

Tests are provider-free (stub models + a deterministic fake embedding) and cover config loading, adapters/tools, the factory, memory (store / consolidation / recall / HITL-note replay), and assembly + process().

Deferred / not built

A CLI (the notebook is the entry point); observability metrics + error tracking; memory housekeeping (metadata, per-category caps, TTL, background compaction); real Gmail/Calendar adapters (the mocks document the per-resource concurrency a real adapter would need).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages