feat(email): inbox profiling from memory (#1289)#1642
Conversation
EmailTriageAgent previously had no persistent memory across sessions. After this change it composes MemoryMixin alongside DatabaseMixin, with memory.db namespaced to ~/.gaia/email/ so it coexists with state.db without conflict. Memory tools (remember/recall/update_memory/forget/ search_past_conversations) are registered through _register_tools().
Priority/low-priority senders and category-default actions set via the preference tools were stored only in self._session_preferences and wiped on every restart. They now persist to the MemoryStore wired in #1114 under a stable entity key (email:preferences) and are restored into _session_preferences on agent construction. Persistence uses an idempotent upsert (get_by_entity -> update by id, or store if absent) so the record never accumulates duplicates. clear_session_preferences wipes the persisted copy too. When memory is disabled (GAIA_MEMORY_DISABLED=1 or Lemonade unreachable) the tools still work in-process and the write is skipped. Closes #1288
Formal eval gate: PASS ✅ (full chain #1114→#1290)Ran the email categorization integration gate on this stack tip against local Lemonade (
|
Review: feat(email): inbox profiling from memory (#1289)SummarySolid, well-tested addition that does exactly what it claims — a rolling per-sender interaction record in the memory store from #1114, surfaced via a The single most useful thing to know: the project linter ( Issues Found🟡 New 🟢 isort: from datetime import datetime, timezone🟢 Docstring omits a returned field ( 🟢 Tie-break direction is unstated ( 🟢 Per-message DB round-trips on the triage hot path ( Strengths
VerdictApprove with suggestions. No blocking issues. Please alphabetize the |
f5a6f5f to
741b6d5
Compare
Adds a per-sender rolling interaction record substrate and a profile_inbox tool that summarises sender frequencies and dominant triage categories from remembered history. Each triage run records one interaction per sender (upsert — bounded O(senders), never unbounded). Memory-disabled path skips silently with no error.
_read_interactions replaced the hardcoded limit=1000 with an explicit 50k-sender ceiling that logs a WARNING if ever hit, so coverage loss is never silent (no-silent-fallbacks rule) and #1290 can reuse the reader safely. Also documents that the record-on-triage hook fires before the max_messages cap on purpose.
724d197 to
1e4ab11
Compare
|
Can you include a few examples of what it stores from profiling and how long it takes? what if the inbox has thousands of messages, do we handle this case well? |
…-persist-preferences # Conflicts: # hub/agents/python/email/gaia_agent_email/agent.py
The four _invoke_* dispatch helpers in test_email_preferences_persist.py accepted an agent parameter that was never used — each helper dispatches through the module-global _TOOL_REGISTRY, which holds closures from the most-recently-built agent. Drop the parameter from all four signatures and update the ten call sites accordingly.
|
@kovtcharov-amd — measured this on the branch (Gemma-4-E4B + nomic-embed on Lemonade); numbers below, not estimates. What it stores — one compact rolling record per sender (not per message). No subject lines, bodies, or attachments — just a counter + category histogram: entity: email:interaction:<sender>
content: {"sender": "[email protected]", "count": 4,
"category_counts": {"urgent": 3, "actionable": 1},
"last_ts": "2026-06-15T15:54:16+00:00"}How long — per-write and read timings (interaction store only):
Crucially: zero embedding/Lemonade calls per write. Thousands of messages — yes, handled well, because record count is bounded by distinct senders, not message volume: 50,000 emails from 1,000 senders → 1,000 rows (~2 ms writes, 5 ms reads). Per triage call, writes are bounded by One honest characteristic: the new-sender INSERT path runs an FTS5 dedup scan that grows with DB size (~10 ms at 5,000 distinct senders); repeat senders skip it (0.14 ms). It's only a factor for an inbox with thousands of never-before-seen senders. If that ever matters, the fix is a one-liner — bypass Actual stored data — real live captureTo make it concrete, I ran the agent over live Gmail + Outlook (read-only): 16 messages, 11 distinct senders → 11 interaction rows. The real Each row is one sender; the entire payload is a |
…sue-1289-email-inbox-profiling
…sue-1289-email-inbox-profiling
|
This review re-surfaced after the stacked rebase — all of its suggestions are already in the current branch:
@kovtcharov-amd — your "examples / how long / thousands of messages" question is answered above in #1642 (comment) (sample stored record, write/read timing at 100/1k/10k senders, the zero-embedding-per-write finding, and a real live-inbox DB dump). Note: this PR is stacked behind the now-merged #1288, so the diff may transiently list |
|
🟡 The implementation detail about |
Both #1288 (PR #1633) and #1289 (PR #1642) have been squash-merged to main. Merge main in so the branch diff is limited to only the #1290 behavioral-learning additions. Conflict in profile_tools.py resolved by keeping the branch superset: #1289 interaction substrate + #1290 reply constants, _evaluate_promotions, and _record_reply_interaction — all additions present in main are included.
#1642 (inbox profiling) registered the profile_inbox tool but never added it to EXPECTED_TOOLS, breaking "Email Agent Tests" on main and every email PR that merges with main. Allowlist it here to unblock this PR.
Closes #1289
Why this matters
Before: every triage started cold — the agent had no sense of who emails you or how a given sender's mail usually lands. After: the agent builds an inbox profile from triage history (per-sender message frequency + dominant/typical category) and exposes a
profile_inboxcapability that summarizes it. It's the substrate the next step (#1290 behavioral learning) reads from.Each triaged message updates a single rolling per-sender record in the memory store from #1114 (
email:interaction:<sender>, idempotent upsert — bounded by distinct senders, never by message count). Recording is a side-effect of triage running — no scheduler, no background job. Memory-disabled degrades to an empty profile, no error.Test plan
test_email_inbox_profiling.py+ 28 carried →pytest hub/agents/python/email/tests/ -x -q= 44 passed.python util/lint.py --allclean. (Review caught a silent 1000-sender read cap → replaced with a loud_MAX_INTERACTION_RECORDS=50000ceiling that WARNs on truncation, per the no-silent-caps rule.)nomic-embed, memory ENABLED):profile_inboxreturns correct per-sender counts + dominant categories (5 senders).close_db()clean (noPermissionError), WAL mode OK.