From bad0783158355be8f1fa9a22151e79eed318da63 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 07:34:45 +0000 Subject: [PATCH] feat(email): the chat assistant is configured per account, not just scoped to one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the multi-account plumbing found the data and automation layers already correct, and one real gap in the assistant. Verified per-account (no change needed): every email table is keyed by account_id and cascades from email_accounts — rules + actions, executed rules, assistant settings, knowledge, learned patterns, rule patterns and guidance, thread status, AI drafts, senders, newsletters, contacts, embeddings. Rules are loaded WHERE account_id and evaluated per account, so each mailbox genuinely has its own rules engine. Drafting already resolves About / signature / models / voice profile / reply memories by account_id, with a per-account Mem0 scope. The gap: the CHAT assistant knew WHICH account to act on but nothing about HOW that mailbox should be handled. The persona carried the account list, the active account_id and the open email — never the account's About, standing instructions, or writing style. So the same assistant conversed identically on a work mailbox and a personal one, and only its tool calls differed. Drafts were already per-account (the server loads settings by account_id), which made the inconsistency easy to miss. - buildEmailAssistantPersona takes the active account's settings and emits them as a scoped block, stating they apply to the ACTIVE account only so the model doesn't carry them across a switch. Standing instructions are marked binding; the auto-derived learned style is marked advisory, matching the precedence the drafter already uses. - Both entry points feed it: the email app (which already fetched these settings for the chat model and discarded the rest) and the chat app, which now also passes the resolved account id. - /assistant/settings returns learned_writing_style so the chat can match the voice the drafter already writes in. Left as-is deliberately: the chat agent's remember() namespace stays user-global. That ContextVar doubles as the agent's gateway-auth identity, so an account suffix would break every tool call — the per-account scope lives on the gateway side (email_memory_scope), where drafting uses it. Tests: the persona names the active account, carries its configuration, marks instructions binding and learned style advisory, swaps cleanly when the account changes, and degrades to account-awareness when the settings haven't loaded or are blank. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0122zNo8kiZooLu49jsaSrQ6 --- .../routes/email/automation/assistant.py | 8 ++ workbench/control_plane/src/app/chat/page.tsx | 41 ++++-- .../email/components/EmailAssistantChat.tsx | 28 +++- .../email/lib/emailAssistantPersona.test.ts | 120 ++++++++++++++++++ .../app/email/lib/emailAssistantPersona.ts | 63 +++++++++ .../control_plane/src/app/email/lib/types.ts | 3 + 6 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 workbench/control_plane/src/app/email/lib/emailAssistantPersona.test.ts diff --git a/apps/services/gateway/gateway/routes/email/automation/assistant.py b/apps/services/gateway/gateway/routes/email/automation/assistant.py index 341c1000..0d05a1bf 100644 --- a/apps/services/gateway/gateway/routes/email/automation/assistant.py +++ b/apps/services/gateway/gateway/routes/email/automation/assistant.py @@ -253,6 +253,7 @@ async def get_assistant_settings( """SELECT about, signature, auto_run, cold_email_blocker, rule_model, draft_model, compose_model, chat_model, digest_frequency, personal_instructions, writing_style, + learned_writing_style, draft_replies, follow_up_days, draft_confidence, follow_up_awaiting_days, follow_up_needs_reply_days, follow_up_auto_draft, digest_categories, digest_day_of_week, @@ -306,6 +307,13 @@ async def get_assistant_settings( "writing_style": ( getattr(row, "writing_style", None) if row else "" ) or "", + # Auto-derived from how the user edits this account's drafts. + # Read-only and advisory (an explicit writing_style outranks it) — + # surfaced so the chat assistant can match the same voice the + # drafter already does, per account. + "learned_writing_style": ( + getattr(row, "learned_writing_style", None) if row else "" + ) or "", # This fallback IS the default for every account that has never # opened AI Settings, so it must agree with # AssistantSettingsModel.draft_replies (ON) — a mismatch would show diff --git a/workbench/control_plane/src/app/chat/page.tsx b/workbench/control_plane/src/app/chat/page.tsx index 1c7dc2e9..73e4e025 100644 --- a/workbench/control_plane/src/app/chat/page.tsx +++ b/workbench/control_plane/src/app/chat/page.tsx @@ -15,7 +15,10 @@ import { isUnresolvedAgent, type ChatSession, } from "@/lib/sessions"; -import { buildEmailAssistantPersona } from "@/app/email/lib/emailAssistantPersona"; +import { + buildEmailAssistantPersona, + type PersonaAccountSettings, +} from "@/app/email/lib/emailAssistantPersona"; import { getAssistantSettings } from "@/app/email/lib/api"; import AgentChat from "@/components/AgentChat"; import { AgentAvatar, useAgentAvatars } from "@/components/AgentAvatar"; @@ -596,14 +599,6 @@ function ChatPageInner() { .catch(() => {}); return () => { cancelled = true; }; }, [activeAgentName]); - const emailAssistantPersona = useMemo( - () => - activeAgentName === "email-assistant" - ? buildEmailAssistantPersona({ accounts: emailAccounts }) - : undefined, - [activeAgentName, emailAccounts], - ); - // Lock the email-assistant chat to the account's configured chat_model — the // SAME single source of truth the email app uses (Assistant → Settings → // Models) — so the agent runs on the SAME model regardless of which surface @@ -617,14 +612,40 @@ function ChatPageInner() { // (which the backend would coerce to a different tier). Refined to the // account's saved chat_model once the fetch resolves; kept on lookup failure. const [emailChatModel, setEmailChatModel] = useState("tier-powerful"); + // The account's standing configuration, fed into the persona below — same + // fetch, so the assistant behaves the same here as in the email app. + const [emailAcctSettings, setEmailAcctSettings] = + useState(null); useEffect(() => { if (activeAgentName !== "email-assistant" || !emailChatAccountId) return; let cancelled = false; getAssistantSettings(emailChatAccountId) - .then((s) => { if (!cancelled) setEmailChatModel(s.chat_model || "tier-powerful"); }) + .then((s) => { + if (cancelled) return; + setEmailChatModel(s.chat_model || "tier-powerful"); + setEmailAcctSettings({ + about: s.about, + personal_instructions: s.personal_instructions, + writing_style: s.writing_style, + learned_writing_style: s.learned_writing_style, + }); + }) .catch(() => { /* keep the tier-powerful default on lookup failure */ }); return () => { cancelled = true; }; }, [activeAgentName, emailChatAccountId]); + // Declared after the settings it reads (the persona carries the active + // account's standing configuration, not just the account list). + const emailAssistantPersona = useMemo( + () => + activeAgentName === "email-assistant" + ? buildEmailAssistantPersona({ + accounts: emailAccounts, + selectedAccountId: emailChatAccountId, + settings: emailAcctSettings, + }) + : undefined, + [activeAgentName, emailAccounts, emailChatAccountId, emailAcctSettings], + ); const [showPicker, setShowPicker] = useState(false); // Desktop: collapsible side panel. Mobile: drawer-based (never a sidebar). const [sessionPanelOpen, setSessionPanelOpen] = useState(true); diff --git a/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx b/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx index ec378cac..0b4cf5c1 100644 --- a/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx +++ b/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx @@ -29,7 +29,10 @@ import { import { useActiveSessions } from "@/hooks/useActiveSessions"; import { useChatMemories } from "@/hooks/useChatMemories"; import { useEmailStore } from "../lib/emailStore"; -import { buildEmailAssistantPersona } from "../lib/emailAssistantPersona"; +import { + buildEmailAssistantPersona, + type PersonaAccountSettings, +} from "../lib/emailAssistantPersona"; import { getAssistantSettings } from "../lib/api"; const AGENT = "email-assistant"; @@ -68,19 +71,35 @@ export function EmailAssistantChat({ // Default to the documented email-chat default (tier-powerful) so a send // during the brief settings-fetch window uses a sensible model instead of // "auto". Refined to the account's saved chat_model once the fetch resolves. + // The ACTIVE account's assistant settings. Two things ride on this, both + // per-account: which chat model to run, and the standing configuration + // (about / instructions / writing style) the persona hands the agent — so + // switching mailboxes switches how the assistant behaves, not just which + // account_id it passes to tools. const [chatModel, setChatModel] = useState("tier-powerful"); + const [acctSettings, setAcctSettings] = + useState(null); useEffect(() => { if (!selectedAccountId) { setChatModel("tier-powerful"); + setAcctSettings(null); return; } let cancelled = false; getAssistantSettings(selectedAccountId) .then((s) => { - if (!cancelled) setChatModel(s.chat_model || "tier-powerful"); + if (cancelled) return; + setChatModel(s.chat_model || "tier-powerful"); + setAcctSettings({ + about: s.about, + personal_instructions: s.personal_instructions, + writing_style: s.writing_style, + learned_writing_style: s.learned_writing_style, + }); }) .catch(() => { - // Keep the tier-powerful default if the lookup fails. + // Keep the tier-powerful default if the lookup fails; the persona + // degrades to account-awareness without the standing orders. }); return () => { cancelled = true; @@ -186,8 +205,9 @@ export function EmailAssistantChat({ accounts, selectedAccountId, openEmail: emails.find((e) => e.id === selectedEmailId) ?? null, + settings: acctSettings, }), - [accounts, emails, selectedAccountId, selectedEmailId], + [accounts, emails, selectedAccountId, selectedEmailId, acctSettings], ); const activeSession = emailSessions.find((s) => s.id === activeId); diff --git a/workbench/control_plane/src/app/email/lib/emailAssistantPersona.test.ts b/workbench/control_plane/src/app/email/lib/emailAssistantPersona.test.ts new file mode 100644 index 00000000..f1faf6f9 --- /dev/null +++ b/workbench/control_plane/src/app/email/lib/emailAssistantPersona.test.ts @@ -0,0 +1,120 @@ +/** + * The email assistant is configured PER ACCOUNT. + * + * Everything the mail app stores — rules, knowledge, learned patterns, models, + * signature, About, standing instructions — is keyed by account_id, and the + * drafting pipeline loads it by account_id. The chat assistant was the one + * surface that didn't: it knew which account_id to hand its tools, but nothing + * about how the user wants THAT mailbox handled, so it conversed identically on + * a work account and a personal one. + * + * These pin the contract that closes that gap: the persona carries the ACTIVE + * account's configuration, says plainly that it applies to that account only, + * and degrades safely when the settings haven't loaded. + */ +import { describe, expect, it } from "vitest"; + +import { buildEmailAssistantPersona } from "./emailAssistantPersona"; + +const ACCOUNTS = [ + { id: "acc-work", emailAddress: "me@fracktal.in" }, + { id: "acc-personal", emailAddress: "me@gmail.com" }, +]; + +describe("buildEmailAssistantPersona", () => { + it("names the active account and tells the agent to scope tools to it", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + }); + expect(p).toContain("acc-work"); + expect(p).toContain("me@fracktal.in"); + expect(p).toMatch(/account-scoped tools/i); + // Both accounts are still listed, so "check my other inbox" is reachable. + expect(p).toContain("acc-personal"); + }); + + it("carries the active account's standing configuration", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: { + about: "I am the CTO of Fracktal Works.", + personal_instructions: "Never promise delivery dates.", + writing_style: "Short, direct, no filler.", + learned_writing_style: "Tends to drop pleasantries.", + }, + }); + expect(p).toContain("I am the CTO of Fracktal Works."); + expect(p).toContain("Never promise delivery dates."); + expect(p).toContain("Short, direct, no filler."); + expect(p).toContain("Tends to drop pleasantries."); + // Scoped explicitly — the model must not carry these across a switch. + expect(p).toMatch(/ACTIVE account only/i); + }); + + it("marks standing instructions as binding and learned style as advisory", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: { + personal_instructions: "Always cc finance on invoices.", + learned_writing_style: "Uses bullet points.", + }, + }); + expect(p).toMatch(/follow these/i); + expect(p).toMatch(/advisory/i); + }); + + it("switching the active account switches the configuration", () => { + const work = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: { personal_instructions: "Formal tone with customers." }, + }); + const personal = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-personal", + settings: { personal_instructions: "Keep it casual." }, + }); + expect(work).toContain("Formal tone with customers."); + expect(work).not.toContain("Keep it casual."); + expect(personal).toContain("Keep it casual."); + expect(personal).not.toContain("Formal tone with customers."); + }); + + it("degrades to account-awareness when settings aren't loaded", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: null, + }); + expect(p).toContain("acc-work"); + expect(p).not.toMatch(/assistant configuration/i); + }); + + it("omits the block when every setting is blank", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: { about: "", personal_instructions: " ", writing_style: null }, + }); + expect(p).not.toMatch(/assistant configuration/i); + }); + + it("still points at the open email when one is selected", () => { + const p = buildEmailAssistantPersona({ + accounts: ACCOUNTS, + selectedAccountId: "acc-work", + settings: { about: "CTO." }, + openEmail: { + id: "msg-1", + subject: "Quote request", + from: { name: "Ayush", email: "ayush@x.com" }, + }, + }); + expect(p).toContain("msg-1"); + expect(p).toContain("Quote request"); + expect(p).toContain("CTO."); + }); +}); diff --git a/workbench/control_plane/src/app/email/lib/emailAssistantPersona.ts b/workbench/control_plane/src/app/email/lib/emailAssistantPersona.ts index d39cab1e..3725c28e 100644 --- a/workbench/control_plane/src/app/email/lib/emailAssistantPersona.ts +++ b/workbench/control_plane/src/app/email/lib/emailAssistantPersona.ts @@ -28,6 +28,28 @@ export interface PersonaOpenEmail { from?: { name?: string | null; email?: string | null } | null; } +/** + * The ACTIVE account's own assistant configuration (email_assistant_settings). + * + * Every one of these is stored per account, and the drafting pipeline already + * loads them by account_id — so a draft written for the work mailbox already + * differs from one written for the personal mailbox. The CHAT assistant was the + * one surface that didn't see them: it knew which account_id to pass to tools, + * but nothing about how the user wants that mailbox handled, so it conversed + * identically on every account. Carrying them here is what makes switching + * accounts in the UI actually change the assistant's behaviour. + */ +export interface PersonaAccountSettings { + /** Who the user is, in this mailbox's context. */ + about?: string | null; + /** Standing rules the user always wants followed on this account. */ + personal_instructions?: string | null; + /** How replies from this account should read. */ + writing_style?: string | null; + /** Auto-derived from how the user edits drafts on this account. */ + learned_writing_style?: string | null; +} + function addr(a: PersonaAccount): string { return a.emailAddress || a.email_address || ""; } @@ -36,6 +58,9 @@ export function buildEmailAssistantPersona(opts: { accounts?: PersonaAccount[]; selectedAccountId?: string | null; openEmail?: PersonaOpenEmail | null; + /** The ACTIVE account's assistant settings. Omit where they aren't loaded — + * the persona degrades to account-awareness without the standing orders. */ + settings?: PersonaAccountSettings | null; }): string { const accounts = opts.accounts ?? []; const parts: string[] = [ @@ -83,6 +108,44 @@ export function buildEmailAssistantPersona(opts: { ); } + // How the ACTIVE account wants to be handled. Scoped to that account, so + // switching mailboxes in the UI switches the assistant's standing orders with + // it. Trimmed and bounded — this rides in the system context on every turn. + const cfg = opts.settings; + if (cfg) { + const clip = (s: string, n: number) => + s.length > n ? `${s.slice(0, n)}…` : s; + const block = (label: string, v?: string | null, n = 1200) => { + const t = (v || "").trim(); + return t ? `${label}\n${clip(t, n)}` : ""; + }; + const cfgParts = [ + block("### About the user (this account)", cfg.about), + // Standing instructions outrank the assistant's defaults — say so, or the + // model treats them as background colour. + block( + "### Standing instructions for this account (follow these)", + cfg.personal_instructions, + ), + block("### How the user writes from this account", cfg.writing_style), + // Advisory: derived from edits, not stated by the user, so an explicit + // writing_style must win where the two disagree. + block( + "### Observed writing style (auto-derived, advisory)", + cfg.learned_writing_style, + 600, + ), + ].filter(Boolean); + if (cfgParts.length) { + parts.push( + "## This account's assistant configuration\n" + + "These are configured for the ACTIVE account only — if the user " + + "switches accounts, they no longer apply.\n\n" + + cfgParts.join("\n\n"), + ); + } + } + const email = opts.openEmail; if (email) { const from = email.from?.name diff --git a/workbench/control_plane/src/app/email/lib/types.ts b/workbench/control_plane/src/app/email/lib/types.ts index b706fb05..ae4f14be 100644 --- a/workbench/control_plane/src/app/email/lib/types.ts +++ b/workbench/control_plane/src/app/email/lib/types.ts @@ -588,6 +588,9 @@ export interface AssistantSettings { personal_instructions: string; /** Tone/length/style guidance for drafted replies (can be auto-derived). */ writing_style: string; + /** Read-only: style distilled from how the user edits this account's drafts. + * Advisory — an explicit `writing_style` outranks it. */ + learned_writing_style?: string; /** Whether the assistant drafts replies for emails that need one. */ draft_replies: boolean; /** Legacy alias for follow_up_awaiting_days (kept for back-compat). */