Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
41 changes: 31 additions & 10 deletions workbench/control_plane/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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<string | undefined>("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<PersonaAccountSettings | null>(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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string | undefined>("tier-powerful");
const [acctSettings, setAcctSettings] =
useState<PersonaAccountSettings | null>(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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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.");
});
});
63 changes: 63 additions & 0 deletions workbench/control_plane/src/app/email/lib/emailAssistantPersona.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || "";
}
Expand All @@ -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[] = [
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions workbench/control_plane/src/app/email/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down
Loading