From 1561073c78a4c0e693a91cad2501e522320d1855 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:38:18 +0000 Subject: [PATCH 01/22] chore: release release --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index cddbaefb9..c95106184 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa2ed919..e2dea4a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0](https://github.com/trycompai/crm/compare/v1.1.0...v1.2.0) (2026-08-07) + + +### Features + +* **api:** add microsoft sign-in and outlook mailbox sync ([#73](https://github.com/trycompai/crm/issues/73)) ([2a0062f](https://github.com/trycompai/crm/commit/2a0062fb76ffdaa5bbbb3848a5573b8b53cd0036)) + ## [1.1.0](https://github.com/trycompai/crm/compare/v1.0.0...v1.1.0) (2026-08-06) diff --git a/package.json b/package.json index 58f50e8b1..bd61fd43b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build": "turbo run build", From 42e41dc4bdf3542b4f9cf88cfdc20f3db35dc020 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:03:54 -0400 Subject: [PATCH 02/22] feat(agent): surface optional capabilities and full-agentic checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report AGENT_BRIDGE_SECRET with LinkedIn, Perplexity, Blob, and Context. Missing keys stay soft-fail. Export FULL_AGENTIC_CHECKLIST (env names only) for production enable, align docs/.env.example, and note names on Settings. Full agentic mode checklist: - RAPIDAPI_KEY - PERPLEXITY_API_KEY - BLOB_READ_WRITE_TOKEN - AGENT_BRIDGE_SECRET (same value on app + agent) - Context key at Settings → General (not an env var) --- .env.example | 7 ++ apps/agent/agent/lib/capabilities.ts | 78 +++++++++++++++++++ apps/agent/test/capabilities.spec.ts | 52 +++++++++++++ .../(app)/[slug]/settings/research-key.tsx | 4 +- docs/agent.md | 5 ++ docs/environment.md | 17 ++++ 6 files changed, 162 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 3be3f9fb2..67eaf06b0 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,13 @@ GOOGLE_CLIENT_SECRET="" # already knows (your own email and calendar history) and simply reports what # it could not check. Each key you add unlocks one more place it can look, and # it tells you at startup which ones are on. +# +# Full agentic mode checklist (env names only; Context is Settings → General): +# RAPIDAPI_KEY +# PERPLEXITY_API_KEY +# BLOB_READ_WRITE_TOKEN +# AGENT_BRIDGE_SECRET (same value for app + agent; see block above) +# Context key (Settings → General — not a variable) # Perplexity — finds where a person lives on the web, and recent news worth # knowing before a call. https://perplexity.ai/settings/api diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index cef15dcc0..e79b0e4f5 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -13,6 +13,50 @@ export type Capability = { readonly from: string; }; +export type EnableChecklistItem = { + readonly id: string; + readonly label: string; + readonly source: string; + readonly kind: "env" | "setting"; +}; + +export const FULL_AGENTIC_CHECKLIST: readonly EnableChecklistItem[] = [ + { + id: "RAPIDAPI_KEY", + label: "LinkedIn", + source: "RAPIDAPI_KEY", + kind: "env", + }, + { + id: "PERPLEXITY_API_KEY", + label: "Web research", + source: "PERPLEXITY_API_KEY", + kind: "env", + }, + { + id: CONTEXT_DEV, + label: "Company brand data", + source: "Settings → General", + kind: "setting", + }, + { + id: "BLOB_READ_WRITE_TOKEN", + label: "Picture storage", + source: "BLOB_READ_WRITE_TOKEN", + kind: "env", + }, + { + id: "AGENT_BRIDGE_SECRET", + label: "Agent panel", + source: "AGENT_BRIDGE_SECRET", + kind: "env", + }, +] as const; + +export const FULL_AGENTIC_ENV_VARS = FULL_AGENTIC_CHECKLIST.filter( + (item) => item.kind === "env", +).map((item) => item.source); + export async function contextDevKey(): Promise { try { return await readContextDevKey(db); @@ -66,6 +110,12 @@ export function capabilitiesFrom( gives: "somewhere to keep a logo or a profile photo. Without it a record has no picture at all, because the URLs these sources hand back expire and are never stored as they are", }, + { + ...fromEnv("AGENT_BRIDGE_SECRET"), + label: "Agent panel", + gives: + "a rep can open a contact, company or deal Agent tab and talk to you live, and the API can poke dispatch without waiting for the schedule", + }, ]; } @@ -137,3 +187,31 @@ export function markdownFor(all: readonly Capability[]): string { return lines.join("\n"); } + +export function enableChecklistMarkdown( + all: readonly Capability[] = capabilitiesFrom(null), +): string { + const byId = new Map(all.map((capability) => [capability.id, capability])); + const lines = [ + "## Full agentic mode enable checklist", + "", + "Env vars only (names, never values). Same value for `AGENT_BRIDGE_SECRET` on the app and the agent.", + "", + ]; + + for (const item of FULL_AGENTIC_CHECKLIST) { + if (item.kind === "env") { + const on = byId.get(item.id)?.enabled === true; + lines.push( + `- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`, + ); + } else { + const on = byId.get(item.id)?.enabled === true; + lines.push( + `- [${on ? "x" : " "}] Context key at \`${item.source}\` — ${item.label} (not an env var)`, + ); + } + } + + return lines.join("\n"); +} diff --git a/apps/agent/test/capabilities.spec.ts b/apps/agent/test/capabilities.spec.ts index 7b4ee8b95..6882fdfc0 100644 --- a/apps/agent/test/capabilities.spec.ts +++ b/apps/agent/test/capabilities.spec.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { CONTEXT_DEV, + FULL_AGENTIC_CHECKLIST, + FULL_AGENTIC_ENV_VARS, capabilitiesFrom, enabled, + enableChecklistMarkdown, markdownFor, unavailable, } from "../agent/lib/capabilities"; @@ -11,6 +14,7 @@ const KEYS = [ "RAPIDAPI_KEY", "PERPLEXITY_API_KEY", "BLOB_READ_WRITE_TOKEN", + "AGENT_BRIDGE_SECRET", ] as const; const saved: Record = {}; @@ -33,6 +37,7 @@ describe("capabilities", () => { it("reports everything off on a bare install", async () => { expect(capabilitiesFrom(null).every((c) => !c.enabled)).toBe(true); expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("turns one on without turning on the others", async () => { @@ -40,11 +45,14 @@ describe("capabilities", () => { expect(await enabled("PERPLEXITY_API_KEY")).toBe(true); expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("treats blank and whitespace as unset", async () => { process.env.RAPIDAPI_KEY = " "; + process.env.AGENT_BRIDGE_SECRET = "\t "; expect(await enabled("RAPIDAPI_KEY")).toBe(false); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(false); }); it("is read live, so a late-configured process is not stuck off", async () => { @@ -58,6 +66,16 @@ describe("capabilities", () => { expect(await enabled("SOMETHING_ELSE")).toBe(false); delete process.env.SOMETHING_ELSE; }); + + it("reports AGENT_BRIDGE_SECRET when set", async () => { + process.env.AGENT_BRIDGE_SECRET = "bridge-secret"; + const bridge = capabilitiesFrom(null).find( + (c) => c.id === "AGENT_BRIDGE_SECRET", + ); + expect(bridge?.enabled).toBe(true); + expect(bridge?.from).toBe("AGENT_BRIDGE_SECRET"); + expect(await enabled("AGENT_BRIDGE_SECRET")).toBe(true); + }); }); describe("the Context key is a setting, never a variable", () => { @@ -112,6 +130,7 @@ describe("the capability briefing", () => { expect(markdown).toContain("LinkedIn"); expect(markdown).toContain("Not configured here"); expect(markdown).toContain("Web research"); + expect(markdown).toContain("Agent panel"); }); it("counts a stored Context key as configured", () => { @@ -133,3 +152,36 @@ describe("the capability briefing", () => { ); }); }); + +describe("full agentic enable checklist", () => { + it("names every production env var and the Context setting", () => { + expect(FULL_AGENTIC_ENV_VARS).toEqual([ + "RAPIDAPI_KEY", + "PERPLEXITY_API_KEY", + "BLOB_READ_WRITE_TOKEN", + "AGENT_BRIDGE_SECRET", + ]); + expect( + FULL_AGENTIC_CHECKLIST.find((item) => item.kind === "setting")?.source, + ).toBe("Settings → General"); + }); + + it("lists env names only, never secret values", () => { + process.env.RAPIDAPI_KEY = "super-secret-value"; + const text = enableChecklistMarkdown(capabilitiesFrom(null)); + + expect(text).toContain("`RAPIDAPI_KEY`"); + expect(text).toContain("`PERPLEXITY_API_KEY`"); + expect(text).toContain("`BLOB_READ_WRITE_TOKEN`"); + expect(text).toContain("`AGENT_BRIDGE_SECRET`"); + expect(text).toContain("Settings → General"); + expect(text).not.toContain("super-secret-value"); + expect(text).toContain("[x] `RAPIDAPI_KEY`"); + expect(text).toContain("[ ] `PERPLEXITY_API_KEY`"); + }); + + it("marks Context when a key is stored", () => { + const text = enableChecklistMarkdown(capabilitiesFrom("ctx")); + expect(text).toContain("[x] Context key at `Settings → General`"); + }); +}); diff --git a/apps/app/app/(app)/[slug]/settings/research-key.tsx b/apps/app/app/(app)/[slug]/settings/research-key.tsx index 3c5dab1a4..c2ca5590a 100644 --- a/apps/app/app/(app)/[slug]/settings/research-key.tsx +++ b/apps/app/app/(app)/[slug]/settings/research-key.tsx @@ -55,7 +55,9 @@ export function ResearchKey() { Company research Enter your Context API key so our agents can research every company in - the CRM. + the CRM. Other agent sources are deploy-time env vars only: + RAPIDAPI_KEY, PERPLEXITY_API_KEY, BLOB_READ_WRITE_TOKEN, and + AGENT_BRIDGE_SECRET (same value on the app and the agent). diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..404516b55 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -189,6 +189,11 @@ states it in the session instructions, and gives tools a shared "not configured, retrying will not help" result — **checked before the research budget is charged**. A missing key removes a place to look. **Never an error, never throws.** +It tracks `RAPIDAPI_KEY`, `PERPLEXITY_API_KEY`, `BLOB_READ_WRITE_TOKEN`, +`AGENT_BRIDGE_SECRET`, and the Context key from Settings → General. +`FULL_AGENTIC_CHECKLIST` / `enableChecklistMarkdown()` is the captain-facing enable +list (env names only; no secret values). + **`capabilities()` is async** because the Context key is a row; `capabilitiesFrom()`/`markdownFor()` are the pure halves. `contextDevKey()` is the only resolver, and `lib/context-dev.ts` memoises its client on the key string. diff --git a/docs/environment.md b/docs/environment.md index aca350183..267a7da1a 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -122,6 +122,23 @@ single place that knows what is set. because the API and the seed write pictures too. The Next.js app is deliberately excluded — recognising our URL for the image optimizer needs no token. +### Full agentic mode checklist + +Set these for full research + rep Agent panel on production (names only; never +commit values). Source of truth: `FULL_AGENTIC_CHECKLIST` in +`apps/agent/agent/lib/capabilities.ts`. + +| Source | Kind | Unlocks | +| --- | --- | --- | +| `RAPIDAPI_KEY` | env | LinkedIn identity | +| `PERPLEXITY_API_KEY` | env | Web research / LinkedIn slug search | +| `BLOB_READ_WRITE_TOKEN` | env | Stored logos and photos | +| `AGENT_BRIDGE_SECRET` | env | Agent tab + dispatch poke (same value on app and agent) | +| Settings → General | setting | Context company brand data (not an env var) | + +Also needed for the model outside Vercel OIDC: `AI_GATEWAY_API_KEY`. Optional +rate-limit help for GitHub matching: `GITHUB_TOKEN`. + ### The Context key is asked for, not configured **`CONTEXT_DEV_API_KEY` is not a variable here and must not become one.** The key lives From a91b622e3225e5ae1c03f64d15236f39f2299c05 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:04:45 -0400 Subject: [PATCH 03/22] fix(agent): keep keyless brand PENDING on the priority-900 direct lane Brand tasks already run on DIRECT_KINDS at priority 900, but a missing Context key wrote SKIPPED and stranded companies outside the re-queue sweep. Return without changing enrichment status when Context is off so the company stays PENDING. Pin lane priority in hermetic tests and align docs/agent.md two-lane rules with DIRECT_KINDS. --- apps/agent/agent/lib/brand.ts | 9 ++-- .../test/keyless-brand.integration.spec.ts | 50 ++++++++++++++++--- apps/agent/test/lanes.integration.spec.ts | 3 ++ docs/agent.md | 34 +++++++------ docs/environment.md | 8 +-- packages/db/test/agent-tasks.spec.ts | 35 +++++++++++++ 6 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 packages/db/test/agent-tasks.spec.ts diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts index d9b30a3a7..6c9b77d30 100644 --- a/apps/agent/agent/lib/brand.ts +++ b/apps/agent/agent/lib/brand.ts @@ -58,10 +58,11 @@ export async function runBrand({ if (!company) return { enriched: false, reason: "No such company." }; if (!(await contextDevEnabled())) { - const reason = - "Context.dev is not configured, so there is nowhere to look."; - await settle(companyId, EnrichmentStatus.SKIPPED, reason); - return { enriched: false, reason }; + return { + enriched: false, + reason: + "Context.dev is not configured, so there is nowhere to look.", + }; } if (!company.domain) { diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index 7b2902b35..3f0ad89ab 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -1,5 +1,14 @@ -import { afterEach, describe, expect, it } from "bun:test"; -import { db, EnrichmentStatus } from "@crm/db"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, +} from "bun:test"; +import { db, EnrichmentStatus, type Prisma } from "@crm/db"; +import { SETTINGS_ID } from "@crm/db/settings"; +import { runBrand } from "../agent/lib/brand"; import { settle } from "../agent/lib/enrichment"; /** @@ -8,16 +17,30 @@ import { settle } from "../agent/lib/enrichment"; * the *record*: the sign-in sweep re-queues companies whose enrichment never * succeeded, and it decides that on `enrichmentStatus` being PENDING or FAILED. * - * `runBrand` settles SKIPPED before anything marks the row RUNNING, and - * `settle` only writes over a RUNNING row — so the row stays PENDING and the - * sweep picks it up once a key exists. That is load bearing and entirely - * implicit, which is why it is pinned here: a `settle` that wrote - * unconditionally would strand every company added before the key, with - * nothing to say so. + * `runBrand` returns without writing enrichment status when the key is missing, + * so the row stays PENDING. `settle` on the enrichment path only writes over a + * RUNNING row. Either path must leave PENDING companies re-queueable once a key + * exists. A settle that wrote SKIPPED unconditionally would strand every + * company added before the key, with nothing to say so. */ const created: string[] = []; const tasks: string[] = []; +let savedSettings: Prisma.AppSettingUncheckedCreateInput | null = null; + +beforeAll(async () => { + savedSettings = await db.appSetting.findUnique({ + where: { id: SETTINGS_ID }, + }); +}); + +afterAll(async () => { + await db.appSetting.deleteMany({ where: { id: SETTINGS_ID } }); + if (savedSettings) { + await db.appSetting.create({ data: savedSettings }); + } +}); + afterEach(async () => { if (tasks.length > 0) { await db.agentTask.deleteMany({ where: { id: { in: tasks.splice(0) } } }); @@ -131,4 +154,15 @@ describe("a brand task with no key", () => { expect(await statusOf(id)).toBe(EnrichmentStatus.COMPLETE); }); + + it("runBrand leaves PENDING when Context is not configured", async () => { + await db.appSetting.deleteMany({ where: { id: SETTINGS_ID } }); + + const id = await company(EnrichmentStatus.PENDING); + const result = await runBrand({ companyId: id }); + + expect(result.enriched).toBe(false); + expect(result.reason).toContain("not configured"); + expect(await statusOf(id)).toBe(EnrichmentStatus.PENDING); + }); }); diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index a2cde1ce9..b8aae20a1 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -107,6 +107,9 @@ describe("kind vocabulary", () => { }); it("puts what a rep sees first above what they have to click for", () => { + expect(PRIORITY.brand).toBe(900); + expect(PRIORITY.portrait).toBe(800); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.portrait); expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); expect(PRIORITY.portrait).toBeGreaterThan(PRIORITY.requested); expect(PRIORITY.requested).toBeGreaterThan(PRIORITY.companyProfile); diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..6560a05de 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -49,16 +49,21 @@ agent and the API both need it. | | Kinds | How | Per tick | | --- | --- | --- | --- | -| **Visible** | `brand`, `portrait` | Directly — no `receive`, no model | 60, six at a time | +| **Direct** | `brand`, `portrait`, `slack-people-match`, `slack-channel-join`, `agent-event` | Directly — no `receive`, no model | 60, six at a time | | **Research** | everything else | One eve session per row | 12 | -**Neither visible kind has anything to decide**, and through a session they queued -behind sixty LLM runs for 25 minutes (`test/lanes.integration.spec.ts`). **The row says -what the work is; the lane only says whether it needs a conversation.** +**None of the direct kinds have anything to decide**, and through a session they +queued behind sixty LLM runs for 25 minutes (`test/lanes.integration.spec.ts`). +**The row says what the work is; the lane only says whether it needs a +conversation.** Company brand and contact portrait are what a rep reads *before* +opening a record. The Slack and event kinds are ops work on the same fast lane so +they never wait behind research sessions either. -**Priority**: `brand` 900 · `portrait` 800 · `workspace` 500 · `requested` 300 · -`meeting` 200 · `identify` 100 · `sweep` 50 · `companyProfile` 40 · `recheck` 0. The -top two are what a rep reads *before* deciding what to open. +**Priority**: `slackJoin` 950 · `brand` 900 · `portrait` 800 · `event` 700 · +`workspace` 500 · `requested` 300 · `meeting` 200 · `slackPeople` 150 · +`identify` 100 · `sweep` 50 · `companyProfile` 40 · `fieldBackfill` 20 · +`recheck` 0. Brand stays at 900 so company visual identity claims ahead of every +research kind. **`claimDue` sorts what it claims** — Postgres does not order `UPDATE … RETURNING` by its sub-select's `ORDER BY`. @@ -612,13 +617,14 @@ bun run --filter=agent dispatch ``` It drains **both lanes**, exactly as the cron does: up to `VISIBLE_BATCH` (60) -`brand` and `portrait` rows six at a time, handled in the process with no session -at all, and `RESEARCH_BATCH` (12) research rows, one session each. So the -`sessionIds` it prints are the research rows only — a run that resolved forty -logos prints an empty list and was not idle. Either way it spends real credits, a -vendor call per visible row and a model session per research one; that is the -point of it, and the reason it is a command you run rather than a ticker somebody -leaves on. Watch the agent pane; the session ids it returns are also streamable at +direct rows (`brand`, `portrait`, and the other `DIRECT_KINDS`) six at a time, +handled in the process with no session at all, and `RESEARCH_BATCH` (12) +research rows, one session each. So the `sessionIds` it prints are the research +rows only — a run that resolved forty logos prints an empty list and was not +idle. Either way it spends real credits, a vendor call per brand row and a model +session per research one; that is the point of it, and the reason it is a +command you run rather than a ticker somebody leaves on. Watch the agent pane; +the session ids it returns are also streamable at `GET /eve/v1/session/:id/stream`. `eve start` on a built app *does* run the schedule, and so does Vercel, where diff --git a/docs/environment.md b/docs/environment.md index aca350183..ffead52bf 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -130,10 +130,10 @@ General — an admin who cannot redeploy cannot set a variable. - **An install that had the variable is asked again**: no migration, no fallback, and **the gate cannot be dismissed**. -- **Nothing is lost while waiting.** A keyless `brand` task settles `SKIPPED` *before* - anything marks the row `RUNNING`, and `settle` only overwrites `RUNNING` — so the - company stays `PENDING`, which the sweep re-queues - (`test/keyless-brand.integration.spec.ts`). +- **Nothing is lost while waiting.** A keyless `brand` task returns without writing + enrichment status (it never marks the row `RUNNING`). On the enrichment path, + `settle` only overwrites `RUNNING`. Either way the company stays `PENDING`, + which the sweep re-queues (`test/keyless-brand.integration.spec.ts`). - **Saving the key runs the company sweep immediately** (fire-and-forget). - **`readContextDevKey` (`@crm/db/settings`) is the only reader**, read live with no cache. An unreadable database is a capability that is off, not an exception. diff --git a/packages/db/test/agent-tasks.spec.ts b/packages/db/test/agent-tasks.spec.ts new file mode 100644 index 000000000..7bfe3a09c --- /dev/null +++ b/packages/db/test/agent-tasks.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { + DIRECT_KINDS, + isDirectKind, + PRIORITY, + TASK_KINDS, +} from "../src/agent-tasks"; + +describe("brand visual identity lane", () => { + it("keeps brand on the direct lane at priority 900", () => { + expect(TASK_KINDS).toContain("brand"); + expect(DIRECT_KINDS).toContain("brand"); + expect(isDirectKind("brand")).toBe(true); + expect(PRIORITY.brand).toBe(900); + }); + + it("orders brand ahead of research work a rep must open to see", () => { + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.portrait); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.workspace); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.meeting); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.identify); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.sweep); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.companyProfile); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.recheck); + }); + + it("keeps research kinds off the direct lane", () => { + expect(isDirectKind("company-profile")).toBe(false); + expect(isDirectKind("identify")).toBe(false); + expect(isDirectKind("workspace-profile")).toBe(false); + expect(isDirectKind("meeting-prep")).toBe(false); + expect(isDirectKind("recheck")).toBe(false); + }); +}); From 5b9f96e31f33213aa86ce98d9c3cb20f77d47320 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:05:24 -0400 Subject: [PATCH 04/22] test(calendar): lock calendar sync substrate and hand off Gmail work Add integration coverage for iCalUID dedupe, relevance filtering, activity projection, cancellation, and 410 cursor reset. Document remaining Gmail and auto-create handoff in the plan. --- apps/api/test/calendar-client.spec.ts | 56 +++++ apps/api/test/calendar-sync.spec.ts | 345 ++++++++++++++++++++++++++ docs/plan/gmail-calendar-plan.md | 71 ++++++ 3 files changed, 472 insertions(+) create mode 100644 apps/api/test/calendar-client.spec.ts create mode 100644 apps/api/test/calendar-sync.spec.ts diff --git a/apps/api/test/calendar-client.spec.ts b/apps/api/test/calendar-client.spec.ts new file mode 100644 index 000000000..f13ea02e1 --- /dev/null +++ b/apps/api/test/calendar-client.spec.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "bun:test"; +import { + conferenceUrl, + eventTime, + type GoogleEvent, +} from "../src/google/calendar.client"; + +describe("eventTime", () => { + it("parses a timed event", () => { + const parsed = eventTime({ dateTime: "2026-08-15T14:30:00Z" }); + expect(parsed?.isAllDay).toBe(false); + expect(parsed?.at.toISOString()).toBe("2026-08-15T14:30:00.000Z"); + }); + + it("parses an all-day event as UTC midnight", () => { + const parsed = eventTime({ date: "2026-08-15" }); + expect(parsed?.isAllDay).toBe(true); + expect(parsed?.at.toISOString()).toBe("2026-08-15T00:00:00.000Z"); + }); + + it("rejects empty and invalid values", () => { + expect(eventTime(undefined)).toBeNull(); + expect(eventTime({})).toBeNull(); + expect(eventTime({ dateTime: "not-a-date" })).toBeNull(); + }); +}); + +describe("conferenceUrl", () => { + it("prefers hangoutLink", () => { + const event: GoogleEvent = { + hangoutLink: "https://meet.google.com/abc", + conferenceData: { + entryPoints: [ + { entryPointType: "video", uri: "https://zoom.example/x" }, + ], + }, + }; + expect(conferenceUrl(event)).toBe("https://meet.google.com/abc"); + }); + + it("falls back to the video conference entry", () => { + const event: GoogleEvent = { + conferenceData: { + entryPoints: [ + { entryPointType: "phone", uri: "tel:+1555" }, + { entryPointType: "video", uri: "https://zoom.example/x" }, + ], + }, + }; + expect(conferenceUrl(event)).toBe("https://zoom.example/x"); + }); + + it("returns null when nothing is present", () => { + expect(conferenceUrl({})).toBeNull(); + }); +}); diff --git a/apps/api/test/calendar-sync.spec.ts b/apps/api/test/calendar-sync.spec.ts new file mode 100644 index 000000000..c99cd54d6 --- /dev/null +++ b/apps/api/test/calendar-sync.spec.ts @@ -0,0 +1,345 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + ActivityType, + db, + GoogleSyncStatus, + type MailboxSyncModel as MailboxSync, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import type { + CalendarClient, + EventsPage, + GoogleEvent, +} from "../src/google/calendar.client"; +import { CalendarSyncService } from "../src/google/calendar-sync.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "calendar-sync-spec"; +const domain = `cal-${suffix}.test`; +const userId = `user-cal-${suffix}`; +const mailbox = `rep-cal-${suffix}@example.test`; +const person = `buyer@${domain}`; +const iCalUid = `uid-${suffix}@google.com`; +const startsAt = "2026-09-01T15:00:00.000Z"; +const endsAt = "2026-09-01T16:00:00.000Z"; + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + meetingSoon: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); + +let row: MailboxSync; +let companyId: string; +let contactId: string; +let pages: EventsPage[] = []; +let listCalls = 0; + +const tokens = { + async accessTokenFor() { + return { outcome: "ok" as const, accessToken: "token" }; + }, +} as unknown as MailboxTokenService; + +const calendar = { + async listEvents() { + listCalls += 1; + const page = pages.shift() ?? { items: [] }; + return { outcome: "ok" as const, data: page }; + }, +} as unknown as CalendarClient; + +const service = new CalendarSyncService( + db, + calendar, + tokens, + match, + state, + stamp, + agent, +); + +function event(overrides: Partial = {}): GoogleEvent { + return { + id: `gcal-${suffix}`, + iCalUID: iCalUid, + status: "confirmed", + summary: "Pricing review", + location: "Zoom", + start: { dateTime: startsAt }, + end: { dateTime: endsAt }, + organizer: { email: person, displayName: "A Buyer" }, + attendees: [ + { + email: person, + displayName: "A Buyer", + responseStatus: "accepted", + organizer: true, + }, + { + email: mailbox, + displayName: "Test Rep", + responseStatus: "accepted", + self: true, + }, + ], + ...overrides, + }; +} + +async function clean() { + await db.activity.deleteMany({ + where: { + OR: [ + { createdById: userId }, + { calendarEvent: { iCalUid } }, + { calendarEvent: { iCalUid: `${iCalUid}-other` } }, + ], + }, + }); + await db.calendarAttendee.deleteMany({ + where: { + event: { + OR: [{ iCalUid }, { iCalUid: `${iCalUid}-other` }, { syncedByUserId: userId }], + }, + }, + }); + await db.calendarEvent.deleteMany({ + where: { + OR: [{ iCalUid }, { iCalUid: `${iCalUid}-other` }, { syncedByUserId: userId }], + }, + }); + await db.contact.deleteMany({ where: { email: person } }); + await db.company.deleteMany({ where: { domain } }); + await db.mailboxSync.deleteMany({ where: { userId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + row = await db.mailboxSync.create({ + data: { + userId, + source: "calendar", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + }, + }); + + const company = await db.company.create({ + data: { name: "Buyer Co", domain }, + select: { id: true }, + }); + companyId = company.id; + + const contact = await db.contact.create({ + data: { + firstName: "A", + lastName: "Buyer", + email: person, + companyId, + }, + select: { id: true }, + }); + contactId = contact.id; +}); + +afterAll(clean); + +describe("CalendarSyncService", () => { + it("projects a relevant meeting onto the company timeline", async () => { + pages = [{ items: [event()], nextSyncToken: "sync-1" }]; + listCalls = 0; + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten).toBe(1); + + const stored = await db.calendarEvent.findUnique({ + where: { + iCalUid_originalStartTime: { + iCalUid, + originalStartTime: new Date(startsAt), + }, + }, + include: { + activity: true, + attendees: { orderBy: { email: "asc" } }, + }, + }); + + expect(stored).not.toBeNull(); + expect(stored?.companyId).toBe(companyId); + expect(stored?.contactId).toBe(contactId); + expect(stored?.activity?.type).toBe(ActivityType.MEETING); + expect(stored?.activity?.subject).toBe("Pricing review"); + expect(stored?.activity?.companyId).toBe(companyId); + expect(stored?.activity?.occurredAt.toISOString()).toBe(startsAt); + expect(stored?.attendees.map((a) => a.email)).toEqual( + [mailbox, person].sort(), + ); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBe("sync-1"); + row = refreshed; + }); + + it("dedupes on (iCalUID, originalStartTime) when the same window re-runs", async () => { + pages = [ + { + items: [ + event({ summary: "Pricing review (updated)", location: "HQ" }), + ], + nextSyncToken: "sync-2", + }, + ]; + + const before = await db.calendarEvent.count({ where: { iCalUid } }); + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten).toBe(1); + + const after = await db.calendarEvent.count({ where: { iCalUid } }); + expect(after).toBe(before); + expect(after).toBe(1); + + const stored = await db.calendarEvent.findFirst({ + where: { iCalUid }, + include: { activity: true }, + }); + expect(stored?.title).toBe("Pricing review (updated)"); + expect(stored?.location).toBe("HQ"); + expect(stored?.activity?.subject).toBe("Pricing review (updated)"); + expect(stored?.activity?.body).toBe("Location: HQ"); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("ignores events with no tracked company or contact", async () => { + const strangerUid = `${iCalUid}-other`; + pages = [ + { + items: [ + event({ + iCalUID: strangerUid, + id: `gcal-stranger-${suffix}`, + organizer: { + email: "stranger@unknown-host.invalid", + displayName: "Nobody", + }, + attendees: [ + { + email: "stranger@unknown-host.invalid", + responseStatus: "accepted", + organizer: true, + }, + { + email: mailbox, + responseStatus: "accepted", + self: true, + }, + ], + }), + ], + nextSyncToken: "sync-3", + }, + ]; + + const before = await db.calendarEvent.count(); + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsWritten ?? 0).toBe(0); + expect(await db.calendarEvent.count()).toBe(before); + expect( + await db.calendarEvent.count({ where: { iCalUid: strangerUid } }), + ).toBe(0); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("removes the projected activity when the event is cancelled", async () => { + pages = [ + { + items: [event({ status: "cancelled" })], + nextSyncToken: "sync-4", + }, + ]; + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.eventsRemoved).toBe(1); + expect(await db.calendarEvent.count({ where: { iCalUid } })).toBe(0); + expect( + await db.activity.count({ + where: { + type: ActivityType.MEETING, + createdById: userId, + subject: { contains: "Pricing" }, + }, + }), + ).toBe(0); + }); + + it("clears the cursor on 410 so the next tick re-reads from now", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: "stale-token", status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + + const failingCalendar = { + async listEvents() { + return { + outcome: "cursor-invalid" as const, + reason: "Sync token is no longer valid.", + }; + }, + } as unknown as CalendarClient; + + const failing = new CalendarSyncService( + db, + failingCalendar, + tokens, + match, + state, + stamp, + agent, + ); + + const outcome = await failing.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.reason).toContain("Cursor reset"); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBeNull(); + expect(refreshed.status).toBe(GoogleSyncStatus.IDLE); + row = refreshed; + }); +}); diff --git a/docs/plan/gmail-calendar-plan.md b/docs/plan/gmail-calendar-plan.md index ae6fe4dda..7d390d177 100644 --- a/docs/plan/gmail-calendar-plan.md +++ b/docs/plan/gmail-calendar-plan.md @@ -819,3 +819,74 @@ with `class-validator` decorators, and to `docs/environment.md`. an account that will not grant the scopes cannot use the CRM anyway. Revisit only if someone needs "keep my login, stop reading my mail", which the policy currently says is not a state we support. + +--- + +## 16. Status and handoff (calendar substrate) + +### Shipped (calendar-before-gmail substrate) + +Phases **0–2** are in the tree. Code map: + +| Concern | Where | +| --- | --- | +| Scopes + offline access | `packages/auth/src/scopes.ts`, `packages/auth/src/auth.ts` | +| App shell gate | `apps/app/lib/session.ts` → `requireMailboxAccess()`, `/grant-access` | +| `MailboxSync` + event tables + `Activity` FK | `packages/db/prisma/schema.prisma` | +| Calendar list client + pure helpers | `apps/api/src/google/calendar.client.ts` | +| Forward-only sync, relevance, projection | `apps/api/src/google/calendar-sync.service.ts` | +| Match (contact → company domain; optional create) | `apps/api/src/mailbox/mailbox-match.service.ts` | +| Cron tick + dispatch | `apps/api/src/sync/mailbox-sync.service.ts`, `sync.controller.ts` | +| Timeline filter `meetings` | `apps/api/src/activities/activities.contracts.ts` | +| Connection / status / purge | `apps/api/src/google/google-connection.service.ts` | +| Acceptance tests | `apps/api/test/calendar-sync.spec.ts`, `calendar-client.spec.ts` | + +Rules already enforced for calendar: + +- Identity is `(iCalUid, originalStartTime)` unique. +- Only events that resolve to a tracked company/contact are stored when + `autoCreate` is off (phase-1 relevance). +- Cancellations delete the `CalendarEvent` and cascade the projected `Activity`. +- Cursor invalidation (410) clears the cursor and resumes from `now` (no backfill). +- `timeMin` is now; horizon is 180 days. + +### Remaining handoff — Gmail (phase 3 polish) + +Gmail write path already exists (`gmail-sync.service.ts`, `ThreadWriterService`). +Work still owned by a Gmail-focused lane, not this substrate: + +1. **Prove phase-3 acceptance on real mailboxes** for a week before tightening + auto-create defaults for email. +2. **Cross-mailbox dedupe tests** on `rfcMessageId` / root `Message-ID` if not + already covered for the Gmail path specifically. +3. **Body privacy checks** — list payloads stay snippet-only; expand uses + `google.thread`. No subjects/bodies in logs (`docs/api.md`). +4. **UI** — thread accordion + Email filter on the record sheet if any surface + still lacks expand-to-messages. + +### Remaining handoff — Auto-create (phase 4) + +Infrastructure is present; product hardening is not closed: + +1. **Defaults** — calendar rows get `autoCreate: true` on first connect; gmail + stays false (`GoogleConnectionService.onConnected`). Keep that until Gmail + matching is observed on production traffic. +2. **Rules** — two-way engagement for Gmail (rep must have sent); calendar + external attendee + not declined. Table-driven tests for declined meetings, + free-host, no-reply, dual external domains. +3. **Undo surfaces** — `RecordSource` filter + bulk delete + `SuppressedDomain` + / `google.suppressDomain` (and contact suppress) must stay usable when junk + appears. +4. **Do not** flip Gmail auto-create on by default in this calendar lane. + +### Out of scope here (other lanes) + +- Meeting-prep agent copy / `meetingSoon` brief generation (`crm-meeting-prep`). +- Gmail body storage policy changes beyond the plan. +- Phase 5 Pub/Sub real-time (`users.watch`). + +### Done when (this lane) + +Calendar events for relevant companies appear on the timeline, deduped by +`(iCalUID, originalStartTime)`, with tests green on the feature branch. Gmail +and auto-create product closure are the next owners above. From 3713dc21db596a9b1cfb10c111f56c1078f58635 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:05:42 -0400 Subject: [PATCH 05/22] fix(agent): harden identity matching with Context slug search Resolve LinkedIn candidates via Context site:linkedin.com/in search instead of Perplexity. LinkDAPI stays enricher-only. Return code-priced evidence and refuse partial matches so a miss stays a miss. --- apps/agent/agent/lib/capabilities.ts | 6 +- apps/agent/agent/lib/identity-verdict.ts | 76 +++++++++ apps/agent/agent/lib/linkedin-candidates.ts | 91 ++++++++++ apps/agent/agent/lib/perplexity.ts | 27 --- apps/agent/agent/skills/evidence.md | 23 ++- apps/agent/agent/skills/identity-matching.md | 50 ++++-- .../agent/agent/tools/get_linkedin_profile.ts | 42 ++--- apps/agent/agent/tools/identify_contact.ts | 2 +- .../agent/tools/resolve_linkedin_profile.ts | 21 +-- apps/agent/test/evidence.spec.ts | 10 ++ apps/agent/test/identity-verdict.spec.ts | 160 ++++++++++++++++++ apps/agent/test/linkedin-candidates.spec.ts | 62 +++++++ docs/environment.md | 2 +- 13 files changed, 482 insertions(+), 90 deletions(-) create mode 100644 apps/agent/agent/lib/identity-verdict.ts create mode 100644 apps/agent/agent/lib/linkedin-candidates.ts create mode 100644 apps/agent/test/identity-verdict.spec.ts create mode 100644 apps/agent/test/linkedin-candidates.spec.ts diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index cef15dcc0..5e808c874 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -50,14 +50,14 @@ export function capabilitiesFrom( { ...fromEnv("PERPLEXITY_API_KEY"), label: "Web research", - gives: - "open-web context with citations, and the search that finds a LinkedIn slug in the first place", + gives: "open-web context with citations for research, not for identity matching", }, { id: CONTEXT_DEV, from: "Settings → General", label: "Company brand data", - gives: "a company's logo, industry, location and socials from its domain", + gives: + "a company's logo, industry, location and socials from its domain, and Context web search that finds LinkedIn candidate slugs for identity matching", enabled: contextDev !== null, }, { diff --git a/apps/agent/agent/lib/identity-verdict.ts b/apps/agent/agent/lib/identity-verdict.ts new file mode 100644 index 000000000..2e51337fd --- /dev/null +++ b/apps/agent/agent/lib/identity-verdict.ts @@ -0,0 +1,76 @@ +import type { Evidence } from "./evidence"; +import type { Profile } from "./linkdapi"; +import { looksLikeSameCompany, nameMatchesLocalPart } from "./names"; + +export type IdentityChecks = { + employerMatches: boolean; + nameMatches: boolean; + isSamePerson: boolean; +}; + +export function identityChecks( + profile: Pick, + email: string, + companyName: string, + companyDomain: string, +): IdentityChecks { + const local = email.split("@")[0] ?? ""; + const employerMatches = profile.positions.some((position) => + looksLikeSameCompany(position.name, companyName, companyDomain), + ); + const nameMatches = nameMatchesLocalPart(profile, local); + + return { + employerMatches, + nameMatches, + isSamePerson: employerMatches && nameMatches, + }; +} + +export function identityEvidence( + profile: Pick< + Profile, + "firstName" | "lastName" | "fullName" | "profileUrl" | "positions" + >, + checks: IdentityChecks, + email: string, + companyName: string, +): Evidence[] { + const joined = [profile.firstName, profile.lastName] + .filter(Boolean) + .join(" "); + const name = profile.fullName ?? (joined || "unnamed profile"); + const employers = profile.positions.map((p) => p.name).filter(Boolean); + const employerList = + employers.length > 0 ? employers.join(", ") : "no current employer listed"; + + if (checks.isSamePerson) { + return [ + { + kind: "linkedin.employer-and-name", + detail: `${name} at ${employerList}; name is consistent with ${email}`, + sourceUrl: profile.profileUrl, + }, + ]; + } + + if (checks.employerMatches) { + return [ + { + kind: "employer-only", + detail: `${name} lists ${companyName}, but the name is not consistent with ${email}`, + sourceUrl: profile.profileUrl, + }, + ]; + } + + return []; +} + +export function identityNextStep(checks: IdentityChecks): string { + if (checks.isSamePerson) { + return "Same person. Call identify_contact with the evidence array from this result."; + } + + return "Not them. Stop. A miss stays a miss — do not call identify_contact for this slug."; +} diff --git a/apps/agent/agent/lib/linkedin-candidates.ts b/apps/agent/agent/lib/linkedin-candidates.ts new file mode 100644 index 000000000..4da0382d3 --- /dev/null +++ b/apps/agent/agent/lib/linkedin-candidates.ts @@ -0,0 +1,91 @@ +import { search, type SearchResult } from "./context-dev"; +import { slugFromProfileUrl } from "./linkdapi"; +import { searchTerms } from "./names"; + +const MAX_CANDIDATES = 5; + +export function linkedInSlugsFromText(text: string): string[] { + const slugs: string[] = []; + + for (const match of text.matchAll( + /linkedin\.com\/in\/([A-Za-z0-9\-_%]+)/gi, + )) { + const raw = match[1]; + if (!raw) continue; + + const slug = decodeURIComponent(raw).replace(/\/+$/, "").toLowerCase(); + if (slug && !slugs.includes(slug)) slugs.push(slug); + } + + return slugs; +} + +export function linkedInSlugsFromResults(results: SearchResult[]): string[] { + const slugs: string[] = []; + + const add = (slug: string | null) => { + if (!slug) return; + const normalised = slug.replace(/\/+$/, "").toLowerCase(); + if (normalised && !slugs.includes(normalised)) slugs.push(normalised); + }; + + for (const result of results) { + add(slugFromProfileUrl(result.url)); + + const haystack = [result.title, result.description, result.markdown] + .filter(Boolean) + .join("\n"); + + for (const slug of linkedInSlugsFromText(haystack)) add(slug); + } + + return slugs; +} + +export function linkedInSearchQuery(term: string, companyName: string): string { + const company = companyName.trim(); + const quoted = company.includes(" ") ? `"${company}"` : company; + return `site:linkedin.com/in ${term} ${quoted}`.trim(); +} + +export async function findLinkedInCandidates( + email: string, + companyName: string, +): Promise<{ + searchedFor: string[]; + candidateSlugs: string[]; + note?: string; +}> { + const local = email.split("@")[0] ?? ""; + const terms = searchTerms(local); + const slugs: string[] = []; + + for (const term of terms) { + const outcome = await search(linkedInSearchQuery(term, companyName), { + limit: 10, + }); + + if (outcome.outcome !== "found") { + return { + searchedFor: terms, + candidateSlugs: [], + note: outcome.reason, + }; + } + + for (const slug of linkedInSlugsFromResults(outcome.results)) { + if (!slugs.includes(slug)) slugs.push(slug); + } + + if (slugs.length >= MAX_CANDIDATES) break; + } + + return { + searchedFor: terms, + candidateSlugs: slugs.slice(0, MAX_CANDIDATES), + note: + slugs.length === 0 + ? "No LinkedIn candidates. A miss stays a miss — do not invent a profile." + : "Unverified. Each slug must be checked with get_linkedin_profile.", + }; +} diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index 4f1c235da..e3d2a4c97 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -81,30 +81,3 @@ export async function ask( } } -export async function findProfileUrls( - terms: string[], - companyName: string, -): Promise { - const slugs: string[] = []; - - for (const term of terms) { - const answer = await ask( - `Find the LinkedIn profile of the person called "${term}" who works at ${companyName}. Reply with their profile URL only.`, - { domains: ["linkedin.com"] }, - ); - - if (!answer.ok) continue; - - const haystack = [answer.data.text, ...answer.data.citations].join(" "); - for (const match of haystack.matchAll( - /linkedin\.com\/in\/([A-Za-z0-9\-_%]+)/g, - )) { - const slug = match[1]; - if (slug && !slugs.includes(slug)) slugs.push(slug); - } - - if (slugs.length > 0) break; - } - - return slugs; -} diff --git a/apps/agent/agent/skills/evidence.md b/apps/agent/agent/skills/evidence.md index 6329dc091..936c79757 100644 --- a/apps/agent/agent/skills/evidence.md +++ b/apps/agent/agent/skills/evidence.md @@ -52,12 +52,21 @@ arithmetic this system exists to avoid. ## What happens next, so you can stop guessing about it -- Primary source and a high score → **written to the record.** -- Otherwise → **stored as a suggestion** under the empty field, for a rep. -- Weak → kept but never shown. -- Nothing → not stored. - -A suggestion is a good outcome. It is often the *correct* outcome: four Marchettis -work at Fernhill and a human settles that in three seconds. Do not go looking for +The write path is `record_fact` / `identify_contact`. It scores evidence, then +applies `fillsBlank` — you do not choose the outcome. + +- Below the floor (no band) → **not stored.** A miss stays a miss. +- Clears the floor and the field is blank (or a derived placeholder name) → + **written**, any band that cleared the floor. Approving a sourced guess into + an empty field is free for the rep; the system does that write. +- Clears the floor but a human or prior value already fills the field, and the + band is not VERIFIED → **proposal** for a rep. Only VERIFIED replaces a filled + field. +- A human-typed value always wins; dismissed values are never re-offered. + +`employer-only` alone is deliberately below the floor. That is how a colleague +at the same company is kept off the record. + +A proposal is a good outcome when the field already has a value. Do not hunt for extra evidence to push a claim over a line — that is how a wrong answer gets dressed up as a right one. diff --git a/apps/agent/agent/skills/identity-matching.md b/apps/agent/agent/skills/identity-matching.md index e5ff9f66f..f38db20a5 100644 --- a/apps/agent/agent/skills/identity-matching.md +++ b/apps/agent/agent/skills/identity-matching.md @@ -23,6 +23,10 @@ the profile. That is the shape of every match: guess where to look, never what you will find. +LinkDAPI is an **enricher, not a finder**. It reads a known slug superbly and +must never be used to search people by name (that search returns strangers). +Finding the slug is Context web search (`site:linkedin.com/in`). + ## The procedure 0. **`read_crm_history` first.** It is free and it is often decisive. If they @@ -30,20 +34,22 @@ That is the shape of every match: guess where to look, never what you will find. evidence available anywhere — `crm.thread-reply` — and a signature block may hand you their title as well. Start every match here, not at a search engine. 1. **`resolve_linkedin_profile`** with the email and company. It decomposes the - local part and returns candidate slugs. These are leads, not answers. + local part, runs Context `site:linkedin.com/in` search, and returns candidate + slugs. These are leads, not answers. An empty list is a finished answer. 2. **`get_linkedin_profile`** on each candidate, passing the email, company name - and domain — **and the `contactId`**. It returns the profile *and a verdict*. - Passing the id is what lets it copy their photograph, which it does only if - the verdict comes back positive, in code, without asking you. Leaving it out - costs the contact their picture and saves nothing. + and domain — **and the `contactId`**. It returns the profile, a code verdict, + and a ready **`evidence` array**. Passing the id is what lets it copy their + photograph, which it does only if the verdict comes back positive, in code, + without asking you. Leaving it out costs the contact their picture and saves + nothing. 3. **Read the verdict, not the profile.** It checks two things: - `employerMatches` — a current position matches the company we have. - `nameMatches` — the real name is consistent with the email local part (`y` + `okonkwo` → Tomi Okonkwo). 4. **Both, or it is not them.** One of the two is not a weaker match, it is a - different person who happens to share something. + different person who happens to share something. `isSamePerson` is false. 5. If no candidate passes, **stop**. Leaving "Pmarchetti" in the CRM is the correct - outcome when you do not know. + outcome when you do not know. A miss stays a miss. Somebody whose LinkedIn URL is **already on the record** has been through all of this before. Do not re-run it to get a picture — `fetch_contact_photo` is one @@ -51,20 +57,26 @@ call, and the URL sitting there is the verification. ## Reporting the match -Call `identify_contact` with what you actually saw: +Only call `identify_contact` when the tool says to. Pass **the `evidence` array +returned by `get_linkedin_profile`** (or CRM kinds you actually observed). Do not +invent kinds to raise a band. -| What you have | Evidence to record | What happens | +| What you have | Evidence to record | What the write path does | | --- | --- | --- | -| Both checks pass | `linkedin.employer-and-name` | Written to the record. | -| They replied from that address | `crm.thread-reply` | Written to the record. | -| One check passes | `employer-only`, or the profile as `search.cites-profile` | Offered to a rep as a suggestion. | +| `isSamePerson` true | tool's `linkedin.employer-and-name` | Clears the VERIFIED floor; writes the name when no human owns it. | +| They replied from that address | `crm.thread-reply` | Clears the VERIFIED floor; same write rule. | +| Email shown on the profile | `profile.email-match` | Clears the VERIFIED floor; same write rule. | +| Employer only, name fails | tool's `employer-only` | Below the keep floor. **Not stored.** Stop. | +| Name only, employer fails | empty evidence | Not them. **Do not identify.** Stop. | | Sources disagree | add a `contradiction` entry | Held. Nobody is shown a guess. | -The middle row is the case this exists for. Four Marchettis work at Fernhill; a -human settles that in three seconds, and the old rule — throw away anything -short of certain — meant we paid for that lookup every run and learned nothing -from it. A suggestion is not a failed match. It is the match, handed to the one -person who can finish it. +`record_fact` / `identify_contact` price evidence in code (`lib/evidence.ts`). +You never set a confidence score. Bands and `fillsBlank` decide apply vs propose: + +- Below the floor → not stored (miss stays a miss). +- Clears the floor and the field is blank (or a derived placeholder name) → applied. +- Clears the floor but a value is already filled and the band is not VERIFIED → proposal for a rep. +- A human-typed name always wins over agent evidence. Do not add evidence you did not observe to push a claim over a line. @@ -77,9 +89,11 @@ Do not add evidence you did not observe to push a claim over a line. The surname or the employer has to carry it. - **Perplexity's view of somebody's job title.** It aggregates stale sources; it said "Account Executive L3" for a profile that reads "Growth Specialist at - Fernhill". For identity, the person's own profile wins. + Fernhill". For identity, the person's own profile wins. Perplexity is not the + slug finder. - **A very plausible expansion.** `jsmith` is probably J. Smith. Probably is not a source. +- **LinkDAPI people search.** Broken; returns unrelated people. Never use it. ## When the person genuinely is not findable diff --git a/apps/agent/agent/tools/get_linkedin_profile.ts b/apps/agent/agent/tools/get_linkedin_profile.ts index f52e21753..6390ce589 100644 --- a/apps/agent/agent/tools/get_linkedin_profile.ts +++ b/apps/agent/agent/tools/get_linkedin_profile.ts @@ -2,13 +2,17 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; import { enabled, unavailable } from "../lib/capabilities"; import { spend } from "../lib/focus"; +import { + identityChecks, + identityEvidence, + identityNextStep, +} from "../lib/identity-verdict"; import { getExperience, getProfile } from "../lib/linkdapi"; -import { looksLikeSameCompany, nameMatchesLocalPart } from "../lib/names"; import { storePortrait } from "../lib/portrait"; export default defineTool({ description: - "Read a LinkedIn profile by slug and check whether it is really the person behind an email address. Returns the profile plus an explicit verdict.", + "Read a LinkedIn profile by slug (LinkDAPI enricher) and check whether it is really the person behind an email address. Returns the profile, a code verdict, and priced evidence. Do not invent evidence kinds — use the evidence array as returned.", inputSchema: z.object({ slug: z.string().describe("The linkedin.com/in/ handle."), email: z.string().describe("The address we are trying to identify."), @@ -48,20 +52,24 @@ export default defineTool({ } const profile = result.data; - const local = email.split("@")[0] ?? ""; - - const employerMatches = profile.positions.some((position) => - looksLikeSameCompany(position.name, companyName, companyDomain), + const verdict = identityChecks( + profile, + email, + companyName, + companyDomain, + ); + const evidence = identityEvidence( + profile, + verdict, + email, + companyName, ); - const nameMatches = nameMatchesLocalPart(profile, local); const history = includeHistory && profile.urn ? await getExperience(profile.urn) : null; - const isSamePerson = employerMatches && nameMatches; - const portrait = - contactId && isSamePerson + contactId && verdict.isSamePerson ? await storePortrait({ contactId, sourceUrl: profile.photoUrl, @@ -74,17 +82,9 @@ export default defineTool({ profile, experience: history?.ok ? history.data : null, photo: portrait ?? undefined, - verdict: { - employerMatches, - nameMatches, - isSamePerson, - confidence: - employerMatches && nameMatches - ? ("high" as const) - : employerMatches || nameMatches - ? ("medium" as const) - : ("low" as const), - }, + verdict, + evidence, + next: identityNextStep(verdict), }; }, }); diff --git a/apps/agent/agent/tools/identify_contact.ts b/apps/agent/agent/tools/identify_contact.ts index e31f7d281..c68013185 100644 --- a/apps/agent/agent/tools/identify_contact.ts +++ b/apps/agent/agent/tools/identify_contact.ts @@ -8,7 +8,7 @@ import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: - "Put a verified name to a CRM contact, with the evidence for it. Strong evidence writes the name; anything less becomes a suggestion for a rep. Never overwrites a name a person supplied.", + "Put a name to a CRM contact with priced evidence. Prefer the evidence array from get_linkedin_profile. VERIFIED or a blank/placeholder name writes through; weaker evidence against a filled name becomes a proposal; below the floor is not stored. Never overwrites a name a person typed. Never invent evidence kinds.", inputSchema: z.object({ contactId: z.string(), fullName: z.string().describe("Exactly as the source writes it."), diff --git a/apps/agent/agent/tools/resolve_linkedin_profile.ts b/apps/agent/agent/tools/resolve_linkedin_profile.ts index b1432146d..8fadb7d4d 100644 --- a/apps/agent/agent/tools/resolve_linkedin_profile.ts +++ b/apps/agent/agent/tools/resolve_linkedin_profile.ts @@ -1,33 +1,30 @@ import { defineTool } from "eve/tools"; import { z } from "zod"; -import { enabled, unavailable } from "../lib/capabilities"; +import { CONTEXT_DEV, enabled, unavailable } from "../lib/capabilities"; import { spend } from "../lib/focus"; -import { searchTerms } from "../lib/names"; -import { findProfileUrls } from "../lib/perplexity"; +import { findLinkedInCandidates } from "../lib/linkedin-candidates"; export default defineTool({ description: - "Find candidate LinkedIn profile slugs for a work email address. Returns CANDIDATES ONLY — you must verify each with get_linkedin_profile before believing any of them.", + "Find candidate LinkedIn profile slugs for a work email via Context web search (site:linkedin.com/in). Returns CANDIDATES ONLY — never a match. Verify each with get_linkedin_profile. LinkDAPI is not used here; it enriches a known slug only.", inputSchema: z.object({ email: z.string().describe("The contact's work email address."), companyName: z.string().describe("The company the CRM has them at."), }), async execute({ email, companyName }) { - if (!(await enabled("PERPLEXITY_API_KEY"))) { - return { candidateSlugs: [], ...unavailable("PERPLEXITY_API_KEY") }; + if (!(await enabled(CONTEXT_DEV))) { + return { candidateSlugs: [], ...unavailable(CONTEXT_DEV) }; } const charge = spend(); if (!charge.ok) return { candidateSlugs: [], note: charge.reason }; - const local = email.split("@")[0] ?? ""; - const terms = searchTerms(local); - const slugs = await findProfileUrls(terms, companyName); + const found = await findLinkedInCandidates(email, companyName); return { - searchedFor: terms, - candidateSlugs: slugs.slice(0, 5), - note: "Unverified. Each slug must be checked with get_linkedin_profile.", + searchedFor: found.searchedFor, + candidateSlugs: found.candidateSlugs, + note: found.note, }; }, }); diff --git a/apps/agent/test/evidence.spec.ts b/apps/agent/test/evidence.spec.ts index 2c215accf..550454f25 100644 --- a/apps/agent/test/evidence.spec.ts +++ b/apps/agent/test/evidence.spec.ts @@ -16,6 +16,16 @@ describe("scoreEvidence", () => { expect(scored.hasPrimary).toBe(true); }); + it("writes when LinkedIn employer and name both match", () => { + const scored = scoreEvidence(of("linkedin.employer-and-name")); + expect(scored.band).toBe("VERIFIED"); + expect(scored.hasPrimary).toBe(true); + }); + + it("keeps employer-only below the floor so strangers are not stored", () => { + expect(scoreEvidence(of("employer-only")).band).toBeNull(); + }); + it("treats our own mailbox as primary evidence", () => { expect(scoreEvidence(of("crm.thread-reply")).band).toBe("VERIFIED"); }); diff --git a/apps/agent/test/identity-verdict.spec.ts b/apps/agent/test/identity-verdict.spec.ts new file mode 100644 index 000000000..3ed381cf2 --- /dev/null +++ b/apps/agent/test/identity-verdict.spec.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "bun:test"; +import { scoreEvidence } from "../agent/lib/evidence"; +import { fillsBlank } from "../agent/lib/facts"; +import { + identityChecks, + identityEvidence, + identityNextStep, +} from "../agent/lib/identity-verdict"; + +const tomi = { + firstName: "Tomi", + lastName: "Okonkwo", + fullName: "Tomi Okonkwo", + profileUrl: "https://www.linkedin.com/in/tomi-okonkwo", + positions: [{ name: "Northwind Bank", url: null }], +}; + +const stranger = { + firstName: "Antonio", + lastName: "Fontana", + fullName: "Antonio Fontana", + profileUrl: "https://www.linkedin.com/in/antonio-fontana", + positions: [{ name: "Northwind Bank", url: null }], +}; + +const wrongCompany = { + firstName: "Tomi", + lastName: "Okonkwo", + fullName: "Tomi Okonkwo", + profileUrl: "https://www.linkedin.com/in/tomi-elsewhere", + positions: [{ name: "Brightwater Group", url: null }], +}; + +describe("identityChecks", () => { + it("requires employer and name together", () => { + expect( + identityChecks(tomi, "tokonkwo@northwind.com", "Northwind", "northwind.com"), + ).toEqual({ + employerMatches: true, + nameMatches: true, + isSamePerson: true, + }); + + expect( + identityChecks( + stranger, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), + ).toEqual({ + employerMatches: true, + nameMatches: false, + isSamePerson: false, + }); + + expect( + identityChecks( + wrongCompany, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), + ).toEqual({ + employerMatches: false, + nameMatches: true, + isSamePerson: false, + }); + }); +}); + +describe("identityEvidence", () => { + it("prices a full match as linkedin.employer-and-name at VERIFIED", () => { + const checks = identityChecks( + tomi, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + tomi, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence).toHaveLength(1); + expect(evidence[0]?.kind).toBe("linkedin.employer-and-name"); + expect(scoreEvidence(evidence).band).toBe("VERIFIED"); + expect(identityNextStep(checks)).toContain("identify_contact"); + }); + + it("prices employer-only below the keep floor so a miss stays a miss", () => { + const checks = identityChecks( + stranger, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + stranger, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence[0]?.kind).toBe("employer-only"); + expect(scoreEvidence(evidence).band).toBeNull(); + expect(identityNextStep(checks)).toContain("miss stays a miss"); + }); + + it("returns no identity evidence when only the name fits", () => { + const checks = identityChecks( + wrongCompany, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ); + const evidence = identityEvidence( + wrongCompany, + checks, + "tokonkwo@northwind.com", + "Northwind", + ); + + expect(evidence).toEqual([]); + expect(scoreEvidence(evidence).band).toBeNull(); + expect(identityNextStep(checks)).toContain("Stop"); + }); +}); + +describe("fillsBlank for identity names", () => { + it("treats a derived email placeholder as blank for the agent", () => { + expect( + fillsBlank({ + field: "name", + contact: { + email: "tokonkwo@northwind.com", + firstName: "Tokonkwo", + lastName: null, + }, + hasAgentFact: false, + }), + ).toBe(true); + }); + + it("does not treat a human-typed name as blank", () => { + expect( + fillsBlank({ + field: "name", + contact: { + email: "tokonkwo@northwind.com", + firstName: "Tomi", + lastName: "Okonkwo", + }, + hasAgentFact: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/agent/test/linkedin-candidates.spec.ts b/apps/agent/test/linkedin-candidates.spec.ts new file mode 100644 index 000000000..4532192f3 --- /dev/null +++ b/apps/agent/test/linkedin-candidates.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "bun:test"; +import { + linkedInSearchQuery, + linkedInSlugsFromResults, + linkedInSlugsFromText, +} from "../agent/lib/linkedin-candidates"; + +describe("linkedInSlugsFromText", () => { + it("extracts profile handles from mixed text", () => { + expect( + linkedInSlugsFromText( + "See https://www.linkedin.com/in/tomi-okonkwo and linkedin.com/in/PaulaMarchetti/", + ), + ).toEqual(["tomi-okonkwo", "paulamarchetti"]); + }); + + it("deduplicates the same handle", () => { + expect( + linkedInSlugsFromText( + "https://linkedin.com/in/jane-doe https://www.linkedin.com/in/jane-doe/", + ), + ).toEqual(["jane-doe"]); + }); + + it("ignores non-profile linkedin urls", () => { + expect( + linkedInSlugsFromText("https://www.linkedin.com/company/northwind"), + ).toEqual([]); + }); +}); + +describe("linkedInSlugsFromResults", () => { + it("reads the result url and body text", () => { + const slugs = linkedInSlugsFromResults([ + { + url: "https://www.linkedin.com/in/tokonkwo", + title: "Tomi Okonkwo", + description: null, + markdown: null, + }, + { + url: "https://example.com/blog", + title: "Also mentioned", + description: "Profile at linkedin.com/in/other-person", + markdown: null, + }, + ]); + + expect(slugs).toEqual(["tokonkwo", "other-person"]); + }); +}); + +describe("linkedInSearchQuery", () => { + it("scopes search to linkedin profiles with the company", () => { + expect(linkedInSearchQuery("okonkwo", "Northwind")).toBe( + "site:linkedin.com/in okonkwo Northwind", + ); + expect(linkedInSearchQuery("marchetti", "Fernhill Bank")).toBe( + 'site:linkedin.com/in marchetti "Fernhill Bank"', + ); + }); +}); diff --git a/docs/environment.md b/docs/environment.md index aca350183..38ce541d0 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -111,7 +111,7 @@ single place that knows what is set. | Variable | What it adds | | --- | --- | -| `PERPLEXITY_API_KEY` | Open-web research with citations; finds a LinkedIn slug | +| `PERPLEXITY_API_KEY` | Open-web research with citations (not identity slug search) | | `RAPIDAPI_KEY` | LinkedIn profiles via LinkDAPI | | `GITHUB_TOKEN` | Raises the GitHub rate limit from 60/hour | | `BLOB_READ_WRITE_TOKEN` | Mirrors logos and photos into Blob | From ae77da290bc9d79c62f8db1f1a38928fb2b42630 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:07:27 -0400 Subject: [PATCH 06/22] feat(agent): pin portrait lane soft-fail and 30-day stand-down Export PORTRAIT_STAND_DOWN_MS next to priority 800, skip photo backfill when BLOB_READ_WRITE_TOKEN is missing, and pin source-chain and blob soft-fail tests so faces only come from person-tied sources. --- apps/agent/test/images.spec.ts | 75 ++++++++++++++++++++++- apps/agent/test/lanes.integration.spec.ts | 20 +++++- apps/api/src/backfill/backfill.service.ts | 57 ++++++++--------- packages/db/src/agent-tasks.ts | 4 ++ 4 files changed, 125 insertions(+), 31 deletions(-) diff --git a/apps/agent/test/images.spec.ts b/apps/agent/test/images.spec.ts index aef9e8e05..cb51c17a8 100644 --- a/apps/agent/test/images.spec.ts +++ b/apps/agent/test/images.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { profilePhotoUrl, slugFromProfileUrl } from "../agent/lib/linkdapi"; +import { runPortrait, storePortrait } from "../agent/lib/portrait"; import { findPortrait } from "../agent/lib/portrait-sources"; const OVERVIEW = { @@ -128,4 +129,76 @@ describe("the portrait source chain", () => { expect(result.found).toBe(false); if (!result.found) expect(result.reason).toBe("Out of budget."); }); + + it("does not charge for a free GitHub headshot", async () => { + let spent = 0; + const result = await findPortrait( + { ...NOBODY, githubUrl: "https://github.com/pmarchetti" }, + (units = 1) => { + spent += units; + return { ok: true }; + }, + ); + + expect(result.found).toBe(true); + expect(spent).toBe(0); + }); + + it("never treats a bare name as a place to look", async () => { + const result = await findPortrait( + { ...NOBODY, name: "Paula Marchetti", companyName: "Acme" }, + free, + ); + + expect(result.found).toBe(false); + if (!result.found) expect(result.tried).toEqual([]); + }); +}); + +describe("portrait soft-fail without BLOB_READ_WRITE_TOKEN", () => { + const saved = process.env.BLOB_READ_WRITE_TOKEN; + + beforeEach(() => { + delete process.env.BLOB_READ_WRITE_TOKEN; + }); + + afterEach(() => { + if (saved === undefined) delete process.env.BLOB_READ_WRITE_TOKEN; + else process.env.BLOB_READ_WRITE_TOKEN = saved; + }); + + it("storePortrait refuses to keep an origin URL and does not throw", async () => { + const result = await storePortrait({ + contactId: "c-no-blob", + sourceUrl: "https://media.licdn.com/dms/image/x.jpg", + verified: true, + }); + + expect(result.stored).toBe(false); + expect(result.imageUrl).toBe(null); + expect(result.reason).toContain("BLOB_READ_WRITE_TOKEN"); + expect(result.reason?.toLowerCase()).toContain("retrying will not help"); + }); + + it("runPortrait stops before looking when there is nowhere to store a copy", async () => { + const result = await runPortrait({ + contactId: "c-no-blob", + spend: () => ({ ok: true }), + }); + + expect(result.stored).toBe(false); + expect(result.imageUrl).toBe(null); + expect(result.reason).toContain("BLOB_READ_WRITE_TOKEN"); + }); + + it("storePortrait still refuses an unverified face without needing storage", async () => { + const result = await storePortrait({ + contactId: "c-no-blob", + sourceUrl: "https://media.licdn.com/dms/image/x.jpg", + verified: false, + }); + + expect(result.stored).toBe(false); + expect(result.reason).toContain("not established"); + }); }); diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index a2cde1ce9..d58b7da95 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { db } from "@crm/db"; -import { DIRECT_KINDS, isDirectKind, PRIORITY } from "@crm/db/agent-tasks"; +import { + DIRECT_KINDS, + isDirectKind, + PORTRAIT_STAND_DOWN_MS, + PRIORITY, +} from "@crm/db/agent-tasks"; import { claimDue } from "../agent/lib/tasks"; const REASON = "lane-test"; @@ -107,9 +112,20 @@ describe("kind vocabulary", () => { }); it("puts what a rep sees first above what they have to click for", () => { - expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.requested); + expect(PRIORITY.brand).toBe(900); + expect(PRIORITY.portrait).toBe(800); + expect(PRIORITY.brand).toBeGreaterThan(PRIORITY.portrait); expect(PRIORITY.portrait).toBeGreaterThan(PRIORITY.requested); expect(PRIORITY.requested).toBeGreaterThan(PRIORITY.companyProfile); expect(PRIORITY.companyProfile).toBeGreaterThan(PRIORITY.recheck); }); + + it("puts portrait on the visible direct lane", () => { + expect(DIRECT_KINDS).toContain("portrait"); + expect(isDirectKind("portrait")).toBe(true); + }); + + it("stands a finished portrait down for thirty days before looking again", () => { + expect(PORTRAIT_STAND_DOWN_MS).toBe(30 * 24 * 60 * 60 * 1000); + }); }); diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 8f4b96071..4a63b5d74 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -1,6 +1,7 @@ import { onSignedIn } from "@crm/auth"; import { type Db, EnrichmentStatus, type Prisma } from "@crm/db"; -import { PRIORITY } from "@crm/db/agent-tasks"; +import { PORTRAIT_STAND_DOWN_MS, PRIORITY } from "@crm/db/agent-tasks"; +import { blobEnabled } from "@crm/db/blob"; import { readWorkspaceIdentity } from "@crm/db/workspace"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; @@ -31,14 +32,7 @@ const AUTO_KEY = "backfill:auto"; const AUTO_EVERY_MS = 5 * 60_000; -/** - * How long a fruitless photo search stands the contact down for. - * - * Long, because the answer rarely changes: somebody with no LinkedIn account - * and no headshot on their employer's site is unlikely to acquire either this - * week, and the team-page read costs credits every time it is asked. - */ -const RECHECK_PHOTO_AFTER_MS = 30 * 24 * 60 * 60_000; +const RECHECK_PHOTO_AFTER_MS = PORTRAIT_STAND_DOWN_MS; const RECHECK_BRAND_AFTER_MS = 30 * 24 * 60 * 60_000; @@ -182,25 +176,32 @@ export class BackfillService implements OnModuleInit { } private async runContacts(): Promise { - const needsPhoto = await this.contactsNeedingPhoto(); - - const [photoTotal, photoRows] = await Promise.all([ - this.db.contact.count({ where: needsPhoto }), - this.db.contact.findMany({ - where: needsPhoto, - orderBy: { createdAt: "asc" }, - take: MAX_PER_RUN, - select: { id: true }, - }), - ]); - - const photos = await this.agent.backfill({ - kind: "portrait", - reason: "Backfill — somewhere to look for a picture, and no picture", - contactIds: photoRows.map((row) => row.id), - budget: 1, - priority: PRIORITY.portrait, - }); + const photosEnabled = blobEnabled(); + const needsPhoto = photosEnabled + ? await this.contactsNeedingPhoto() + : null; + + const [photoTotal, photoRows] = needsPhoto + ? await Promise.all([ + this.db.contact.count({ where: needsPhoto }), + this.db.contact.findMany({ + where: needsPhoto, + orderBy: { createdAt: "asc" }, + take: MAX_PER_RUN, + select: { id: true }, + }), + ]) + : [0, [] as { id: string }[]]; + + const photos = needsPhoto + ? await this.agent.backfill({ + kind: "portrait", + reason: "Backfill — somewhere to look for a picture, and no picture", + contactIds: photoRows.map((row) => row.id), + budget: 1, + priority: PRIORITY.portrait, + }) + : { queued: 0, alreadyQueued: 0 }; const headroom = MAX_PER_RUN - photoRows.length; diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts index 33f60eb17..d46b40170 100644 --- a/packages/db/src/agent-tasks.ts +++ b/packages/db/src/agent-tasks.ts @@ -48,3 +48,7 @@ export const PRIORITY = { slackJoin: 950, event: 700, } as const; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export const PORTRAIT_STAND_DOWN_MS = 30 * DAY_MS; From 2f3861bd44a3cf1bbd7602989bf9751a3fe3248f Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:07:22 -0400 Subject: [PATCH 07/22] feat: detect stalled deals and raise owner tasks without an LLM Open deals with no activity for 14 days enqueue a stalled-deal AgentTask. The direct lane raises one owner Activity task and never calls the model. --- apps/agent/agent/lib/dispatch.ts | 6 + apps/agent/agent/lib/stalled-deal.ts | 66 ++++++++ apps/agent/test/lanes.integration.spec.ts | 1 + .../test/stalled-deal.integration.spec.ts | 127 +++++++++++++++ apps/api/src/agent/agent-trigger.service.ts | 16 +- apps/api/src/deals/deals.module.ts | 5 +- apps/api/src/deals/stalled-deals.config.ts | 6 + apps/api/src/deals/stalled-deals.service.ts | 125 ++++++++++++++ apps/api/test/stalled-deals.spec.ts | 152 ++++++++++++++++++ packages/db/package.json | 1 + packages/db/src/agent-tasks.ts | 3 + packages/db/src/stalled-deals.ts | 57 +++++++ packages/db/test/stalled-deals.spec.ts | 113 +++++++++++++ 13 files changed, 675 insertions(+), 3 deletions(-) create mode 100644 apps/agent/agent/lib/stalled-deal.ts create mode 100644 apps/agent/test/stalled-deal.integration.spec.ts create mode 100644 apps/api/src/deals/stalled-deals.config.ts create mode 100644 apps/api/src/deals/stalled-deals.service.ts create mode 100644 apps/api/test/stalled-deals.spec.ts create mode 100644 packages/db/src/stalled-deals.ts create mode 100644 packages/db/test/stalled-deals.spec.ts diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..df8d573a4 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -9,6 +9,7 @@ import { collapsing, runLimited } from "./pool"; import { runPortrait } from "./portrait"; import { runSlackChannelJoin } from "./slack-join-task"; import { runSlackPeopleMatch } from "./slack-people"; +import { flagStalledDeal } from "./stalled-deal"; import { claimDue, completeTask, @@ -146,6 +147,11 @@ async function handleDirect(task: LeasedTask): Promise { return; } + if (task.kind === "stalled-deal") { + await completeTask(task.id, await flagStalledDeal(task)); + return; + } + await completeTask(task.id, "The record this names is gone."); } diff --git a/apps/agent/agent/lib/stalled-deal.ts b/apps/agent/agent/lib/stalled-deal.ts new file mode 100644 index 000000000..ab83b86f3 --- /dev/null +++ b/apps/agent/agent/lib/stalled-deal.ts @@ -0,0 +1,66 @@ +import { ActivityType, db } from "@crm/db"; +import { isClosedStage } from "@crm/db/deal-stage"; +import { + daysInactive, + STALLED_DEAL, + stallTaskSubject, +} from "@crm/db/stalled-deals"; +import type { LeasedTask } from "./tasks"; + +export async function flagStalledDeal(task: LeasedTask): Promise { + if (!task.dealId) return "No deal on this task."; + + const deal = await db.deal.findUnique({ + where: { id: task.dealId }, + select: { + id: true, + name: true, + stage: true, + companyId: true, + ownerId: true, + createdAt: true, + lastActivityAt: true, + }, + }); + + if (!deal) return "The deal this names is gone."; + if (isClosedStage(deal.stage)) return "The deal is closed."; + + const existing = await db.activity.findFirst({ + where: { + dealId: deal.id, + type: ActivityType.TASK, + completedAt: null, + meta: { path: ["source"], equals: STALLED_DEAL.source }, + }, + select: { id: true }, + }); + + if (existing) return "Owner already has an open stalled-deal task."; + + const now = new Date(); + const days = daysInactive({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + }); + + await db.activity.create({ + data: { + type: ActivityType.TASK, + subject: stallTaskSubject(deal.name), + body: task.reason, + occurredAt: now, + dueAt: now, + companyId: deal.companyId, + dealId: deal.id, + createdById: deal.ownerId, + meta: { + source: STALLED_DEAL.source, + daysInactive: days, + }, + }, + }); + + return "Raised an owner task for the stalled deal."; +} diff --git a/apps/agent/test/lanes.integration.spec.ts b/apps/agent/test/lanes.integration.spec.ts index a2cde1ce9..b40908abd 100644 --- a/apps/agent/test/lanes.integration.spec.ts +++ b/apps/agent/test/lanes.integration.spec.ts @@ -101,6 +101,7 @@ describe("kind vocabulary", () => { it("agrees on which kinds skip the model", () => { expect(isDirectKind("brand")).toBe(true); expect(isDirectKind("portrait")).toBe(true); + expect(isDirectKind("stalled-deal")).toBe(true); expect(isDirectKind("company-profile")).toBe(false); expect(isDirectKind("identify")).toBe(false); expect(isDirectKind("workspace-profile")).toBe(false); diff --git a/apps/agent/test/stalled-deal.integration.spec.ts b/apps/agent/test/stalled-deal.integration.spec.ts new file mode 100644 index 000000000..d0f31ac21 --- /dev/null +++ b/apps/agent/test/stalled-deal.integration.spec.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ActivityType, DealStage, db } from "@crm/db"; +import { PRIORITY } from "@crm/db/agent-tasks"; +import { STALLED_DEAL } from "@crm/db/stalled-deals"; +import { flagStalledDeal } from "../agent/lib/stalled-deal"; +import type { LeasedTask } from "../agent/lib/tasks"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `flag-stalled-${suffix}.test`; +const ownerId = `flag-owner-${suffix}`; +const staleAt = new Date("2026-07-20T12:00:00.000Z"); + +let companyId = ""; +let dealId = ""; + +function task(over: Partial = {}): LeasedTask { + return { + id: `task-${suffix}`, + contactId: null, + companyId: null, + dealId, + kind: STALLED_DEAL.kind, + reason: "Stale Renewal has had no activity for 23 days.", + payload: null, + budget: 1, + attempts: 1, + priority: PRIORITY.stalledDeal, + dueAt: new Date(), + ...over, + }; +} + +async function clean() { + if (dealId) { + await db.activity.deleteMany({ where: { dealId } }); + await db.deal.deleteMany({ where: { id: dealId } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Flag Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Flag Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const deal = await db.deal.create({ + data: { + name: `Stale Renewal ${suffix}`, + companyId, + ownerId, + stage: DealStage.CONTRACT_SENT, + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + dealId = deal.id; +}); + +afterAll(clean); + +describe("flagStalledDeal", () => { + it("creates one owner task and leaves lastActivityAt alone", async () => { + const before = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { lastActivityAt: true }, + }); + + const first = await flagStalledDeal(task()); + expect(first).toBe("Raised an owner task for the stalled deal."); + + const activities = await db.activity.findMany({ + where: { + dealId, + type: ActivityType.TASK, + completedAt: null, + }, + select: { + subject: true, + createdById: true, + meta: true, + }, + }); + + expect(activities).toHaveLength(1); + expect(activities[0]?.createdById).toBe(ownerId); + expect(activities[0]?.subject).toContain("Re-engage:"); + expect(activities[0]?.meta).toMatchObject({ + source: STALLED_DEAL.source, + }); + + const after = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { lastActivityAt: true }, + }); + expect(after.lastActivityAt?.getTime()).toBe( + before.lastActivityAt?.getTime(), + ); + + const second = await flagStalledDeal(task()); + expect(second).toBe("Owner already has an open stalled-deal task."); + + const open = await db.activity.count({ + where: { + dealId, + type: ActivityType.TASK, + completedAt: null, + meta: { path: ["source"], equals: STALLED_DEAL.source }, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index ea5226eeb..0d169de0c 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -246,6 +246,16 @@ export class AgentTriggerService { }); } + async stalledDeal(dealId: string, reason: string): Promise { + return this.enqueue({ + dealId, + kind: "stalled-deal", + reason, + priority: PRIORITY.stalledDeal, + budget: 1, + }); + } + builderConversationQueued(): void { this.pokeRoute("/internal/crm/builder-dispatch"); } @@ -364,6 +374,7 @@ export class AgentTriggerService { task: { contactId?: string; companyId?: string; + dealId?: string; kind: string; reason: string; priority: number; @@ -378,7 +389,7 @@ export class AgentTriggerService { const write = async (tx: Prisma.TransactionClient) => { await lockIdempotencyKey( tx, - `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.subject?.value ?? ""}`, + `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.dealId ?? ""}:${task.subject?.value ?? ""}`, ); const pending = await tx.agentTask.findFirst({ where: { @@ -386,6 +397,7 @@ export class AgentTriggerService { finishedAt: null, ...(task.contactId ? { contactId: task.contactId } : {}), ...(task.companyId ? { companyId: task.companyId } : {}), + ...(task.dealId ? { dealId: task.dealId } : {}), ...(task.subject ? { payload: { @@ -403,6 +415,7 @@ export class AgentTriggerService { data: { contactId: task.contactId ?? null, companyId: task.companyId ?? null, + dealId: task.dealId ?? null, kind: task.kind, reason: task.reason, priority: task.priority, @@ -424,6 +437,7 @@ export class AgentTriggerService { kind: task.kind, contactId: task.contactId, companyId: task.companyId, + dealId: task.dealId, }); if (!client) this.poke(); diff --git a/apps/api/src/deals/deals.module.ts b/apps/api/src/deals/deals.module.ts index 310aed015..2ad819cba 100644 --- a/apps/api/src/deals/deals.module.ts +++ b/apps/api/src/deals/deals.module.ts @@ -5,10 +5,11 @@ import { FieldsModule } from "../fields/fields.module"; import { TrpcModule } from "../trpc/trpc.module"; import { DealsRouter } from "./deals.router"; import { DealsService } from "./deals.service"; +import { StalledDealsService } from "./stalled-deals.service"; @Module({ imports: [AgentModule, FieldsModule, TrpcModule, CurrencyModule], - providers: [DealsService, DealsRouter], - exports: [DealsService], + providers: [DealsService, DealsRouter, StalledDealsService], + exports: [DealsService, StalledDealsService], }) export class DealsModule {} diff --git a/apps/api/src/deals/stalled-deals.config.ts b/apps/api/src/deals/stalled-deals.config.ts new file mode 100644 index 000000000..f52230ca5 --- /dev/null +++ b/apps/api/src/deals/stalled-deals.config.ts @@ -0,0 +1,6 @@ +const SECOND_MS = 1_000; +const MINUTE_MS = 60 * SECOND_MS; + +export const STALLED_DEALS = { + auto: { everyMs: 15 * MINUTE_MS, cacheKey: "stalled-deals:auto" }, +} as const; diff --git a/apps/api/src/deals/stalled-deals.service.ts b/apps/api/src/deals/stalled-deals.service.ts new file mode 100644 index 000000000..51ebfb0de --- /dev/null +++ b/apps/api/src/deals/stalled-deals.service.ts @@ -0,0 +1,125 @@ +import { onSignedIn } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { + daysInactive, + isStalledDeal, + STALLED_DEAL, + stallCutoff, + stallReason, +} from "@crm/db/stalled-deals"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; +import type { Cache } from "cache-manager"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import { STALLED_DEALS } from "./stalled-deals.config"; + +export type StalledDealSweepResult = { + scanned: number; + queued: number; + alreadyQueued: number; +}; + +@Injectable() +export class StalledDealsService implements OnModuleInit { + private readonly logger = new Logger(StalledDealsService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + onModuleInit(): void { + onSignedIn(() => { + void this.auto(); + }); + } + + async auto(): Promise<{ started: boolean }> { + if (await this.cache.get(STALLED_DEALS.auto.cacheKey)) { + return { started: false }; + } + await this.cache.set( + STALLED_DEALS.auto.cacheKey, + true, + STALLED_DEALS.auto.everyMs, + ); + + void (async () => { + try { + const result = await this.sweep(); + if (result.queued > 0 || result.scanned > 0) { + this.logger.log({ + message: "Stalled-deal sweep finished", + ...result, + }); + } + } catch (error) { + this.logger.error( + { message: "Stalled-deal sweep failed" }, + error instanceof Error ? error.stack : String(error), + ); + } + })(); + + return { started: true }; + } + + async sweep(now = new Date()): Promise { + const cutoff = stallCutoff(now, STALLED_DEAL.inactiveDays); + + const deals = await this.db.deal.findMany({ + where: { + stage: { in: [...OPEN_DEAL_STAGES] }, + OR: [ + { lastActivityAt: { lte: cutoff } }, + { lastActivityAt: null, createdAt: { lte: cutoff } }, + ], + }, + orderBy: [ + { lastActivityAt: { sort: "asc", nulls: "first" } }, + { createdAt: "asc" }, + { id: "asc" }, + ], + take: STALLED_DEAL.maxPerRun, + select: { + id: true, + name: true, + createdAt: true, + lastActivityAt: true, + }, + }); + + let queued = 0; + let alreadyQueued = 0; + + for (const deal of deals) { + if ( + !isStalledDeal({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + inactiveDays: STALLED_DEAL.inactiveDays, + }) + ) { + continue; + } + + const days = daysInactive({ + lastActivityAt: deal.lastActivityAt, + createdAt: deal.createdAt, + now, + }); + const created = await this.agent.stalledDeal( + deal.id, + stallReason(deal.name, days), + ); + if (created) queued += 1; + else alreadyQueued += 1; + } + + return { scanned: deals.length, queued, alreadyQueued }; + } +} diff --git a/apps/api/test/stalled-deals.spec.ts b/apps/api/test/stalled-deals.spec.ts new file mode 100644 index 000000000..878df4117 --- /dev/null +++ b/apps/api/test/stalled-deals.spec.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { STALLED_DEAL } from "@crm/db/stalled-deals"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { StalledDealsService } from "../src/deals/stalled-deals.service"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `stalled-${suffix}.test`; +const ownerId = `stalled-owner-${suffix}`; +const now = new Date("2026-08-12T12:00:00.000Z"); +const staleAt = new Date("2026-07-20T12:00:00.000Z"); +const freshAt = new Date("2026-08-10T12:00:00.000Z"); + +const agent = new AgentTriggerService(db); +const cache = { + store: new Map(), + async get(key: string) { + return this.store.get(key); + }, + async set(key: string, value: unknown) { + this.store.set(key, value); + }, +}; +const service = new StalledDealsService(db, agent, cache as never); + +let companyId = ""; +let stalledDealId = ""; +let freshDealId = ""; +let closedDealId = ""; +let previousBridgeSecret: string | undefined; + +async function clean() { + const deals = [stalledDealId, freshDealId, closedDealId].filter(Boolean); + if (deals.length > 0) { + await db.agentTask.deleteMany({ where: { dealId: { in: deals } } }); + await db.activity.deleteMany({ where: { dealId: { in: deals } } }); + await db.deal.deleteMany({ where: { id: { in: deals } } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; + delete process.env.AGENT_BRIDGE_SECRET; + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Stalled Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Stalled Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const stalled = await db.deal.create({ + data: { + name: `Stale Renewal ${suffix}`, + companyId, + ownerId, + stage: DealStage.QUALIFIED_TO_BUY, + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + stalledDealId = stalled.id; + + const fresh = await db.deal.create({ + data: { + name: `Fresh Deal ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + createdAt: freshAt, + lastActivityAt: freshAt, + }, + select: { id: true }, + }); + freshDealId = fresh.id; + + const closed = await db.deal.create({ + data: { + name: `Closed Old ${suffix}`, + companyId, + ownerId, + stage: DealStage.CLOSED_LOST, + closedAt: staleAt, + closedReason: "budget", + createdAt: staleAt, + lastActivityAt: staleAt, + }, + select: { id: true }, + }); + closedDealId = closed.id; +}); + +afterAll(async () => { + await clean(); + if (previousBridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = previousBridgeSecret; + } +}); + +describe("stalled-deal detection", () => { + it("enqueues one AgentTask for each open stalled deal", async () => { + const first = await service.sweep(now); + + expect(first.scanned).toBeGreaterThanOrEqual(1); + expect(first.queued).toBe(1); + expect(first.alreadyQueued).toBe(0); + + const tasks = await db.agentTask.findMany({ + where: { + kind: STALLED_DEAL.kind, + dealId: { in: [stalledDealId, freshDealId, closedDealId] }, + finishedAt: null, + }, + select: { dealId: true, reason: true, priority: true }, + }); + + expect(tasks).toHaveLength(1); + expect(tasks[0]?.dealId).toBe(stalledDealId); + expect(tasks[0]?.reason).toContain("Stale Renewal"); + expect(tasks[0]?.reason).toContain("no activity"); + }); + + it("does not double-queue while a task is still open", async () => { + const second = await service.sweep(now); + + expect(second.queued).toBe(0); + expect(second.alreadyQueued).toBe(1); + + const open = await db.agentTask.count({ + where: { + kind: STALLED_DEAL.kind, + dealId: stalledDealId, + finishedAt: null, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/packages/db/package.json b/packages/db/package.json index 9ef448183..2217c3112 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,6 +22,7 @@ "./safe-fetch": "./src/safe-fetch.ts", "./settings": "./src/settings.ts", "./slack-inventory": "./src/slack-inventory.ts", + "./stalled-deals": "./src/stalled-deals.ts", "./tracking": "./src/tracking.ts", "./workspace": "./src/workspace.ts" }, diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts index 33f60eb17..1494e8334 100644 --- a/packages/db/src/agent-tasks.ts +++ b/packages/db/src/agent-tasks.ts @@ -11,6 +11,7 @@ export const TASK_KINDS = [ "slack-people-match", "slack-channel-join", "agent-event", + "stalled-deal", ] as const; export type TaskKind = (typeof TASK_KINDS)[number]; @@ -21,6 +22,7 @@ export const DIRECT_KINDS = [ "slack-people-match", "slack-channel-join", "agent-event", + "stalled-deal", ] as const; export type DirectKind = (typeof DIRECT_KINDS)[number]; @@ -47,4 +49,5 @@ export const PRIORITY = { slackPeople: 150, slackJoin: 950, event: 700, + stalledDeal: 120, } as const; diff --git a/packages/db/src/stalled-deals.ts b/packages/db/src/stalled-deals.ts new file mode 100644 index 000000000..9b83555d9 --- /dev/null +++ b/packages/db/src/stalled-deals.ts @@ -0,0 +1,57 @@ +const DAY_MS = 86_400_000; + +export const STALLED_DEAL = { + kind: "stalled-deal", + inactiveDays: 14, + maxPerRun: 100, + source: "stalled-deal", + subjectPrefix: "Re-engage:", +} as const; + +export type StalledDealInput = { + lastActivityAt: Date | null; + createdAt: Date; + now: Date; + inactiveDays?: number; +}; + +export function stallCutoff( + now: Date, + inactiveDays: number = STALLED_DEAL.inactiveDays, +): Date { + return new Date(now.getTime() - Math.max(inactiveDays, 0) * DAY_MS); +} + +export function activityAnchor( + lastActivityAt: Date | null, + createdAt: Date, +): Date { + return lastActivityAt ?? createdAt; +} + +export function daysInactive(input: StalledDealInput): number { + const anchor = activityAnchor(input.lastActivityAt, input.createdAt); + return Math.max( + 0, + Math.floor((input.now.getTime() - anchor.getTime()) / DAY_MS), + ); +} + +export function isStalledDeal(input: StalledDealInput): boolean { + const inactiveDays = input.inactiveDays ?? STALLED_DEAL.inactiveDays; + const anchor = activityAnchor(input.lastActivityAt, input.createdAt); + return anchor.getTime() <= stallCutoff(input.now, inactiveDays).getTime(); +} + +export function stallReason(dealName: string, days: number): string { + const label = dealName.trim() || "Untitled deal"; + if (days <= 0) { + return `${label} has no recent activity.`; + } + return `${label} has had no activity for ${days} day${days === 1 ? "" : "s"}.`; +} + +export function stallTaskSubject(dealName: string): string { + const label = dealName.trim() || "Untitled deal"; + return `${STALLED_DEAL.subjectPrefix} ${label}`; +} diff --git a/packages/db/test/stalled-deals.spec.ts b/packages/db/test/stalled-deals.spec.ts new file mode 100644 index 000000000..e15c11c92 --- /dev/null +++ b/packages/db/test/stalled-deals.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "bun:test"; +import { + activityAnchor, + daysInactive, + isStalledDeal, + STALLED_DEAL, + stallCutoff, + stallReason, + stallTaskSubject, +} from "../src/stalled-deals"; + +const NOW = new Date("2026-08-12T12:00:00.000Z"); + +describe("stallCutoff", () => { + it("subtracts whole days from now", () => { + expect(stallCutoff(NOW, 14).toISOString()).toBe("2026-07-29T12:00:00.000Z"); + }); + + it("treats a negative window as zero", () => { + expect(stallCutoff(NOW, -3).getTime()).toBe(NOW.getTime()); + }); +}); + +describe("activityAnchor", () => { + it("prefers lastActivityAt when present", () => { + const last = new Date("2026-08-01T00:00:00.000Z"); + const created = new Date("2026-07-01T00:00:00.000Z"); + expect(activityAnchor(last, created)).toBe(last); + }); + + it("falls back to createdAt when never active", () => { + const created = new Date("2026-07-01T00:00:00.000Z"); + expect(activityAnchor(null, created)).toBe(created); + }); +}); + +describe("isStalledDeal", () => { + it("flags an open deal past the inactive window", () => { + expect( + isStalledDeal({ + lastActivityAt: new Date("2026-07-20T12:00:00.000Z"), + createdAt: new Date("2026-06-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(true); + }); + + it("keeps a recently active deal open", () => { + expect( + isStalledDeal({ + lastActivityAt: new Date("2026-08-10T12:00:00.000Z"), + createdAt: new Date("2026-06-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(false); + }); + + it("uses createdAt when the deal has no activity stamp", () => { + expect( + isStalledDeal({ + lastActivityAt: null, + createdAt: new Date("2026-07-01T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(true); + + expect( + isStalledDeal({ + lastActivityAt: null, + createdAt: new Date("2026-08-10T00:00:00.000Z"), + now: NOW, + inactiveDays: 14, + }), + ).toBe(false); + }); + + it("defaults to STALLED_DEAL.inactiveDays", () => { + expect(STALLED_DEAL.inactiveDays).toBe(14); + expect( + isStalledDeal({ + lastActivityAt: stallCutoff(NOW), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + now: NOW, + }), + ).toBe(true); + }); +}); + +describe("daysInactive and reasons", () => { + it("counts whole days from the activity anchor", () => { + expect( + daysInactive({ + lastActivityAt: new Date("2026-07-29T12:00:00.000Z"), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + now: NOW, + }), + ).toBe(14); + }); + + it("writes a stable reason and subject", () => { + expect(stallReason("Acme renewal", 14)).toBe( + "Acme renewal has had no activity for 14 days.", + ); + expect(stallReason("Acme renewal", 1)).toBe( + "Acme renewal has had no activity for 1 day.", + ); + expect(stallTaskSubject("Acme renewal")).toBe("Re-engage: Acme renewal"); + expect(stallTaskSubject(" ")).toBe("Re-engage: Untitled deal"); + }); +}); From 18ae5e6d28602241db097b973ba19332de375204 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:10:56 -0400 Subject: [PATCH 08/22] feat(agent): job-change detection with owner task and gated re-parent Raise superseding employer facts as a timeline note plus owner TASK. Gate company re-parent on human approval; allow unattended raise without moveToCompanyId. Add recheck cadence config, job-change skill, and tests. --- apps/agent/agent/lib/approval.ts | 17 ++ apps/agent/agent/lib/crm.ts | 31 +++ apps/agent/agent/lib/dispatch.ts | 2 +- apps/agent/agent/lib/job-change.ts | 100 +++++++ apps/agent/agent/lib/recheck-config.ts | 19 ++ apps/agent/agent/lib/research-instructions.ts | 3 +- apps/agent/agent/skills/job-change.md | 57 ++++ apps/agent/agent/tools/record_job_change.ts | 67 +---- apps/agent/agent/tools/schedule_recheck.ts | 14 +- apps/agent/test/approval.spec.ts | 93 +++++++ .../agent/test/job-change.integration.spec.ts | 259 ++++++++++++++++++ apps/agent/test/recheck-config.spec.ts | 35 +++ 12 files changed, 627 insertions(+), 70 deletions(-) create mode 100644 apps/agent/agent/lib/job-change.ts create mode 100644 apps/agent/agent/lib/recheck-config.ts create mode 100644 apps/agent/agent/skills/job-change.md create mode 100644 apps/agent/test/approval.spec.ts create mode 100644 apps/agent/test/job-change.integration.spec.ts create mode 100644 apps/agent/test/recheck-config.spec.ts diff --git a/apps/agent/agent/lib/approval.ts b/apps/agent/agent/lib/approval.ts index 04f9707c1..2b05b55f9 100644 --- a/apps/agent/agent/lib/approval.ts +++ b/apps/agent/agent/lib/approval.ts @@ -27,3 +27,20 @@ export function sensitiveWrite(instead: string): Approval { } : "user-approval"; } + +export function sensitiveWhen( + needsApproval: (input: TInput | undefined) => boolean, + instead: string, +): Approval { + return ({ session, toolInput }) => { + if (!needsApproval(toolInput as TInput | undefined)) { + return "not-applicable"; + } + return isAutomated(session) + ? { + type: "denied" as const, + reason: `Not something to do unattended. ${instead}`, + } + : "user-approval"; + }; +} diff --git a/apps/agent/agent/lib/crm.ts b/apps/agent/agent/lib/crm.ts index 91599187f..aefa44857 100644 --- a/apps/agent/agent/lib/crm.ts +++ b/apps/agent/agent/lib/crm.ts @@ -381,3 +381,34 @@ export async function writeTimelineNote( return activity.id; } + +export async function writeOwnerTask( + contactId: string, + subject: string, + body: string, + dueAt: Date, + meta: Record = {}, +): Promise { + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true, ownerId: true }, + }); + if (!contact?.ownerId) return null; + + const activity = await db.activity.create({ + data: { + type: "TASK", + subject, + body, + occurredAt: new Date(), + dueAt, + contactId, + companyId: contact.companyId, + createdById: contact.ownerId, + meta: { ...meta, agent: "people-research" }, + }, + select: { id: true }, + }); + + return activity.id; +} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..d61a6ef36 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -413,7 +413,7 @@ function work(kind: string, reason: string): string { return "Work out who this contact actually is, and record what you find. Read what we already have before spending anything."; case "profile": case "recheck": - return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked."; + return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked. If their employer has moved, load job-change, record the new employer, then call record_job_change without moveToCompanyId."; case "meeting-prep": return "There is a meeting with this person soon. Make sure whoever is taking it opens the record knowing who they are dealing with."; case "company-profile": diff --git a/apps/agent/agent/lib/job-change.ts b/apps/agent/agent/lib/job-change.ts new file mode 100644 index 000000000..c4be66ada --- /dev/null +++ b/apps/agent/agent/lib/job-change.ts @@ -0,0 +1,100 @@ +import { db } from "@crm/db"; +import { writeOwnerTask, writeTimelineNote } from "./crm"; +import { lastEmployerChange } from "./facts"; +import { daysFromNow, JOB_CHANGE } from "./recheck-config"; + +export type RaiseJobChangeInput = { + contactId: string; + moveToCompanyId?: string; +}; + +export type RaiseJobChangeResult = + | { + raised: false; + reason: string; + } + | { + raised: true; + from: string; + to: string; + moved: boolean; + ownerNotified: boolean; + noteId: string | null; + taskId: string | null; + }; + +export async function raiseJobChange( + input: RaiseJobChangeInput, +): Promise { + const { contactId, moveToCompanyId } = input; + + const change = await lastEmployerChange(contactId); + if (!change) { + return { + raised: false, + reason: "No employer change on the facts for this contact.", + }; + } + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { + firstName: true, + lastName: true, + ownerId: true, + companyId: true, + }, + }); + if (!contact) return { raised: false, reason: "No such contact." }; + + const name = [contact.firstName, contact.lastName] + .filter(Boolean) + .join(" "); + + const subject = `${name} has moved to ${change.to}`; + const body = [ + `${name} appears to have left ${change.from} for ${change.to}.`, + change.sourceUrl ?? "", + "", + "Worth a conversation either way: a champion in a new seat is the", + "warmest introduction there is, and their replacement at the old", + "account is a relationship nobody owns yet.", + ] + .filter(Boolean) + .join("\n"); + + const meta = { + source: "job-change", + from: change.from, + to: change.to, + }; + + const noteId = await writeTimelineNote(contactId, subject, body, meta); + + const taskId = contact.ownerId + ? await writeOwnerTask( + contactId, + subject, + body, + daysFromNow(JOB_CHANGE.ownerTaskDueDays), + meta, + ) + : null; + + if (moveToCompanyId) { + await db.contact.update({ + where: { id: contactId }, + data: { companyId: moveToCompanyId }, + }); + } + + return { + raised: true, + from: change.from, + to: change.to, + moved: Boolean(moveToCompanyId), + ownerNotified: taskId !== null, + noteId, + taskId, + }; +} diff --git a/apps/agent/agent/lib/recheck-config.ts b/apps/agent/agent/lib/recheck-config.ts new file mode 100644 index 000000000..0ce3a1ae4 --- /dev/null +++ b/apps/agent/agent/lib/recheck-config.ts @@ -0,0 +1,19 @@ +const DAY_MS = 24 * 60 * 60 * 1000; + +export const RECHECK = { + championDays: 14, + namedDays: 90, + emptyDays: 365, + baselineDays: 30, + minDays: 1, + maxDays: 730, + defaultBudget: 4, +} as const; + +export const JOB_CHANGE = { + ownerTaskDueDays: 2, +} as const; + +export function daysFromNow(days: number, from = Date.now()): Date { + return new Date(from + days * DAY_MS); +} diff --git a/apps/agent/agent/lib/research-instructions.ts b/apps/agent/agent/lib/research-instructions.ts index b29b5781b..b762671f4 100644 --- a/apps/agent/agent/lib/research-instructions.ts +++ b/apps/agent/agent/lib/research-instructions.ts @@ -22,5 +22,6 @@ Only vendor calls spend the session research budget. When it is gone, write up what you have and stop, or schedule a recheck when another look is justified. Load identity-matching before deciding whether a candidate is the same person, -evidence before recording facts, writing-a-brief before a background brief, and +evidence before recording facts, writing-a-brief before a background brief, +job-change when an employer fact moves or a recheck finds a new role, and data-boundaries before moving data outside the CRM.`; diff --git a/apps/agent/agent/skills/job-change.md b/apps/agent/agent/skills/job-change.md new file mode 100644 index 000000000..61d029a7b --- /dev/null +++ b/apps/agent/agent/skills/job-change.md @@ -0,0 +1,57 @@ +--- +name: job-change +description: Use when a contact's employer fact changes, or on a recheck pass that compares current role to what the CRM already holds — how to raise the signal without overwriting human data. +--- + +# Job changes + +A champion who moves company is the highest-intent people signal in B2B sales. +Detection is not a separate pipeline. A new applied `employer` fact that +supersedes the previous one *is* the event. + +## Detect + +1. Read the contact (`read_crm_history`) and what you already recorded. +2. Re-read their public profile or work history on the recheck cadence. +3. If the current employer differs from the applied fact, call `record_fact` + with field `employer` and the evidence you observed. +4. When that fact applies and supersedes a previous employer, call + `record_job_change` with the contact id only. + +Do not invent a move from a weak page. No employer change on the facts means +`record_job_change` refuses. That is correct. + +## Raise, do not re-parent unattended + +`record_job_change` always: + +- writes a timeline note on the contact +- creates an owner TASK when the contact has an owner + +It does **not** move the contact to another company unless you pass +`moveToCompanyId`. That argument is approval-gated for a person and **denied** +on automated sessions. Unattended runs omit it. The owner decides whether the +CRM company link changes. + +Never overwrite a field a person typed. Facts already refuse human-owned +columns; company re-parent is the extra gate on this tool. + +## Recheck cadences + +Use `schedule_recheck` with a reason a rep can read: + +| Situation | Days | Reason shape | +| --- | ---: | --- | +| Champion on an open deal | 14 | "a job change here would move the Acme deal" | +| Named contact, no open deal | 90 | "named contact at Fernhill, worth a quiet recheck" | +| Steady job-change feed | 30 | "baseline re-read so an employer move is not missed" | +| Nothing found twice | 365 | "two empty lookups; park until next year" | +| Support / no-reply alias | — | do not schedule | + +Pick the shortest interval that matches the contact's deal weight. A live +champion beats a baseline feed. + +## After the raise + +Schedule the next recheck if the contact still matters. The note and task are +the product; do not email anyone and do not invent a replacement contact. diff --git a/apps/agent/agent/tools/record_job_change.ts b/apps/agent/agent/tools/record_job_change.ts index d7fdbb726..7b759c9a9 100644 --- a/apps/agent/agent/tools/record_job_change.ts +++ b/apps/agent/agent/tools/record_job_change.ts @@ -1,15 +1,13 @@ -import { db } from "@crm/db"; import { defineTool } from "eve/tools"; import { z } from "zod"; -import { sensitiveWrite } from "../lib/approval"; -import { writeTimelineNote } from "../lib/crm"; -import { lastEmployerChange } from "../lib/facts"; +import { sensitiveWhen } from "../lib/approval"; import { focusOn } from "../lib/focus"; +import { raiseJobChange } from "../lib/job-change"; import { assertResearchPurpose } from "../lib/session-purpose"; export default defineTool({ description: - "Raise a job change on a contact's timeline and task their owner. Reads the change from the facts already recorded; call it after recording a new employer.", + "Raise a job change on a contact's timeline and task their owner. Reads the change from the facts already recorded; call it after recording a new employer. Re-parenting to a CRM company needs a person; omit moveToCompanyId on unattended runs.", inputSchema: z.object({ contactId: z.string(), moveToCompanyId: z @@ -19,65 +17,14 @@ export default defineTool({ "Only when the new employer is already a company in the CRM and a person has approved the move.", ), }), - approval: sensitiveWrite( + approval: sensitiveWhen( + (input: { moveToCompanyId?: string } | undefined) => + Boolean(input?.moveToCompanyId), "Raise the change without `moveToCompanyId` — the alert lands on the timeline and their owner decides whether to move them.", ), async execute({ contactId, moveToCompanyId }, ctx) { assertResearchPurpose(ctx); focusOn({ contactId }); - - const change = await lastEmployerChange(contactId); - if (!change) { - return { - raised: false as const, - reason: "No employer change on the facts for this contact.", - }; - } - - const contact = await db.contact.findUnique({ - where: { id: contactId }, - select: { - firstName: true, - lastName: true, - ownerId: true, - companyId: true, - }, - }); - if (!contact) return { raised: false as const, reason: "No such contact." }; - - const name = [contact.firstName, contact.lastName] - .filter(Boolean) - .join(" "); - - await writeTimelineNote( - contactId, - `${name} has moved to ${change.to}`, - [ - `${name} appears to have left ${change.from} for ${change.to}.`, - change.sourceUrl ?? "", - "", - "Worth a conversation either way: a champion in a new seat is the", - "warmest introduction there is, and their replacement at the old", - "account is a relationship nobody owns yet.", - ] - .filter(Boolean) - .join("\n"), - { source: "job-change", from: change.from, to: change.to }, - ); - - if (moveToCompanyId) { - await db.contact.update({ - where: { id: contactId }, - data: { companyId: moveToCompanyId }, - }); - } - - return { - raised: true as const, - from: change.from, - to: change.to, - moved: Boolean(moveToCompanyId), - ownerNotified: contact.ownerId !== null, - }; + return raiseJobChange({ contactId, moveToCompanyId }); }, }); diff --git a/apps/agent/agent/tools/schedule_recheck.ts b/apps/agent/agent/tools/schedule_recheck.ts index fa9188913..94216f669 100644 --- a/apps/agent/agent/tools/schedule_recheck.ts +++ b/apps/agent/agent/tools/schedule_recheck.ts @@ -1,12 +1,10 @@ import { PRIORITY } from "@crm/db/agent-tasks"; import { defineTool } from "eve/tools"; import { z } from "zod"; +import { daysFromNow, RECHECK } from "../lib/recheck-config"; import { assertResearchPurpose } from "../lib/session-purpose"; import { scheduleTask } from "../lib/tasks"; -const MIN_DAYS = 1; -const MAX_DAYS = 730; - export default defineTool({ description: "Decide when this contact is worth looking at again, and say why. Use a short interval for people whose job change would move a live deal, a long one for quiet records, and skip it entirely for addresses nobody will ever sell to.", @@ -15,10 +13,10 @@ export default defineTool({ days: z .number() .int() - .min(MIN_DAYS) - .max(MAX_DAYS) + .min(RECHECK.minDays) + .max(RECHECK.maxDays) .describe( - "14 for a champion on an open deal; 90 for a named contact with no deal; 365 when two attempts have found nothing.", + `${RECHECK.championDays} for a champion on an open deal; ${RECHECK.namedDays} for a named contact with no deal; ${RECHECK.baselineDays} for a steady job-change feed; ${RECHECK.emptyDays} when two attempts have found nothing.`, ), reason: z .string() @@ -31,12 +29,12 @@ export default defineTool({ .int() .min(1) .max(20) - .default(4) + .default(RECHECK.defaultBudget) .describe("Vendor calls the next run may spend."), }), async execute({ contactId, days, reason, budget }, ctx) { assertResearchPurpose(ctx); - const dueAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000); + const dueAt = daysFromNow(days); await scheduleTask({ contactId, diff --git a/apps/agent/test/approval.spec.ts b/apps/agent/test/approval.spec.ts new file mode 100644 index 000000000..f75e69e11 --- /dev/null +++ b/apps/agent/test/approval.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "bun:test"; +import { APP_AUTH } from "../agent/lib/app-auth"; +import { + isAutomated, + sensitiveWhen, + sensitiveWrite, +} from "../agent/lib/approval"; + +const appSession = { + auth: { + current: { + authenticator: APP_AUTH.authenticator, + principalId: APP_AUTH.principalId, + principalType: APP_AUTH.principalType, + }, + }, +}; + +const humanSession = { + auth: { + current: { + authenticator: "crm-app", + principalId: "user_123", + principalType: "user", + }, + }, +}; + +function decide( + policy: ReturnType, + session: typeof appSession | typeof humanSession, + toolInput?: Record, +) { + return policy({ + session, + toolInput, + toolName: "record_job_change", + callId: "call_1", + approvedTools: new Set(), + } as never); +} + +describe("isAutomated", () => { + it("matches the app principal on all three fields", () => { + expect(isAutomated(appSession)).toBe(true); + expect(isAutomated(humanSession)).toBe(false); + }); +}); + +describe("sensitiveWrite", () => { + const policy = sensitiveWrite("Do the safe path instead."); + + it("denies unattended runs", async () => { + expect(await decide(policy, appSession)).toEqual({ + type: "denied", + reason: "Not something to do unattended. Do the safe path instead.", + }); + }); + + it("asks a person on a human session", async () => { + expect(await decide(policy, humanSession)).toBe("user-approval"); + }); +}); + +describe("sensitiveWhen", () => { + const policy = sensitiveWhen<{ moveToCompanyId?: string }>( + (input) => Boolean(input?.moveToCompanyId), + "Raise without moveToCompanyId.", + ); + + it("lets automated runs raise a job change without re-parenting", async () => { + expect(await decide(policy, appSession, {})).toBe("not-applicable"); + expect( + await decide(policy, appSession, { contactId: "c1" }), + ).toBe("not-applicable"); + }); + + it("denies automated re-parenting of a contact company", async () => { + expect( + await decide(policy, appSession, { moveToCompanyId: "co_1" }), + ).toEqual({ + type: "denied", + reason: "Not something to do unattended. Raise without moveToCompanyId.", + }); + }); + + it("asks a person only when re-parenting", async () => { + expect(await decide(policy, humanSession, {})).toBe("not-applicable"); + expect( + await decide(policy, humanSession, { moveToCompanyId: "co_1" }), + ).toBe("user-approval"); + }); +}); diff --git a/apps/agent/test/job-change.integration.spec.ts b/apps/agent/test/job-change.integration.spec.ts new file mode 100644 index 000000000..7ae15ca9c --- /dev/null +++ b/apps/agent/test/job-change.integration.spec.ts @@ -0,0 +1,259 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { ActivityType, db } from "@crm/db"; +import type { Evidence } from "../agent/lib/evidence"; +import { recordFact, lastEmployerChange } from "../agent/lib/facts"; +import { raiseJobChange } from "../agent/lib/job-change"; + +const suffix = process.env.TEST_RUN_ID ?? "job-change-spec"; +const email = `job.change.${suffix}@example.test`; +const ownerEmail = `owner.job.${suffix}@example.test`; + +let contactId: string; +let ownerId: string; +let companyId: string; +let otherCompanyId: string; + +const seen = (kind: Evidence["kind"], detail = "observed"): Evidence => ({ + kind, + detail, +}); + +async function cleanup() { + await db.activity.deleteMany({ + where: { + OR: [ + { contact: { email } }, + { createdBy: { email: ownerEmail } }, + ], + }, + }); + await db.contactFact.deleteMany({ where: { contact: { email } } }); + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ + where: { domain: { in: [`old-${suffix}.test`, `new-${suffix}.test`] } }, + }); + await db.user.deleteMany({ where: { email: ownerEmail } }); +} + +beforeAll(async () => { + await cleanup(); + + const owner = await db.user.create({ + data: { + id: `owner-${suffix}`, + name: "Owner Rep", + email: ownerEmail, + emailVerified: true, + }, + select: { id: true }, + }); + ownerId = owner.id; + + const oldCompany = await db.company.create({ + data: { name: `Old Co ${suffix}`, domain: `old-${suffix}.test` }, + select: { id: true }, + }); + companyId = oldCompany.id; + + const newCompany = await db.company.create({ + data: { name: `New Co ${suffix}`, domain: `new-${suffix}.test` }, + select: { id: true }, + }); + otherCompanyId = newCompany.id; + + const contact = await db.contact.create({ + data: { + firstName: "Champion", + lastName: "Mover", + email, + companyId, + ownerId, + }, + select: { id: true }, + }); + contactId = contact.id; + + await recordFact({ + contactId, + field: "employer", + value: "Fleetio", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); + await recordFact({ + contactId, + field: "employer", + value: "Comp AI", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); +}); + +afterAll(cleanup); + +describe("lastEmployerChange", () => { + it("reads the superseding employer pair", async () => { + const change = await lastEmployerChange(contactId); + expect(change).toMatchObject({ + from: "Fleetio", + to: "Comp AI", + sourceUrl: "https://www.linkedin.com/in/champion-mover", + }); + }); +}); + +describe("raiseJobChange", () => { + it("writes a note and an owner TASK without moving the company", async () => { + const result = await raiseJobChange({ contactId }); + + expect(result).toMatchObject({ + raised: true, + from: "Fleetio", + to: "Comp AI", + moved: false, + ownerNotified: true, + }); + if (!result.raised) return; + + expect(result.noteId).toBeTruthy(); + expect(result.taskId).toBeTruthy(); + + const note = await db.activity.findUnique({ + where: { id: result.noteId! }, + select: { + type: true, + subject: true, + createdById: true, + contactId: true, + meta: true, + }, + }); + expect(note).toMatchObject({ + type: ActivityType.NOTE, + subject: "Champion Mover has moved to Comp AI", + createdById: ownerId, + contactId, + }); + expect(note?.meta).toMatchObject({ + source: "job-change", + from: "Fleetio", + to: "Comp AI", + }); + + const task = await db.activity.findUnique({ + where: { id: result.taskId! }, + select: { + type: true, + subject: true, + createdById: true, + dueAt: true, + completedAt: true, + meta: true, + }, + }); + expect(task).toMatchObject({ + type: ActivityType.TASK, + subject: "Champion Mover has moved to Comp AI", + createdById: ownerId, + completedAt: null, + }); + expect(task?.dueAt).toBeInstanceOf(Date); + expect(task?.meta).toMatchObject({ source: "job-change" }); + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true }, + }); + expect(contact?.companyId).toBe(companyId); + }); + + it("re-parents only when moveToCompanyId is given after approval", async () => { + const result = await raiseJobChange({ + contactId, + moveToCompanyId: otherCompanyId, + }); + + expect(result).toMatchObject({ + raised: true, + moved: true, + ownerNotified: true, + }); + + const contact = await db.contact.findUnique({ + where: { id: contactId }, + select: { companyId: true }, + }); + expect(contact?.companyId).toBe(otherCompanyId); + }); + + it("refuses when there is no employer supersession", async () => { + const lonely = await db.contact.create({ + data: { + firstName: "Still", + lastName: "Here", + email: `still.here.${suffix}@example.test`, + ownerId, + }, + select: { id: true }, + }); + + await recordFact({ + contactId: lonely.id, + field: "employer", + value: "Same Co", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + + const result = await raiseJobChange({ contactId: lonely.id }); + expect(result).toEqual({ + raised: false, + reason: "No employer change on the facts for this contact.", + }); + + await db.contactFact.deleteMany({ where: { contactId: lonely.id } }); + await db.contact.delete({ where: { id: lonely.id } }); + }); + + it("reports ownerNotified false when the contact has no owner", async () => { + const orphan = await db.contact.create({ + data: { + firstName: "No", + lastName: "Owner", + email: `no.owner.${suffix}@example.test`, + }, + select: { id: true }, + }); + + await recordFact({ + contactId: orphan.id, + field: "employer", + value: "Alpha", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + await recordFact({ + contactId: orphan.id, + field: "employer", + value: "Beta", + evidence: [seen("linkedin.employer-and-name")], + method: "linkedin.profile", + }); + + const result = await raiseJobChange({ contactId: orphan.id }); + expect(result).toMatchObject({ + raised: true, + ownerNotified: false, + taskId: null, + }); + if (result.raised) { + expect(result.noteId).toBeTruthy(); + } + + await db.activity.deleteMany({ where: { contactId: orphan.id } }); + await db.contactFact.deleteMany({ where: { contactId: orphan.id } }); + await db.contact.delete({ where: { id: orphan.id } }); + }); +}); diff --git a/apps/agent/test/recheck-config.spec.ts b/apps/agent/test/recheck-config.spec.ts new file mode 100644 index 000000000..8ae75b708 --- /dev/null +++ b/apps/agent/test/recheck-config.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "bun:test"; +import { daysFromNow, JOB_CHANGE, RECHECK } from "../agent/lib/recheck-config"; + +describe("RECHECK", () => { + it("keeps champion shorter than named, named shorter than empty", () => { + expect(RECHECK.championDays).toBe(14); + expect(RECHECK.namedDays).toBe(90); + expect(RECHECK.baselineDays).toBe(30); + expect(RECHECK.emptyDays).toBe(365); + expect(RECHECK.championDays).toBeLessThan(RECHECK.namedDays); + expect(RECHECK.namedDays).toBeLessThan(RECHECK.emptyDays); + expect(RECHECK.championDays).toBeLessThan(RECHECK.baselineDays); + }); + + it("bounds schedule_recheck days", () => { + expect(RECHECK.minDays).toBe(1); + expect(RECHECK.maxDays).toBe(730); + expect(RECHECK.defaultBudget).toBe(4); + }); +}); + +describe("JOB_CHANGE", () => { + it("gives the owner a near due date", () => { + expect(JOB_CHANGE.ownerTaskDueDays).toBe(2); + }); +}); + +describe("daysFromNow", () => { + it("adds whole days from a fixed instant", () => { + const from = Date.UTC(2026, 0, 1, 12, 0, 0); + expect(daysFromNow(14, from).toISOString()).toBe( + "2026-01-15T12:00:00.000Z", + ); + }); +}); From a0481cdc3b3cf3aefe999ab82f456b76021ece65 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:11:06 -0400 Subject: [PATCH 09/22] feat(agent): meeting-prep briefs refuse untrusted identity Wire the calendar-enqueued meeting-prep path with a skill and code gate so write_brief only lands when identity is trustworthy. --- apps/agent/agent/lib/brief-identity.ts | 67 ++++++++++ apps/agent/agent/lib/dispatch.ts | 2 +- apps/agent/agent/lib/facts.ts | 39 +++++- apps/agent/agent/lib/research-instructions.ts | 5 +- apps/agent/agent/skills/meeting-prep.md | 40 ++++++ apps/agent/agent/tools/write_brief.ts | 2 +- apps/agent/test/brief-identity.spec.ts | 114 ++++++++++++++++++ apps/agent/test/facts.integration.spec.ts | 15 +++ apps/agent/test/meeting-prep-dispatch.spec.ts | 33 +++++ apps/api/src/agent/agent-trigger.service.ts | 2 +- 10 files changed, 312 insertions(+), 7 deletions(-) create mode 100644 apps/agent/agent/lib/brief-identity.ts create mode 100644 apps/agent/agent/skills/meeting-prep.md create mode 100644 apps/agent/test/brief-identity.spec.ts create mode 100644 apps/agent/test/meeting-prep-dispatch.spec.ts diff --git a/apps/agent/agent/lib/brief-identity.ts b/apps/agent/agent/lib/brief-identity.ts new file mode 100644 index 000000000..1e3909267 --- /dev/null +++ b/apps/agent/agent/lib/brief-identity.ts @@ -0,0 +1,67 @@ +import { FactBand } from "@crm/db"; +import { + type Evidence, + type EvidenceKind, + scoreEvidence, +} from "./evidence"; +import { isDerivedName } from "./names"; + +export const IDENTITY_PROOF_KINDS = [ + "profile.email-match", + "linkedin.employer-and-name", + "crm.thread-reply", + "crm.signature-block", + "github.account-identity", +] as const satisfies readonly EvidenceKind[]; + +const identityKinds = new Set(IDENTITY_PROOF_KINDS); + +export type ContactIdentitySnapshot = { + email: string | null; + firstName: string; + lastName: string | null; + linkedinUrl: string | null; + hasAppliedName: boolean; +}; + +export function evidenceProvesIdentity(evidence: Evidence[]): boolean { + return evidence.some((item) => identityKinds.has(item.kind)); +} + +export function contactIdentityIsTrustworthy( + contact: ContactIdentitySnapshot, +): boolean { + if (contact.linkedinUrl) return true; + if (contact.hasAppliedName) return true; + if ( + contact.lastName && + !isDerivedName(contact.email, contact.firstName, contact.lastName) + ) { + return true; + } + return false; +} + +export function refuseBriefReason(input: { + contact: ContactIdentitySnapshot; + evidence: Evidence[]; +}): string | null { + const identityOk = + contactIdentityIsTrustworthy(input.contact) || + evidenceProvesIdentity(input.evidence); + + if (!identityOk) { + return "Identity is not trustworthy yet. Identify them first, then write the brief."; + } + + const scored = scoreEvidence(input.evidence); + + if ( + scored.band === null || + scored.band === FactBand.POSSIBLE + ) { + return "Nothing here is sourced well enough to put on the record."; + } + + return null; +} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..51b4e0fe2 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -415,7 +415,7 @@ function work(kind: string, reason: string): string { case "recheck": return "Bring this contact's record up to date: their background, their current role, and anything that has changed since we last looked."; case "meeting-prep": - return "There is a meeting with this person soon. Make sure whoever is taking it opens the record knowing who they are dealing with."; + return "There is a meeting with this person soon. Load meeting-prep and identity-matching. If identity is not trustworthy, identify them first. Only then write a brief with write_brief — the tool refuses garbage identity. Leave the panel empty rather than invent."; case "company-profile": return "This company's brand, industry, location and links are filled in separately and may already be there. Read the account, fill anything still missing, and write a brief if there is something worth saying."; case "workspace-profile": diff --git a/apps/agent/agent/lib/facts.ts b/apps/agent/agent/lib/facts.ts index ebcc02e16..402c4da29 100644 --- a/apps/agent/agent/lib/facts.ts +++ b/apps/agent/agent/lib/facts.ts @@ -1,4 +1,5 @@ import { db, FactBand, FactStatus } from "@crm/db"; +import { refuseBriefReason } from "./brief-identity"; import { type Evidence, scoreEvidence } from "./evidence"; import { currentFocus } from "./focus"; import { isDerivedName, splitName } from "./names"; @@ -277,11 +278,45 @@ export async function writeBrief(input: { }): Promise<{ written: boolean; score: number; reason?: string }> { const scored = scoreEvidence(input.evidence); - if (scored.band === null) { + const contact = await db.contact.findUnique({ + where: { id: input.contactId }, + select: { + email: true, + firstName: true, + lastName: true, + linkedinUrl: true, + facts: { + where: { field: "name", status: FactStatus.APPLIED }, + select: { id: true }, + take: 1, + }, + }, + }); + + if (!contact) { + return { + written: false, + score: scored.score, + reason: "No such contact.", + }; + } + + const refused = refuseBriefReason({ + contact: { + email: contact.email, + firstName: contact.firstName, + lastName: contact.lastName, + linkedinUrl: contact.linkedinUrl, + hasAppliedName: contact.facts.length > 0, + }, + evidence: input.evidence, + }); + + if (refused) { return { written: false, score: scored.score, - reason: "Nothing here is sourced well enough to put on the record.", + reason: refused, }; } diff --git a/apps/agent/agent/lib/research-instructions.ts b/apps/agent/agent/lib/research-instructions.ts index b29b5781b..821cd9f06 100644 --- a/apps/agent/agent/lib/research-instructions.ts +++ b/apps/agent/agent/lib/research-instructions.ts @@ -22,5 +22,6 @@ Only vendor calls spend the session research budget. When it is gone, write up what you have and stop, or schedule a recheck when another look is justified. Load identity-matching before deciding whether a candidate is the same person, -evidence before recording facts, writing-a-brief before a background brief, and -data-boundaries before moving data outside the CRM.`; +evidence before recording facts, writing-a-brief before a background brief, +meeting-prep for an upcoming-meeting task, and data-boundaries before moving +data outside the CRM.`; diff --git a/apps/agent/agent/skills/meeting-prep.md b/apps/agent/agent/skills/meeting-prep.md new file mode 100644 index 000000000..370af1231 --- /dev/null +++ b/apps/agent/agent/skills/meeting-prep.md @@ -0,0 +1,40 @@ +--- +name: meeting-prep +description: Use when a meeting-prep task is open — identify first if needed, then write a short Background brief only when identity is trustworthy. +--- + +# Meeting prep + +Calendar sync enqueues a `meeting-prep` task (priority 200) for known contacts +on upcoming meetings who still have no Background panel. Your job is a rep-ready +brief by the night before — or a deliberate empty panel when identity is weak. + +## Order of work + +1. **`read_crm_history` first.** Free, often decisive. Threads and prior + meetings settle identity and titles without a vendor call. +2. **Trustworthy identity, or stop.** Load `identity-matching`. If the contact + is still a placeholder name with no LinkedIn and no applied name, run the + identity path (`resolve_linkedin_profile` → `get_linkedin_profile` → + `identify_contact`) before any brief. +3. **Write only when identity holds.** Call `write_brief` with evidence that + proves who they are *and* supports the claims. The tool refuses garbage + identity in code — do not try to push a weak match over the line. +4. **Shape.** Load `writing-a-brief`. Two or three sentences, third person, + current role first. Empty structured lines beat guessed ones. +5. **Stop.** Nobody is waiting on chat. Record what you found and end. + +## When to write nothing + +- Identity fails both employer and name checks. +- The only material is the job title already on the record. +- Evidence is employer-only, a search hit, or meeting attendance alone. + +An empty Background panel is the correct outcome. A wrong person on the panel +is the failure mode this path exists to prevent. + +## What calendar already did + +Nest only wrote the task row. It does not research, match identity, or score +people. Attendees without a CRM contact are not enqueued. Contacts that already +have a brief are skipped. Horizon is the next seven days. diff --git a/apps/agent/agent/tools/write_brief.ts b/apps/agent/agent/tools/write_brief.ts index 190d1f04b..902c22f27 100644 --- a/apps/agent/agent/tools/write_brief.ts +++ b/apps/agent/agent/tools/write_brief.ts @@ -10,7 +10,7 @@ const MAX_NARRATIVE = 400; export default defineTool({ description: - "Write the Background panel on a contact: a short narrative plus the structured lines under it. Replaces the previous one. Every claim must come from something you read.", + "Write the Background panel on a contact: a short narrative plus the structured lines under it. Replaces the previous one. Every claim must come from something you read. Refuses when identity is not trustworthy — identify them first.", inputSchema: z.object({ contactId: z.string(), narrative: z diff --git a/apps/agent/test/brief-identity.spec.ts b/apps/agent/test/brief-identity.spec.ts new file mode 100644 index 000000000..476301196 --- /dev/null +++ b/apps/agent/test/brief-identity.spec.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "bun:test"; +import type { Evidence } from "../agent/lib/evidence"; +import { + contactIdentityIsTrustworthy, + evidenceProvesIdentity, + refuseBriefReason, + type ContactIdentitySnapshot, +} from "../agent/lib/brief-identity"; + +const of = (...kinds: Evidence["kind"][]): Evidence[] => + kinds.map((kind) => ({ kind, detail: `saw ${kind}` })); + +const placeholder: ContactIdentitySnapshot = { + email: "pmarchetti@fernhill.com", + firstName: "Pmarchetti", + lastName: null, + linkedinUrl: null, + hasAppliedName: false, +}; + +const known: ContactIdentitySnapshot = { + email: "paula@fernhill.com", + firstName: "Paula", + lastName: "Marchetti", + linkedinUrl: "https://www.linkedin.com/in/paulamarchetti", + hasAppliedName: true, +}; + +describe("evidenceProvesIdentity", () => { + it("accepts employer-and-name and thread reply", () => { + expect(evidenceProvesIdentity(of("linkedin.employer-and-name"))).toBe( + true, + ); + expect(evidenceProvesIdentity(of("crm.thread-reply"))).toBe(true); + }); + + it("rejects employer-only and meeting attendance alone", () => { + expect(evidenceProvesIdentity(of("employer-only"))).toBe(false); + expect(evidenceProvesIdentity(of("crm.meeting-attendance"))).toBe(false); + expect(evidenceProvesIdentity(of("search.cites-profile"))).toBe(false); + }); +}); + +describe("contactIdentityIsTrustworthy", () => { + it("accepts a LinkedIn URL or applied name", () => { + expect(contactIdentityIsTrustworthy(known)).toBe(true); + expect( + contactIdentityIsTrustworthy({ + ...placeholder, + hasAppliedName: true, + }), + ).toBe(true); + }); + + it("rejects a derived placeholder name with no sources", () => { + expect(contactIdentityIsTrustworthy(placeholder)).toBe(false); + }); + + it("accepts a human full name without LinkedIn", () => { + expect( + contactIdentityIsTrustworthy({ + email: "x@example.com", + firstName: "Lewis", + lastName: "Carhart", + linkedinUrl: null, + hasAppliedName: false, + }), + ).toBe(true); + }); +}); + +describe("refuseBriefReason", () => { + it("refuses garbage identity even when evidence scores as probable", () => { + const reason = refuseBriefReason({ + contact: placeholder, + evidence: of("crm.meeting-attendance"), + }); + expect(reason).toMatch(/Identity is not trustworthy/); + }); + + it("refuses employer-only on an unknown contact", () => { + const reason = refuseBriefReason({ + contact: placeholder, + evidence: of("employer-only"), + }); + expect(reason).toMatch(/Identity is not trustworthy/); + }); + + it("allows a brief when identity evidence is primary", () => { + expect( + refuseBriefReason({ + contact: placeholder, + evidence: of("linkedin.employer-and-name"), + }), + ).toBeNull(); + }); + + it("allows a brief on a known contact with primary evidence", () => { + expect( + refuseBriefReason({ + contact: known, + evidence: of("linkedin.employer-and-name", "crm.signature-block"), + }), + ).toBeNull(); + }); + + it("refuses weak content evidence even when the contact is known", () => { + const reason = refuseBriefReason({ + contact: known, + evidence: of("web.cited-claim"), + }); + expect(reason).toMatch(/sourced well enough/); + }); +}); diff --git a/apps/agent/test/facts.integration.spec.ts b/apps/agent/test/facts.integration.spec.ts index 4c9fc42e8..20af9d6b7 100644 --- a/apps/agent/test/facts.integration.spec.ts +++ b/apps/agent/test/facts.integration.spec.ts @@ -291,5 +291,20 @@ describe("writeBrief", () => { }); expect(result.written).toBe(false); + expect(result.reason).toMatch(/Identity is not trustworthy|sourced well enough/); + }); + + it("refuses meeting-attendance alone as garbage identity", async () => { + const result = await writeBrief({ + contactId, + narrative: + "Subject is someone we met once and should prepare carefully for.", + sections: {}, + evidence: [seen("crm.meeting-attendance")], + }); + + expect(result.written).toBe(false); + expect(result.reason).toMatch(/Identity is not trustworthy/); }); }); + diff --git a/apps/agent/test/meeting-prep-dispatch.spec.ts b/apps/agent/test/meeting-prep-dispatch.spec.ts new file mode 100644 index 000000000..24284c4c6 --- /dev/null +++ b/apps/agent/test/meeting-prep-dispatch.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test"; +import { brief } from "../agent/lib/dispatch"; +import type { LeasedTask } from "../agent/lib/tasks"; +import { PRIORITY } from "@crm/db/agent-tasks"; + +function task(kind: string, attempts = 1): LeasedTask { + return { + id: "task_test", + kind, + reason: "Meeting on Thu", + priority: PRIORITY.meeting, + budget: 10, + attempts, + contactId: "contact_test", + companyId: null, + dealId: null, + payload: null, + dueAt: new Date(), + }; +} + +describe("meeting-prep dispatch brief", () => { + it("tells the session to identify before writing", () => { + const text = brief(task("meeting-prep")); + expect(text).toMatch(/identity/i); + expect(text).toMatch(/write_brief|brief/i); + expect(text).toMatch(/meeting-prep/); + }); + + it("keeps meeting priority at 200", () => { + expect(PRIORITY.meeting).toBe(200); + }); +}); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index ea5226eeb..116972cc9 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -240,7 +240,7 @@ export class AgentTriggerService { await this.enqueue({ contactId, kind: "meeting-prep", - reason: `Meeting on ${when.toDateString()} with someone we know nothing about`, + reason: `Meeting on ${when.toDateString()} — prepare a brief only if identity is trustworthy`, priority: PRIORITY.meeting, budget: 10, }); From a729d0137ed24b0d729a691a2dcc4d7aabb8e3f8 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:11:36 -0400 Subject: [PATCH 10/22] test(gmail): lock thread timeline projection and hand off auto-create Add Gmail sync acceptance coverage for history first-sight, company timeline projection, cross-mailbox rfcMessageId dedupe, body privacy on list vs expand, relevance when auto-create is off, idempotent re-runs, and history 404 cursor reset. Document phase 3 status and phase 4 handoff in the gmail-calendar plan. --- apps/api/test/gmail-sync.spec.ts | 616 +++++++++++++++++++++++++++++++ docs/plan/gmail-calendar-plan.md | 62 ++++ 2 files changed, 678 insertions(+) create mode 100644 apps/api/test/gmail-sync.spec.ts diff --git a/apps/api/test/gmail-sync.spec.ts b/apps/api/test/gmail-sync.spec.ts new file mode 100644 index 000000000..bca5f297e --- /dev/null +++ b/apps/api/test/gmail-sync.spec.ts @@ -0,0 +1,616 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + ActivityType, + db, + GoogleSyncStatus, + type MailboxSyncModel as MailboxSync, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import { ConversationService } from "../src/google/conversation.service"; +import type { GmailClient, GmailMessage } from "../src/google/gmail.client"; +import { GmailSyncService } from "../src/google/gmail-sync.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import type { MailboxTokenService } from "../src/mailbox/mailbox-token.service"; +import { SyncStateService } from "../src/mailbox/sync-state.service"; +import { ThreadWriterService } from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "gmail-sync-spec"; +const domain = `gmail-${suffix}.test`; +const userId = `user-gmail-${suffix}`; +const secondUserId = `user-gmail-b-${suffix}`; +const mailbox = `rep-gmail-${suffix}@example.test`; +const secondMailbox = `rep-b-gmail-${suffix}@example.test`; +const person = `buyer@${domain}`; +const rootRfc = ``; +const replyRfc = ``; +const rootId = `root-${suffix}@mail.test`; +const replyId = `reply-${suffix}@mail.test`; +const bodyText = [ + "Here are the numbers you asked for on the Q3 order.", + "Please review the line items, the volume discount table,", + "and the shipping schedule before Friday so we can lock the PO.", + "I also attached the competitor matrix from last week for context.", +].join(" "); + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const state = new SyncStateService(db); +const threads = new ThreadWriterService(db, match, stamp); +const conversations = new ConversationService(db); + +let row: MailboxSync; +let secondRow: MailboxSync; +let companyId: string; +let contactId: string; + +let messagesById = new Map(); +let historyOutcome: + | { outcome: "ok"; data: { history?: unknown[]; historyId?: string } } + | { outcome: "cursor-invalid"; reason: string } = { + outcome: "ok", + data: { history: [], historyId: "hist-0" }, +}; +let profileHistoryId = "hist-start"; + +const tokens = { + async accessTokenFor() { + return { outcome: "ok" as const, accessToken: "token" }; + }, +} as unknown as MailboxTokenService; + +const gmail = { + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: profileHistoryId }, + }; + }, + async listHistory() { + return historyOutcome; + }, + async getMessage(_token: string, id: string) { + const data = messagesById.get(id); + if (!data) { + return { outcome: "failed" as const, reason: "missing", retryable: false }; + } + return { outcome: "ok" as const, data }; + }, +} as unknown as GmailClient; + +const service = new GmailSyncService(db, gmail, tokens, state, threads); + +function encode(text: string): string { + return Buffer.from(text, "utf8") + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function gmailMessage(options: { + id: string; + rfcMessageId: string; + from: string; + fromName?: string; + to: string; + subject: string; + body: string; + sentAt: string; + references?: string; + inReplyTo?: string; + threadId?: string; +}): GmailMessage { + const headers = [ + { name: "Message-ID", value: options.rfcMessageId }, + { name: "From", value: options.fromName ? `${options.fromName} <${options.from}>` : options.from }, + { name: "To", value: options.to }, + { name: "Subject", value: options.subject }, + { name: "Date", value: options.sentAt }, + ]; + if (options.references) { + headers.push({ name: "References", value: options.references }); + } + if (options.inReplyTo) { + headers.push({ name: "In-Reply-To", value: options.inReplyTo }); + } + + return { + id: options.id, + threadId: options.threadId ?? `gmail-thread-${options.id}`, + internalDate: String(new Date(options.sentAt).getTime()), + payload: { + mimeType: "text/plain", + headers, + body: { data: encode(options.body) }, + }, + }; +} + +function setHistory(addedIds: string[], historyId: string) { + historyOutcome = { + outcome: "ok", + data: { + historyId, + history: [ + { + id: historyId, + messagesAdded: addedIds.map((id) => ({ message: { id } })), + }, + ], + }, + }; +} + +async function clean() { + await db.emailMessage.deleteMany({ + where: { + OR: [ + { rfcMessageId: { contains: suffix } }, + { gmailMessageId: { contains: suffix } }, + { thread: { rootMessageId: { contains: suffix } } }, + ], + }, + }); + await db.activity.deleteMany({ + where: { + OR: [ + { createdById: { in: [userId, secondUserId] } }, + { emailThread: { rootMessageId: { contains: suffix } } }, + ], + }, + }); + await db.emailThread.deleteMany({ + where: { rootMessageId: { contains: suffix } }, + }); + await db.contact.deleteMany({ where: { email: person } }); + await db.company.deleteMany({ where: { domain } }); + await db.mailboxSync.deleteMany({ + where: { userId: { in: [userId, secondUserId] } }, + }); + await db.user.deleteMany({ where: { id: { in: [userId, secondUserId] } } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + await db.user.create({ + data: { id: secondUserId, name: "Other Rep", email: secondMailbox }, + }); + + row = await db.mailboxSync.create({ + data: { + userId, + source: "gmail", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + cursor: "hist-ready", + }, + }); + secondRow = await db.mailboxSync.create({ + data: { + userId: secondUserId, + source: "gmail", + autoCreate: false, + status: GoogleSyncStatus.IDLE, + cursor: "hist-ready-b", + }, + }); + + const company = await db.company.create({ + data: { name: "Buyer Co", domain }, + select: { id: true }, + }); + companyId = company.id; + + const contact = await db.contact.create({ + data: { + firstName: "A", + lastName: "Buyer", + email: person, + companyId, + }, + select: { id: true }, + }); + contactId = contact.id; +}); + +afterAll(clean); + +describe("GmailSyncService", () => { + it("stamps the historyId on first sight and imports nothing", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: null, status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + profileHistoryId = "hist-first-sight"; + messagesById = new Map(); + setHistory(["should-not-fetch"], "hist-ignored"); + + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: { contains: suffix } }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBeUndefined(); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBe("hist-first-sight"); + expect( + await db.emailThread.count({ + where: { rootMessageId: { contains: suffix } }, + }), + ).toBe(beforeThreads); + + row = refreshed; + }); + + it("projects a relevant thread onto the company timeline", async () => { + const gmailId = `gm-${suffix}-1`; + const message = gmailMessage({ + id: gmailId, + rfcMessageId: rootRfc, + from: mailbox, + fromName: "Test Rep", + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + }); + messagesById = new Map([[gmailId, message]]); + setHistory([gmailId], "hist-1"); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBe(1); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootId }, + include: { + activity: true, + messages: true, + }, + }); + + expect(thread).not.toBeNull(); + expect(thread?.companyId).toBe(companyId); + expect(thread?.contactId).toBe(contactId); + expect(thread?.messageCount).toBe(1); + expect(thread?.activity?.type).toBe(ActivityType.EMAIL); + expect(thread?.activity?.subject).toBe("Pricing"); + expect(thread?.activity?.companyId).toBe(companyId); + expect(thread?.activity?.body?.endsWith("…")).toBe(true); + expect(thread?.activity?.body?.length).toBeLessThanOrEqual(200); + expect(thread?.messages[0]?.gmailMessageId).toBe(gmailId); + expect(thread?.messages[0]?.rfcMessageId).toBe(rootId); + expect(thread?.messages[0]?.body).toBe(bodyText); + + const meta = thread?.activity?.meta as Record | null; + expect(meta?.synced).toBe(true); + expect(meta?.source).toBe("gmail"); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + expect(row.cursor).toBe("hist-1"); + }); + + it("dedupes on rfcMessageId across two mailboxes with different Gmail ids", async () => { + const otherGmailId = `gm-${suffix}-1b`; + const duplicate = gmailMessage({ + id: otherGmailId, + rfcMessageId: rootRfc, + from: mailbox, + fromName: "Test Rep", + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + threadId: `other-mailbox-thread-${suffix}`, + }); + + const secondGmail = { + async profile() { + return { + outcome: "ok" as const, + data: { + emailAddress: secondMailbox, + historyId: "hist-b-start", + }, + }; + }, + async listHistory() { + return { + outcome: "ok" as const, + data: { + historyId: "hist-b-1", + history: [ + { + id: "hist-b-1", + messagesAdded: [{ message: { id: otherGmailId } }], + }, + ], + }, + }; + }, + async getMessage() { + return { outcome: "ok" as const, data: duplicate }; + }, + } as unknown as GmailClient; + + const otherService = new GmailSyncService( + db, + secondGmail, + tokens, + state, + threads, + ); + + const beforeMessages = await db.emailMessage.count({ + where: { rfcMessageId: rootId }, + }); + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: rootId }, + }); + const beforeActivities = await db.activity.count({ + where: { + type: ActivityType.EMAIL, + emailThread: { rootMessageId: rootId }, + }, + }); + + const outcome = await otherService.sync(secondRow); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailMessage.count({ where: { rfcMessageId: rootId } }), + ).toBe(beforeMessages); + expect( + await db.emailThread.count({ where: { rootMessageId: rootId } }), + ).toBe(beforeThreads); + expect( + await db.activity.count({ + where: { + type: ActivityType.EMAIL, + emailThread: { rootMessageId: rootId }, + }, + }), + ).toBe(beforeActivities); + expect(beforeMessages).toBe(1); + expect(beforeThreads).toBe(1); + expect(beforeActivities).toBe(1); + }); + + it("keeps full bodies out of the timeline list path and returns them on expand", async () => { + const thread = await db.emailThread.findUniqueOrThrow({ + where: { rootMessageId: rootId }, + select: { id: true }, + }); + + const list = await db.activity.findMany({ + where: { emailThreadId: thread.id }, + select: { + id: true, + subject: true, + body: true, + emailThread: { + select: { + id: true, + messageCount: true, + lastMessageAt: true, + }, + }, + }, + }); + + expect(list).toHaveLength(1); + expect(list[0]?.emailThread).not.toBeNull(); + expect(list[0]?.body?.endsWith("…")).toBe(true); + expect(list[0]?.body?.length).toBeLessThanOrEqual(200); + expect(list[0]?.body).not.toBe(bodyText); + expect(JSON.stringify(list[0]?.emailThread)).not.toContain( + "volume discount table", + ); + + const expanded = await conversations.thread(thread.id); + expect(expanded.messages.length).toBe(1); + expect(expanded.messages[0]?.body).toBe(bodyText); + expect(expanded.messages[0]?.mailboxName).toBe("Gmail"); + expect(expanded.messages[0]?.mailboxUrl).toContain("mail.google.com"); + }); + + it("updates the projected activity when a new message arrives on the thread", async () => { + const gmailId = `gm-${suffix}-2`; + const reply = gmailMessage({ + id: gmailId, + rfcMessageId: replyRfc, + from: person, + fromName: "A Buyer", + to: mailbox, + subject: "Re: Pricing", + body: "Thanks — can we also get the volume discount table?", + sentAt: "2026-08-10T16:30:00.000Z", + references: rootRfc, + inReplyTo: rootRfc, + }); + messagesById = new Map([[gmailId, reply]]); + setHistory([gmailId], "hist-2"); + + const gmailForInbound = { + ...gmail, + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: "hist-2" }, + }; + }, + } as unknown as GmailClient; + + const inboundService = new GmailSyncService( + db, + gmailForInbound, + tokens, + state, + threads, + ); + + const outcome = await inboundService.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten).toBe(1); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootId }, + include: { activity: true, messages: true }, + }); + + expect(thread?.messageCount).toBe(2); + expect(thread?.messages).toHaveLength(2); + expect(thread?.activity?.body).toContain("volume discount"); + expect(thread?.activity?.occurredAt?.toISOString()).toBe( + "2026-08-10T16:30:00.000Z", + ); + expect( + thread?.messages.some((message) => message.rfcMessageId === replyId), + ).toBe(true); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("ignores mail that does not resolve to a tracked company when auto-create is off", async () => { + const strangerId = `gm-${suffix}-stranger`; + const strangerRfc = ``; + const strangerKey = `stranger-${suffix}@mail.test`; + const stranger = gmailMessage({ + id: strangerId, + rfcMessageId: strangerRfc, + from: mailbox, + to: "nobody@unknown-host.invalid", + subject: "Hello stranger", + body: "This should never land in the CRM.", + sentAt: "2026-08-11T10:00:00.000Z", + }); + messagesById = new Map([[strangerId, stranger]]); + setHistory([strangerId], "hist-3"); + + const before = await db.emailThread.count({ + where: { rootMessageId: strangerKey }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailThread.count({ where: { rootMessageId: strangerKey } }), + ).toBe(before); + expect( + await db.emailMessage.count({ where: { rfcMessageId: strangerKey } }), + ).toBe(0); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("is idempotent when the same history window is re-run", async () => { + const gmailId = `gm-${suffix}-1`; + const message = gmailMessage({ + id: gmailId, + rfcMessageId: rootRfc, + from: mailbox, + to: person, + subject: "Pricing", + body: bodyText, + sentAt: "2026-08-10T14:00:00.000Z", + }); + messagesById = new Map([[gmailId, message]]); + setHistory([gmailId], "hist-4"); + + const beforeMessages = await db.emailMessage.count({ + where: { thread: { rootMessageId: rootId } }, + }); + const beforeThreads = await db.emailThread.count({ + where: { rootMessageId: rootId }, + }); + + const outcome = await service.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.messagesWritten ?? 0).toBe(0); + expect( + await db.emailMessage.count({ + where: { thread: { rootMessageId: rootId } }, + }), + ).toBe(beforeMessages); + expect( + await db.emailThread.count({ where: { rootMessageId: rootId } }), + ).toBe(beforeThreads); + + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + }); + + it("clears the cursor on history 404 so the next tick resumes from now", async () => { + await db.mailboxSync.update({ + where: { id: row.id }, + data: { cursor: "stale-history", status: GoogleSyncStatus.IDLE }, + }); + row = await db.mailboxSync.findUniqueOrThrow({ where: { id: row.id } }); + + const failingGmail = { + async profile() { + return { + outcome: "ok" as const, + data: { emailAddress: mailbox, historyId: "hist-new" }, + }; + }, + async listHistory() { + return { + outcome: "cursor-invalid" as const, + reason: "Requested entity was not found.", + }; + }, + async getMessage() { + throw new Error("must not fetch messages after cursor invalidation"); + }, + } as unknown as GmailClient; + + const failing = new GmailSyncService( + db, + failingGmail, + tokens, + state, + threads, + ); + + const outcome = await failing.sync(row); + + expect(outcome.status).toBe("synced"); + expect(outcome.reason).toContain("History expired"); + + const refreshed = await db.mailboxSync.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(refreshed.cursor).toBeNull(); + expect(refreshed.status).toBe(GoogleSyncStatus.IDLE); + row = refreshed; + }); +}); diff --git a/docs/plan/gmail-calendar-plan.md b/docs/plan/gmail-calendar-plan.md index ae6fe4dda..c9f703503 100644 --- a/docs/plan/gmail-calendar-plan.md +++ b/docs/plan/gmail-calendar-plan.md @@ -819,3 +819,65 @@ with `class-validator` decorators, and to `docs/environment.md`. an account that will not grant the scopes cannot use the CRM anyway. Revisit only if someone needs "keep my login, stop reading my mail", which the policy currently says is not a state we support. + +--- + +## 16. Status and handoff (Gmail threads on timeline) + +### Shipped (phases 0–3) + +Phases **0–3** are in the tree. Calendar substrate was already present; this +lane locks **Gmail threads on the record timeline** without auto-create. + +| Concern | Where | +| --- | --- | +| Scopes + offline access | `packages/auth/src/scopes.ts`, `packages/auth/src/auth.ts` | +| App shell gate | `apps/app/lib/session.ts` → `requireMailboxAccess()`, `/grant-access` | +| `EmailThread` / `EmailMessage` + `Activity.emailThreadId` | `packages/db/prisma/schema.prisma` | +| Gmail history client + MIME parse | `apps/api/src/google/gmail.client.ts`, `gmail-mime.ts` | +| Forward-only sync (`historyId`) | `apps/api/src/google/gmail-sync.service.ts` | +| Thread write + EMAIL activity projection | `apps/api/src/mailbox/thread-writer.service.ts` | +| Match (contact → company; create only if allowed) | `apps/api/src/mailbox/mailbox-match.service.ts` | +| Expand path with full bodies | `google.thread` → `ConversationService.thread` | +| Timeline filter `email` + accordion UI | `activities.contracts.ts`, `timeline-search-params.ts`, `email-thread-entry.tsx` | +| Acceptance tests | `apps/api/test/gmail-sync.spec.ts` (plus `mailbox-thread-writer`, `mailbox-message-text`) | + +Rules already enforced for Gmail: + +- Identity is RFC 822 `Message-ID` (normalised) for messages and the root of + `References` / `In-Reply-To` / own id for threads — not Gmail `threadId`. +- Cross-mailbox copies of one conversation produce one `EmailThread` and one + projected `Activity`. +- Only threads that resolve to a tracked company/contact are stored when + `autoCreate` is off (this lane leaves Gmail auto-create off). +- Timeline list payloads carry a snippet on `Activity.body` and thread summary + fields only. Full message bodies load on expand via `google.thread`. +- First sight of a mailbox stores the current `historyId` and imports nothing. +- History 404 clears the cursor and resumes from now (no backfill). + +### Remaining handoff — Auto-create (phase 4) + +Do **not** flip Gmail `autoCreate` on by default in this lane. + +1. **Defaults** — calendar may stay true on connect; gmail stays false + (`GoogleConnectionService.onConnected`) until matching is observed on real + mailboxes. +2. **Rules** — two-way engagement for Gmail (rep must have sent); calendar + external attendee + not declined. +3. **Undo surfaces** — `RecordSource` filter, bulk delete, `SuppressedDomain` / + `google.suppressDomain`. +4. **Real-mailbox soak** — run phase 3 on production traffic for a week before + tightening auto-create defaults for email. + +### Out of scope here + +- Phase 5 Pub/Sub real-time (`users.watch`). +- Sending mail from the CRM. +- Meeting-prep agent work (`crm-meeting-prep`). +- Auto-create product closure (phase 4). + +### Done when (this lane) + +A thread with an existing company is projected as one `EMAIL` activity, expands +to readable messages, dedupes across mailboxes on `rfcMessageId`, and the +acceptance suite in `gmail-sync.spec.ts` is green. Auto-create stays off. From cf70a19d28521c6b34cc2373703f8b062ee006e2 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:12:51 -0400 Subject: [PATCH 11/22] feat(agent): Deploy-gated Qualify specialist with lifecycleRole Add lifecycleRole to team-agent manifests and a recommend-only Qualify template (NOTE/TASK + run.summary). Surface role on agent list/detail. Saving stays READY; Deploy remains the human gate. No send tools. --- apps/agent/agent/lib/agent-manifest.ts | 2 + apps/agent/agent/lib/builder-runtime.ts | 3 + apps/agent/agent/lib/lifecycle-qualify.ts | 174 ++++++++++++++++ apps/agent/agent/lib/lifecycle-roles.ts | 15 ++ .../subagents/agent_builder/instructions.md | 6 + .../agent_builder/lib/draft-input.ts | 2 + apps/agent/test/lifecycle-qualify.spec.ts | 191 ++++++++++++++++++ .../src/agent/agent-definitions.service.ts | 51 +++-- .../agent-builder/agent-capabilities.tsx | 1 + .../agent-builder/team-agent-detail.tsx | 24 +++ .../agent-builder/team-agents-index.tsx | 5 + docs/agent.md | 4 + packages/validation/src/agents.ts | 21 ++ packages/validation/src/index.ts | 2 + .../validation/test/lifecycle-role.spec.ts | 24 +++ 15 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 apps/agent/agent/lib/lifecycle-qualify.ts create mode 100644 apps/agent/agent/lib/lifecycle-roles.ts create mode 100644 apps/agent/test/lifecycle-qualify.spec.ts create mode 100644 packages/validation/test/lifecycle-role.spec.ts diff --git a/apps/agent/agent/lib/agent-manifest.ts b/apps/agent/agent/lib/agent-manifest.ts index f8d8b661c..9bd803af4 100644 --- a/apps/agent/agent/lib/agent-manifest.ts +++ b/apps/agent/agent/lib/agent-manifest.ts @@ -1,6 +1,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "./agent-actions"; +import { LIFECYCLE_ROLES } from "./lifecycle-roles"; const slackDestination = z.object({ kind: z.enum(["channel", "user"]), @@ -65,6 +66,7 @@ export const agentManifestResource = z.object({ export const agentManifest = z .object({ description: z.string().optional(), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), actions: z.array(agentManifestAction).min(1), triggers: z.array(agentManifestTrigger).min(1), dataScope: z.object({ diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..efedac6f6 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -8,6 +8,7 @@ import { import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import type { LifecycleRole } from "./lifecycle-roles"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -56,6 +57,7 @@ export type DraftAgentInput = { name: string; description: string; instructions: string; + lifecycleRole?: LifecycleRole; triggers: DraftTrigger[]; recordScope: "SELECTED" | "WORKSPACE"; resources: BuilderResource[]; @@ -232,6 +234,7 @@ export async function saveBuilderDraft( kind: "crm-team-agent", name: input.name, description: input.description, + ...(input.lifecycleRole ? { lifecycleRole: input.lifecycleRole } : {}), triggers: manifestTriggers, dataScope: { mode: input.recordScope, diff --git a/apps/agent/agent/lib/lifecycle-qualify.ts b/apps/agent/agent/lib/lifecycle-qualify.ts new file mode 100644 index 000000000..501248946 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-qualify.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const QUALIFY_LIFECYCLE_ROLE = "qualify" as const; + +export const QUALIFY_SPECIALIST_NAME = "Qualify"; + +export const QUALIFY_SPECIALIST_DESCRIPTION = + "Decide if a contact or company is worth pipeline time using CRM evidence and seller rules. Recommend only. Never send outreach."; + +export const QUALIFY_SPECIALIST_INSTRUCTIONS = `You are the Qualify specialist for this CRM team agent. + +Your job is to decide whether a contact or company is worth pipeline time. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent firmographics, titles, or fit scores. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear qualification decision: worth pursuing, not a fit, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the decision, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the decision and what you wrote. +7. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const QUALIFY_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Qualify selected record", + summary: "Run on demand for a tagged contact or company", + }, + { + type: "EVENT", + name: "When a contact is created", + summary: "Qualify a new contact for pipeline fit", + event: "contact.created", + }, + { + type: "EVENT", + name: "When a company is created", + summary: "Qualify a new company for pipeline fit", + event: "company.created", + }, +]; + +const QUALIFY_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the qualification decision as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the qualification decision and evidence", + }, +]; + +export const QUALIFY_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isRecommendOnlyActionType(type: string): boolean { + return (QUALIFY_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertQualifyRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isRecommendOnlyActionType(action.type)) { + throw new Error( + `Qualify specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type QualifyDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function qualifySpecialistDraft( + options: QualifyDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Qualify draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Qualify draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? QUALIFY_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : QUALIFY_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? QUALIFY_SPECIALIST_NAME, + description: options.description ?? QUALIFY_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? QUALIFY_SPECIALIST_INSTRUCTIONS, + lifecycleRole: QUALIFY_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: QUALIFY_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertQualifyRecommendOnlyActions(draft.actions); + return draft; +} + +export function qualifySpecialistManifest( + options: QualifyDraftOptions = {}, +): AgentManifest { + const draft = qualifySpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for intake qualification" + : "Selected CRM records for manual qualification", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-roles.ts b/apps/agent/agent/lib/lifecycle-roles.ts new file mode 100644 index 000000000..9ebe5a46a --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-roles.ts @@ -0,0 +1,15 @@ +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export function isLifecycleRole(value: unknown): value is LifecycleRole { + return ( + typeof value === "string" && + (LIFECYCLE_ROLES as readonly string[]).includes(value) + ); +} diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index d4321cf79..cae7f23a0 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -41,6 +41,12 @@ Gmail and Google Calendar are read-only sources when connected. Do not promise email sending, arbitrary webhooks, or any integration the context does not report. +Optional `lifecycleRole` tags a specialist for the sales lifecycle: +`qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that +role. For `qualify`, use only `crm.activity.create` and `run.summary`. Never +grant send or Slack actions for qualify. Qualify recommends fit with notes and +tasks only. + Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches exactly one entry in `availableConnections.slackPeople` by CRM name, CRM email, diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..2b7a0b11f 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -2,6 +2,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; +import { LIFECYCLE_ROLES } from "../../../lib/lifecycle-roles"; const recordResource = z.object({ kind: z.enum(["company", "contact", "deal"]), @@ -61,6 +62,7 @@ export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), instructions: z.string().trim().min(40).max(20_000), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), triggers: z.array(trigger).min(1).max(10), recordScope: z.enum(["SELECTED", "WORKSPACE"]), resources: z.array(recordResource).max(30), diff --git a/apps/agent/test/lifecycle-qualify.spec.ts b/apps/agent/test/lifecycle-qualify.spec.ts new file mode 100644 index 000000000..afb44a6d7 --- /dev/null +++ b/apps/agent/test/lifecycle-qualify.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertQualifyRecommendOnlyActions, + isRecommendOnlyActionType, + QUALIFY_LIFECYCLE_ROLE, + QUALIFY_RECOMMEND_ONLY_ACTION_TYPES, + QUALIFY_SPECIALIST_NAME, + qualifySpecialistDraft, + qualifySpecialistManifest, +} from "../agent/lib/lifecycle-qualify"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle qualify specialist", () => { + it("builds a workspace intake draft tagged as qualify", () => { + const draft = qualifySpecialistDraft(); + + expect(draft.name).toBe(QUALIFY_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(QUALIFY_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( + ["contact.created", "company.created"], + ); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...QUALIFY_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = qualifySpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "contact", id: "c1", label: "Ada Lovelace" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + qualifySpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Qualify draft"); + expect(() => + qualifySpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Qualify draft"); + }); + + it("parses the qualify template as a valid runner manifest", () => { + const manifest = qualifySpecialistManifest(); + + expect(manifest.lifecycleRole).toBe("qualify"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect(manifest.actions.every((action) => isRecommendOnlyActionType(action.type))).toBe( + true, + ); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps selected qualify manifests recommend-only", () => { + const manifest = qualifySpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertQualifyRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on qualify", () => { + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = qualifySpecialistDraft(); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => + trigger.type === "EVENT" + ? { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as + | "contact.created" + | "company.created", + } + : { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }, + ), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("qualify"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "EVENT" ? { event: trigger.event } : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace CRM records for intake qualification", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("qualify"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..afdba82a3 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; -import { schemas } from "@crm/validation"; +import { readLifecycleRole, schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -43,7 +43,13 @@ export class AgentDefinitionsService { updatedAt: true, createdBy: { select: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + select: { id: true, number: true, deployedAt: true, manifest: true }, + }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, }, triggers: { where: { enabled: true }, @@ -55,22 +61,29 @@ export class AgentDefinitionsService { }, }); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - currentVersion: row.currentVersion - ? { - ...row.currentVersion, - deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, - } - : null, - triggers: row.triggers.map((trigger) => ({ - ...trigger, - nextRunAt: trigger.nextRunAt?.toISOString() ?? null, - })), - runCount: row._count.runs, - })); + return rows.map((row) => { + const { versions, currentVersion, ...agent } = row; + const manifest = currentVersion?.manifest ?? versions[0]?.manifest; + const { manifest: _manifest, ...currentVersionPublic } = + currentVersion ?? {}; + return { + ...agent, + lifecycleRole: readLifecycleRole(manifest), + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + currentVersion: currentVersion + ? { + ...currentVersionPublic, + deployedAt: currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: agent._count.runs, + }; + }); } async byId(id: string, userId: string) { @@ -825,6 +838,7 @@ function readCapabilities(manifest: unknown) { (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, ) .join("; "), + lifecycleRole: readLifecycleRole(manifest), actions: [], dataScope: null, channel: null, @@ -838,6 +852,7 @@ function readCapabilities(manifest: unknown) { return { readable: true as const, problem: null, + lifecycleRole: parsed.data.lifecycleRole ?? null, actions: parsed.data.actions, dataScope: parsed.data.dataScope, channel: slack?.destination ?? null, diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..9bf294d69 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -29,6 +29,7 @@ export type Resource = { id: string; kind: string; label: string }; export type Capabilities = { readable: boolean; problem: string | null; + lifecycleRole?: "qualify" | "engage" | "advance" | "close" | null; channel: { kind: "channel" | "user"; id: string; label: string } | null; actions: Array<{ type: string; provider: string; summary: string }>; dataScope: { diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..426633c1a 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -209,6 +209,14 @@ export function TeamAgentDetail({ enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + const detailCapabilities = ( + data as unknown as { capabilities?: Capabilities } + ).capabilities; + const lifecycleRole = + detailCapabilities?.lifecycleRole ?? + (isLifecycleRoleText(reviewManifest.lifecycleRole) + ? reviewManifest.lifecycleRole + : null); return ( @@ -216,6 +224,11 @@ export function TeamAgentDetail({ {displayedName} + {lifecycleRole ? ( + + {lifecycleRole} + + ) : null} {displayedDescription} @@ -587,6 +600,17 @@ function textOf(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } +function isLifecycleRoleText( + value: unknown, +): value is "qualify" | "engage" | "advance" | "close" { + return ( + value === "qualify" || + value === "engage" || + value === "advance" || + value === "close" + ); +} + function formatDate(value: string): string { return DATE_FORMATTER.format(new Date(value)); } diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..e17613700 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -39,6 +39,11 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.name} + {agent.lifecycleRole ? ( + + {agent.lifecycleRole} + + ) : null} {agent.status.toLowerCase()} diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..0747db695 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -311,6 +311,10 @@ delegation paths for custom agents. team. Scheduled runner sessions use task mode and therefore cannot pause for a per-action approval; the deployed permission and idempotent runtime checks are the boundary. +- **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field + `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. The Qualify + template lives in `apps/agent/agent/lib/lifecycle-qualify.ts` and is recommend-only: + CRM note/task plus `run.summary`, no send tools. Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and diff --git a/packages/validation/src/agents.ts b/packages/validation/src/agents.ts index f1381b672..cd67845b4 100644 --- a/packages/validation/src/agents.ts +++ b/packages/validation/src/agents.ts @@ -91,7 +91,19 @@ export const capabilityResource = z.object({ label: z.string().trim().min(1).max(160), }); +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export const lifecycleRole = z.enum(LIFECYCLE_ROLES); + export const capabilities = z.object({ + lifecycleRole: lifecycleRole.optional(), actions: z.array(capabilityAction), dataScope: z.object({ mode: z.enum(["SELECTED", "WORKSPACE"]), @@ -103,3 +115,12 @@ export const capabilities = z.object({ export type Capabilities = z.infer; export type CapabilityAction = z.infer; export type CapabilityResource = z.infer; + +export function readLifecycleRole(manifest: unknown): LifecycleRole | null { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return null; + } + const role = (manifest as { lifecycleRole?: unknown }).lifecycleRole; + const parsed = lifecycleRole.safeParse(role); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..d30d91789 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -10,8 +10,10 @@ export type { InputOption, InputRequest, InputRequested, + LifecycleRole, Permission, } from "./agents"; +export { LIFECYCLE_ROLES, lifecycleRole, readLifecycleRole } from "./agents"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts new file mode 100644 index 000000000..ebb4ddd36 --- /dev/null +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { capabilities, readLifecycleRole } from "../src/agents"; + +describe("lifecycleRole validation", () => { + it("reads a valid role from a raw manifest", () => { + expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); + expect(readLifecycleRole({})).toBeNull(); + }); + + it("accepts capabilities with an optional lifecycleRole", () => { + const base = { + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE" as const, summary: "all", resources: [] }, + }; + expect(capabilities.parse(base).lifecycleRole).toBeUndefined(); + expect( + capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, + ).toBe("qualify"); + expect( + capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, + ).toBe(false); + }); +}); From a832e2eab4fd67e780650de614e494343b8a93a6 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:12:51 -0400 Subject: [PATCH 12/22] feat(agent): Deploy-gated Qualify specialist with lifecycleRole Add lifecycleRole to team-agent manifests and a recommend-only Qualify template (NOTE/TASK + run.summary). Surface role on agent list/detail. Saving stays READY; Deploy remains the human gate. No send tools. --- apps/agent/agent/lib/agent-manifest.ts | 2 + apps/agent/agent/lib/builder-runtime.ts | 3 + apps/agent/agent/lib/lifecycle-qualify.ts | 174 ++++++++++++++++ apps/agent/agent/lib/lifecycle-roles.ts | 15 ++ .../subagents/agent_builder/instructions.md | 6 + .../agent_builder/lib/draft-input.ts | 2 + apps/agent/test/lifecycle-qualify.spec.ts | 191 ++++++++++++++++++ .../src/agent/agent-definitions.service.ts | 51 +++-- .../agent-builder/agent-capabilities.tsx | 1 + .../agent-builder/team-agent-detail.tsx | 24 +++ .../agent-builder/team-agents-index.tsx | 5 + docs/agent.md | 4 + packages/validation/src/agents.ts | 21 ++ packages/validation/src/index.ts | 2 + .../validation/test/lifecycle-role.spec.ts | 24 +++ 15 files changed, 507 insertions(+), 18 deletions(-) create mode 100644 apps/agent/agent/lib/lifecycle-qualify.ts create mode 100644 apps/agent/agent/lib/lifecycle-roles.ts create mode 100644 apps/agent/test/lifecycle-qualify.spec.ts create mode 100644 packages/validation/test/lifecycle-role.spec.ts diff --git a/apps/agent/agent/lib/agent-manifest.ts b/apps/agent/agent/lib/agent-manifest.ts index f8d8b661c..9bd803af4 100644 --- a/apps/agent/agent/lib/agent-manifest.ts +++ b/apps/agent/agent/lib/agent-manifest.ts @@ -1,6 +1,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "./agent-actions"; +import { LIFECYCLE_ROLES } from "./lifecycle-roles"; const slackDestination = z.object({ kind: z.enum(["channel", "user"]), @@ -65,6 +66,7 @@ export const agentManifestResource = z.object({ export const agentManifest = z .object({ description: z.string().optional(), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), actions: z.array(agentManifestAction).min(1), triggers: z.array(agentManifestTrigger).min(1), dataScope: z.object({ diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..efedac6f6 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -8,6 +8,7 @@ import { import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import type { LifecycleRole } from "./lifecycle-roles"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -56,6 +57,7 @@ export type DraftAgentInput = { name: string; description: string; instructions: string; + lifecycleRole?: LifecycleRole; triggers: DraftTrigger[]; recordScope: "SELECTED" | "WORKSPACE"; resources: BuilderResource[]; @@ -232,6 +234,7 @@ export async function saveBuilderDraft( kind: "crm-team-agent", name: input.name, description: input.description, + ...(input.lifecycleRole ? { lifecycleRole: input.lifecycleRole } : {}), triggers: manifestTriggers, dataScope: { mode: input.recordScope, diff --git a/apps/agent/agent/lib/lifecycle-qualify.ts b/apps/agent/agent/lib/lifecycle-qualify.ts new file mode 100644 index 000000000..501248946 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-qualify.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const QUALIFY_LIFECYCLE_ROLE = "qualify" as const; + +export const QUALIFY_SPECIALIST_NAME = "Qualify"; + +export const QUALIFY_SPECIALIST_DESCRIPTION = + "Decide if a contact or company is worth pipeline time using CRM evidence and seller rules. Recommend only. Never send outreach."; + +export const QUALIFY_SPECIALIST_INSTRUCTIONS = `You are the Qualify specialist for this CRM team agent. + +Your job is to decide whether a contact or company is worth pipeline time. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent firmographics, titles, or fit scores. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear qualification decision: worth pursuing, not a fit, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the decision, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the decision and what you wrote. +7. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const QUALIFY_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Qualify selected record", + summary: "Run on demand for a tagged contact or company", + }, + { + type: "EVENT", + name: "When a contact is created", + summary: "Qualify a new contact for pipeline fit", + event: "contact.created", + }, + { + type: "EVENT", + name: "When a company is created", + summary: "Qualify a new company for pipeline fit", + event: "company.created", + }, +]; + +const QUALIFY_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the qualification decision as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the qualification decision and evidence", + }, +]; + +export const QUALIFY_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isRecommendOnlyActionType(type: string): boolean { + return (QUALIFY_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertQualifyRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isRecommendOnlyActionType(action.type)) { + throw new Error( + `Qualify specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type QualifyDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function qualifySpecialistDraft( + options: QualifyDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Qualify draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Qualify draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? QUALIFY_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : QUALIFY_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? QUALIFY_SPECIALIST_NAME, + description: options.description ?? QUALIFY_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? QUALIFY_SPECIALIST_INSTRUCTIONS, + lifecycleRole: QUALIFY_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: QUALIFY_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertQualifyRecommendOnlyActions(draft.actions); + return draft; +} + +export function qualifySpecialistManifest( + options: QualifyDraftOptions = {}, +): AgentManifest { + const draft = qualifySpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for intake qualification" + : "Selected CRM records for manual qualification", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-roles.ts b/apps/agent/agent/lib/lifecycle-roles.ts new file mode 100644 index 000000000..9ebe5a46a --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-roles.ts @@ -0,0 +1,15 @@ +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export function isLifecycleRole(value: unknown): value is LifecycleRole { + return ( + typeof value === "string" && + (LIFECYCLE_ROLES as readonly string[]).includes(value) + ); +} diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index d4321cf79..cae7f23a0 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -41,6 +41,12 @@ Gmail and Google Calendar are read-only sources when connected. Do not promise email sending, arbitrary webhooks, or any integration the context does not report. +Optional `lifecycleRole` tags a specialist for the sales lifecycle: +`qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that +role. For `qualify`, use only `crm.activity.create` and `run.summary`. Never +grant send or Slack actions for qualify. Qualify recommends fit with notes and +tasks only. + Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches exactly one entry in `availableConnections.slackPeople` by CRM name, CRM email, diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..2b7a0b11f 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -2,6 +2,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; +import { LIFECYCLE_ROLES } from "../../../lib/lifecycle-roles"; const recordResource = z.object({ kind: z.enum(["company", "contact", "deal"]), @@ -61,6 +62,7 @@ export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), instructions: z.string().trim().min(40).max(20_000), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), triggers: z.array(trigger).min(1).max(10), recordScope: z.enum(["SELECTED", "WORKSPACE"]), resources: z.array(recordResource).max(30), diff --git a/apps/agent/test/lifecycle-qualify.spec.ts b/apps/agent/test/lifecycle-qualify.spec.ts new file mode 100644 index 000000000..afb44a6d7 --- /dev/null +++ b/apps/agent/test/lifecycle-qualify.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertQualifyRecommendOnlyActions, + isRecommendOnlyActionType, + QUALIFY_LIFECYCLE_ROLE, + QUALIFY_RECOMMEND_ONLY_ACTION_TYPES, + QUALIFY_SPECIALIST_NAME, + qualifySpecialistDraft, + qualifySpecialistManifest, +} from "../agent/lib/lifecycle-qualify"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle qualify specialist", () => { + it("builds a workspace intake draft tagged as qualify", () => { + const draft = qualifySpecialistDraft(); + + expect(draft.name).toBe(QUALIFY_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(QUALIFY_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( + ["contact.created", "company.created"], + ); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...QUALIFY_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = qualifySpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "contact", id: "c1", label: "Ada Lovelace" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + qualifySpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Qualify draft"); + expect(() => + qualifySpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Qualify draft"); + }); + + it("parses the qualify template as a valid runner manifest", () => { + const manifest = qualifySpecialistManifest(); + + expect(manifest.lifecycleRole).toBe("qualify"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect(manifest.actions.every((action) => isRecommendOnlyActionType(action.type))).toBe( + true, + ); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps selected qualify manifests recommend-only", () => { + const manifest = qualifySpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertQualifyRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on qualify", () => { + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = qualifySpecialistDraft(); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => + trigger.type === "EVENT" + ? { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as + | "contact.created" + | "company.created", + } + : { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }, + ), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("qualify"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "EVENT" ? { event: trigger.event } : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace CRM records for intake qualification", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("qualify"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..afdba82a3 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; -import { schemas } from "@crm/validation"; +import { readLifecycleRole, schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -43,7 +43,13 @@ export class AgentDefinitionsService { updatedAt: true, createdBy: { select: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + select: { id: true, number: true, deployedAt: true, manifest: true }, + }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, }, triggers: { where: { enabled: true }, @@ -55,22 +61,29 @@ export class AgentDefinitionsService { }, }); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - currentVersion: row.currentVersion - ? { - ...row.currentVersion, - deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, - } - : null, - triggers: row.triggers.map((trigger) => ({ - ...trigger, - nextRunAt: trigger.nextRunAt?.toISOString() ?? null, - })), - runCount: row._count.runs, - })); + return rows.map((row) => { + const { versions, currentVersion, ...agent } = row; + const manifest = currentVersion?.manifest ?? versions[0]?.manifest; + const { manifest: _manifest, ...currentVersionPublic } = + currentVersion ?? {}; + return { + ...agent, + lifecycleRole: readLifecycleRole(manifest), + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + currentVersion: currentVersion + ? { + ...currentVersionPublic, + deployedAt: currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: agent._count.runs, + }; + }); } async byId(id: string, userId: string) { @@ -825,6 +838,7 @@ function readCapabilities(manifest: unknown) { (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, ) .join("; "), + lifecycleRole: readLifecycleRole(manifest), actions: [], dataScope: null, channel: null, @@ -838,6 +852,7 @@ function readCapabilities(manifest: unknown) { return { readable: true as const, problem: null, + lifecycleRole: parsed.data.lifecycleRole ?? null, actions: parsed.data.actions, dataScope: parsed.data.dataScope, channel: slack?.destination ?? null, diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..9bf294d69 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -29,6 +29,7 @@ export type Resource = { id: string; kind: string; label: string }; export type Capabilities = { readable: boolean; problem: string | null; + lifecycleRole?: "qualify" | "engage" | "advance" | "close" | null; channel: { kind: "channel" | "user"; id: string; label: string } | null; actions: Array<{ type: string; provider: string; summary: string }>; dataScope: { diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..426633c1a 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -209,6 +209,14 @@ export function TeamAgentDetail({ enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + const detailCapabilities = ( + data as unknown as { capabilities?: Capabilities } + ).capabilities; + const lifecycleRole = + detailCapabilities?.lifecycleRole ?? + (isLifecycleRoleText(reviewManifest.lifecycleRole) + ? reviewManifest.lifecycleRole + : null); return ( @@ -216,6 +224,11 @@ export function TeamAgentDetail({ {displayedName} + {lifecycleRole ? ( + + {lifecycleRole} + + ) : null} {displayedDescription} @@ -587,6 +600,17 @@ function textOf(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } +function isLifecycleRoleText( + value: unknown, +): value is "qualify" | "engage" | "advance" | "close" { + return ( + value === "qualify" || + value === "engage" || + value === "advance" || + value === "close" + ); +} + function formatDate(value: string): string { return DATE_FORMATTER.format(new Date(value)); } diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..e17613700 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -39,6 +39,11 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.name} + {agent.lifecycleRole ? ( + + {agent.lifecycleRole} + + ) : null} {agent.status.toLowerCase()} diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..0747db695 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -311,6 +311,10 @@ delegation paths for custom agents. team. Scheduled runner sessions use task mode and therefore cannot pause for a per-action approval; the deployed permission and idempotent runtime checks are the boundary. +- **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field + `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. The Qualify + template lives in `apps/agent/agent/lib/lifecycle-qualify.ts` and is recommend-only: + CRM note/task plus `run.summary`, no send tools. Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and diff --git a/packages/validation/src/agents.ts b/packages/validation/src/agents.ts index f1381b672..cd67845b4 100644 --- a/packages/validation/src/agents.ts +++ b/packages/validation/src/agents.ts @@ -91,7 +91,19 @@ export const capabilityResource = z.object({ label: z.string().trim().min(1).max(160), }); +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export const lifecycleRole = z.enum(LIFECYCLE_ROLES); + export const capabilities = z.object({ + lifecycleRole: lifecycleRole.optional(), actions: z.array(capabilityAction), dataScope: z.object({ mode: z.enum(["SELECTED", "WORKSPACE"]), @@ -103,3 +115,12 @@ export const capabilities = z.object({ export type Capabilities = z.infer; export type CapabilityAction = z.infer; export type CapabilityResource = z.infer; + +export function readLifecycleRole(manifest: unknown): LifecycleRole | null { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return null; + } + const role = (manifest as { lifecycleRole?: unknown }).lifecycleRole; + const parsed = lifecycleRole.safeParse(role); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..d30d91789 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -10,8 +10,10 @@ export type { InputOption, InputRequest, InputRequested, + LifecycleRole, Permission, } from "./agents"; +export { LIFECYCLE_ROLES, lifecycleRole, readLifecycleRole } from "./agents"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts new file mode 100644 index 000000000..ebb4ddd36 --- /dev/null +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { capabilities, readLifecycleRole } from "../src/agents"; + +describe("lifecycleRole validation", () => { + it("reads a valid role from a raw manifest", () => { + expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); + expect(readLifecycleRole({})).toBeNull(); + }); + + it("accepts capabilities with an optional lifecycleRole", () => { + const base = { + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE" as const, summary: "all", resources: [] }, + }; + expect(capabilities.parse(base).lifecycleRole).toBeUndefined(); + expect( + capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, + ).toBe("qualify"); + expect( + capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, + ).toBe(false); + }); +}); From 211d397ed44736867986049a13f1c10c1f44187b Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:16:02 -0400 Subject: [PATCH 13/22] feat(agent): Deploy-gated Close specialist (recommend-only) Add lifecycle-close template with lifecycleRole close: SELECTED default, deal.closed workspace option, NOTE/TASK + run.summary only. No send tools, no reopen, no finance writes. Mirror qualify Deploy gate. --- apps/agent/agent/lib/lifecycle-close.ts | 168 ++++++++++++++++ .../subagents/agent_builder/instructions.md | 8 +- apps/agent/test/lifecycle-close.spec.ts | 184 ++++++++++++++++++ docs/agent.md | 6 +- .../validation/test/lifecycle-role.spec.ts | 4 + 5 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 apps/agent/agent/lib/lifecycle-close.ts create mode 100644 apps/agent/test/lifecycle-close.spec.ts diff --git a/apps/agent/agent/lib/lifecycle-close.ts b/apps/agent/agent/lib/lifecycle-close.ts new file mode 100644 index 000000000..ebac4caff --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-close.ts @@ -0,0 +1,168 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const CLOSE_LIFECYCLE_ROLE = "close" as const; + +export const CLOSE_SPECIALIST_NAME = "Close"; + +export const CLOSE_SPECIALIST_DESCRIPTION = + "Win/loss hygiene, handoff notes, and closed-deal checklist under seller rules. Recommend only. Never send outreach or reopen deals."; + +export const CLOSE_SPECIALIST_INSTRUCTIONS = `You are the Close specialist for this CRM team agent. + +Your job is win/loss hygiene, handoff notes, closed-won or closed-lost checklist items, and disqualify recommendations under seller rules. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent outcomes, revenue, or reasons. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear close recommendation: closed-won checklist, closed-lost or disqualify reason, handoff notes for the next owner, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the recommendation and what you wrote. +7. Never send email, SMS, Slack, or any external outreach. Never promise that a message was delivered. +8. Never reopen a deal. Never change deal stage, amount, currency, or ownership. Never write finance fields. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const CLOSE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Close selected deal", + summary: "Run on demand for a tagged closed or closing deal", + }, + { + type: "EVENT", + name: "When a deal is closed", + summary: "Win/loss hygiene and handoff notes for a closed deal", + event: "deal.closed", + }, +]; + +const CLOSE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the close recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the close recommendation and evidence", + }, +]; + +export const CLOSE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isCloseRecommendOnlyActionType(type: string): boolean { + return (CLOSE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertCloseRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isCloseRecommendOnlyActionType(action.type)) { + throw new Error( + `Close specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type CloseDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function closeSpecialistDraft( + options: CloseDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "SELECTED"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Close draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Close draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? CLOSE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : CLOSE_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? CLOSE_SPECIALIST_NAME, + description: options.description ?? CLOSE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? CLOSE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: CLOSE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: CLOSE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertCloseRecommendOnlyActions(draft.actions); + return draft; +} + +export function closeSpecialistManifest( + options: CloseDraftOptions = {}, +): AgentManifest { + const draft = closeSpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for closed-deal hygiene" + : "Selected CRM records for manual close handoff", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index cae7f23a0..c8115be76 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -43,9 +43,11 @@ report. Optional `lifecycleRole` tags a specialist for the sales lifecycle: `qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that -role. For `qualify`, use only `crm.activity.create` and `run.summary`. Never -grant send or Slack actions for qualify. Qualify recommends fit with notes and -tasks only. +role. For `qualify` and `close`, use only `crm.activity.create` and +`run.summary`. Never grant send or Slack actions for qualify or close. Qualify +recommends fit with notes and tasks only. Close recommends win/loss hygiene, +handoff notes, closed checklists, and disqualify reasons with notes and tasks +only. Never reopen deals or write finance fields for close. Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches diff --git a/apps/agent/test/lifecycle-close.spec.ts b/apps/agent/test/lifecycle-close.spec.ts new file mode 100644 index 000000000..69f24791c --- /dev/null +++ b/apps/agent/test/lifecycle-close.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertCloseRecommendOnlyActions, + CLOSE_LIFECYCLE_ROLE, + CLOSE_RECOMMEND_ONLY_ACTION_TYPES, + CLOSE_SPECIALIST_NAME, + closeSpecialistDraft, + closeSpecialistManifest, + isCloseRecommendOnlyActionType, +} from "../agent/lib/lifecycle-close"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle close specialist", () => { + it("builds a selected manual draft tagged as close by default", () => { + const draft = closeSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(draft.name).toBe(CLOSE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(CLOSE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "deal", id: "d1", label: "Acme expansion" }, + ]); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...CLOSE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a workspace draft with deal.closed when requested", () => { + const draft = closeSpecialistDraft({ recordScope: "WORKSPACE" }); + + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "MANUAL", + ]); + expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( + ["deal.closed"], + ); + expect(draft.resources).toEqual([]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + closeSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Close draft"); + expect(() => + closeSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "deal", id: "d1", label: "Acme" }], + }), + ).toThrow("Workspace Close draft"); + }); + + it("parses the close template as a valid runner manifest", () => { + const manifest = closeSpecialistManifest({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.lifecycleRole).toBe("close"); + expect(manifest.dataScope.mode).toBe("SELECTED"); + expect( + manifest.actions.every((action) => + isCloseRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps workspace close manifests recommend-only with deal.closed", () => { + const manifest = closeSpecialistManifest({ recordScope: "WORKSPACE" }); + + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect(manifest.triggers.some((trigger) => trigger.type === "EVENT")).toBe( + true, + ); + assertCloseRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on close", () => { + expect(() => + assertCloseRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertCloseRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = closeSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + })), + recordScope: draft.recordScope, + resources: draft.resources, + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("close"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Selected CRM records for manual close handoff", + resources: draft.resources, + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("close"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/docs/agent.md b/docs/agent.md index 0747db695..b321752c5 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -313,8 +313,10 @@ delegation paths for custom agents. boundary. - **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. The Qualify - template lives in `apps/agent/agent/lib/lifecycle-qualify.ts` and is recommend-only: - CRM note/task plus `run.summary`, no send tools. Saving never makes a version LIVE. + template lives in `apps/agent/agent/lib/lifecycle-qualify.ts`. The Close template + lives in `apps/agent/agent/lib/lifecycle-close.ts`. Both are recommend-only: CRM + note/task plus `run.summary`, no send tools. Close never reopens deals or writes + finance fields. Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts index ebb4ddd36..9e02138c9 100644 --- a/packages/validation/test/lifecycle-role.spec.ts +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -4,6 +4,7 @@ import { capabilities, readLifecycleRole } from "../src/agents"; describe("lifecycleRole validation", () => { it("reads a valid role from a raw manifest", () => { expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "close" })).toBe("close"); expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); expect(readLifecycleRole({})).toBeNull(); }); @@ -17,6 +18,9 @@ describe("lifecycleRole validation", () => { expect( capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, ).toBe("qualify"); + expect( + capabilities.parse({ ...base, lifecycleRole: "close" }).lifecycleRole, + ).toBe("close"); expect( capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, ).toBe(false); From f7de7b9c3c6a80f469357b7e15507779e064da60 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:16:07 -0400 Subject: [PATCH 14/22] feat(agent): Deploy-gated Engage specialist (recommend-only) Add lifecycleRole=engage template that recommends next outreach and queues CRM notes/tasks only. No send email/SMS tools. Deploy remains the human gate. Mirrors Qualify specialist pattern. --- apps/agent/agent/lib/lifecycle-engage.ts | 174 +++++++++++++++++ .../subagents/agent_builder/instructions.md | 8 +- apps/agent/test/lifecycle-engage.spec.ts | 184 ++++++++++++++++++ docs/agent.md | 6 +- .../validation/test/lifecycle-role.spec.ts | 4 + 5 files changed, 371 insertions(+), 5 deletions(-) create mode 100644 apps/agent/agent/lib/lifecycle-engage.ts create mode 100644 apps/agent/test/lifecycle-engage.spec.ts diff --git a/apps/agent/agent/lib/lifecycle-engage.ts b/apps/agent/agent/lib/lifecycle-engage.ts new file mode 100644 index 000000000..f59960283 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-engage.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const ENGAGE_LIFECYCLE_ROLE = "engage" as const; + +export const ENGAGE_SPECIALIST_NAME = "Engage"; + +export const ENGAGE_SPECIALIST_DESCRIPTION = + "Recommend the next outreach for a selected person or company. Queue notes and tasks only. Never send email or SMS."; + +export const ENGAGE_SPECIALIST_INSTRUCTIONS = `You are the Engage specialist for this CRM team agent. + +Your job is to recommend the next outreach and queue the work for a human. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent history, titles, or relationship facts. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Recommend one next outreach: channel, audience, subject or opener, body draft, and why. +5. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the recommendation and what you queued. +7. Never send email, SMS, LinkedIn messages, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one recommendation cycle on the triggering or selected record. +`; + +const ENGAGE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Recommend next outreach", + summary: "Run on demand for a tagged contact, company, or deal", + }, + { + type: "EVENT", + name: "When a deal is opened", + summary: "Recommend first-touch outreach for a newly opened deal", + event: "deal.opened", + }, + { + type: "EVENT", + name: "When a deal stage changes", + summary: "Recommend follow-up outreach after a stage change", + event: "deal.stage.changed", + }, +]; + +const ENGAGE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Queue the outreach recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the recommended outreach and what was queued", + }, +]; + +export const ENGAGE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isEngageRecommendOnlyActionType(type: string): boolean { + return (ENGAGE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertEngageRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isEngageRecommendOnlyActionType(action.type)) { + throw new Error( + `Engage specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type EngageDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function engageSpecialistDraft( + options: EngageDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "SELECTED"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Engage draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Engage draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? ENGAGE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : ENGAGE_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? ENGAGE_SPECIALIST_NAME, + description: options.description ?? ENGAGE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? ENGAGE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: ENGAGE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: ENGAGE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertEngageRecommendOnlyActions(draft.actions); + return draft; +} + +export function engageSpecialistManifest( + options: EngageDraftOptions = {}, +): AgentManifest { + const draft = engageSpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for outreach recommendations" + : "Selected CRM records for manual outreach recommendations", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index cae7f23a0..38e1193fc 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -43,9 +43,11 @@ report. Optional `lifecycleRole` tags a specialist for the sales lifecycle: `qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that -role. For `qualify`, use only `crm.activity.create` and `run.summary`. Never -grant send or Slack actions for qualify. Qualify recommends fit with notes and -tasks only. +role. For `qualify` and `engage`, use only `crm.activity.create` and +`run.summary`. Never grant send or Slack actions for those roles. Qualify +recommends pipeline fit with notes and tasks only. Engage recommends the next +outreach and queues notes or tasks only; it never sends email or SMS. + Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches diff --git a/apps/agent/test/lifecycle-engage.spec.ts b/apps/agent/test/lifecycle-engage.spec.ts new file mode 100644 index 000000000..df99b5f76 --- /dev/null +++ b/apps/agent/test/lifecycle-engage.spec.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertEngageRecommendOnlyActions, + ENGAGE_LIFECYCLE_ROLE, + ENGAGE_RECOMMEND_ONLY_ACTION_TYPES, + ENGAGE_SPECIALIST_NAME, + engageSpecialistDraft, + engageSpecialistManifest, + isEngageRecommendOnlyActionType, +} from "../agent/lib/lifecycle-engage"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle engage specialist", () => { + it("builds a selected draft tagged as engage by default", () => { + const draft = engageSpecialistDraft({ + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.name).toBe(ENGAGE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(ENGAGE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...ENGAGE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a workspace draft with deal lifecycle events", () => { + const draft = engageSpecialistDraft({ recordScope: "WORKSPACE" }); + + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean).sort(), + ).toEqual(["deal.opened", "deal.stage.changed"]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + engageSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Engage draft"); + expect(() => + engageSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Engage draft"); + }); + + it("parses the engage template as a valid runner manifest", () => { + const manifest = engageSpecialistManifest({ + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(manifest.lifecycleRole).toBe("engage"); + expect(manifest.dataScope.mode).toBe("SELECTED"); + expect( + manifest.actions.every((action) => + isEngageRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps workspace engage manifests recommend-only", () => { + const manifest = engageSpecialistManifest({ recordScope: "WORKSPACE" }); + + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + assertEngageRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on engage", () => { + expect(() => + assertEngageRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertEngageRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = engageSpecialistDraft({ + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + })), + recordScope: draft.recordScope, + resources: draft.resources + .filter((resource) => resource.kind !== "integration") + .map((resource) => ({ + kind: resource.kind as "company" | "contact" | "deal", + id: resource.id as string, + label: resource.label, + })), + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("engage"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Selected CRM records for manual outreach recommendations", + resources: draft.resources, + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("engage"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/docs/agent.md b/docs/agent.md index 0747db695..3972ecb3f 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -313,8 +313,10 @@ delegation paths for custom agents. boundary. - **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. The Qualify - template lives in `apps/agent/agent/lib/lifecycle-qualify.ts` and is recommend-only: - CRM note/task plus `run.summary`, no send tools. Saving never makes a version LIVE. + template lives in `apps/agent/agent/lib/lifecycle-qualify.ts`. The Engage template + lives in `apps/agent/agent/lib/lifecycle-engage.ts`. Both are recommend-only: + CRM note/task plus `run.summary`, no send tools. Engage queues outreach + recommendations only and never sends email or SMS. Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts index ebb4ddd36..962b08670 100644 --- a/packages/validation/test/lifecycle-role.spec.ts +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -4,6 +4,7 @@ import { capabilities, readLifecycleRole } from "../src/agents"; describe("lifecycleRole validation", () => { it("reads a valid role from a raw manifest", () => { expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "engage" })).toBe("engage"); expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); expect(readLifecycleRole({})).toBeNull(); }); @@ -17,6 +18,9 @@ describe("lifecycleRole validation", () => { expect( capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, ).toBe("qualify"); + expect( + capabilities.parse({ ...base, lifecycleRole: "engage" }).lifecycleRole, + ).toBe("engage"); expect( capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, ).toBe(false); From 7c6019272f3ade20595b256b877c5cd0bf7a673f Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:16:12 -0400 Subject: [PATCH 15/22] feat(agent): Deploy-gated Advance specialist with lifecycleRole Add lifecycleRole substrate and a recommend-only Advance template (next stage/step as NOTE/TASK + run.summary). Workspace triggers cover deal.opened, deal.stage.changed, daily stall schedule, and manual. No stage mutation, no send tools. Deploy remains the human gate. --- apps/agent/agent/lib/agent-manifest.ts | 2 + apps/agent/agent/lib/builder-runtime.ts | 3 + apps/agent/agent/lib/lifecycle-advance.ts | 192 ++++++++++++++++ apps/agent/agent/lib/lifecycle-qualify.ts | 174 +++++++++++++++ apps/agent/agent/lib/lifecycle-roles.ts | 15 ++ .../subagents/agent_builder/instructions.md | 7 + .../agent_builder/lib/draft-input.ts | 2 + apps/agent/test/lifecycle-advance.spec.ts | 209 ++++++++++++++++++ apps/agent/test/lifecycle-qualify.spec.ts | 191 ++++++++++++++++ .../src/agent/agent-definitions.service.ts | 51 +++-- .../agent-builder/agent-capabilities.tsx | 1 + .../agent-builder/team-agent-detail.tsx | 24 ++ .../agent-builder/team-agents-index.tsx | 5 + docs/agent.md | 6 + packages/validation/src/agents.ts | 21 ++ packages/validation/src/index.ts | 2 + .../validation/test/lifecycle-role.spec.ts | 28 +++ 17 files changed, 915 insertions(+), 18 deletions(-) create mode 100644 apps/agent/agent/lib/lifecycle-advance.ts create mode 100644 apps/agent/agent/lib/lifecycle-qualify.ts create mode 100644 apps/agent/agent/lib/lifecycle-roles.ts create mode 100644 apps/agent/test/lifecycle-advance.spec.ts create mode 100644 apps/agent/test/lifecycle-qualify.spec.ts create mode 100644 packages/validation/test/lifecycle-role.spec.ts diff --git a/apps/agent/agent/lib/agent-manifest.ts b/apps/agent/agent/lib/agent-manifest.ts index f8d8b661c..9bd803af4 100644 --- a/apps/agent/agent/lib/agent-manifest.ts +++ b/apps/agent/agent/lib/agent-manifest.ts @@ -1,6 +1,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "./agent-actions"; +import { LIFECYCLE_ROLES } from "./lifecycle-roles"; const slackDestination = z.object({ kind: z.enum(["channel", "user"]), @@ -65,6 +66,7 @@ export const agentManifestResource = z.object({ export const agentManifest = z .object({ description: z.string().optional(), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), actions: z.array(agentManifestAction).min(1), triggers: z.array(agentManifestTrigger).min(1), dataScope: z.object({ diff --git a/apps/agent/agent/lib/builder-runtime.ts b/apps/agent/agent/lib/builder-runtime.ts index 8a20494c0..efedac6f6 100644 --- a/apps/agent/agent/lib/builder-runtime.ts +++ b/apps/agent/agent/lib/builder-runtime.ts @@ -8,6 +8,7 @@ import { import { readAgentModel } from "@crm/db/settings"; import { WORKSPACE_ID } from "@crm/db/workspace"; import { AGENT_ACTION_TYPES, actionDependency } from "./agent-actions"; +import type { LifecycleRole } from "./lifecycle-roles"; import { requestStaleSlackInventorySync } from "./slack-people"; const GMAIL_SCOPE = "https://www.googleapis.com/auth/gmail.readonly"; @@ -56,6 +57,7 @@ export type DraftAgentInput = { name: string; description: string; instructions: string; + lifecycleRole?: LifecycleRole; triggers: DraftTrigger[]; recordScope: "SELECTED" | "WORKSPACE"; resources: BuilderResource[]; @@ -232,6 +234,7 @@ export async function saveBuilderDraft( kind: "crm-team-agent", name: input.name, description: input.description, + ...(input.lifecycleRole ? { lifecycleRole: input.lifecycleRole } : {}), triggers: manifestTriggers, dataScope: { mode: input.recordScope, diff --git a/apps/agent/agent/lib/lifecycle-advance.ts b/apps/agent/agent/lib/lifecycle-advance.ts new file mode 100644 index 000000000..4ba90c561 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-advance.ts @@ -0,0 +1,192 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const ADVANCE_LIFECYCLE_ROLE = "advance" as const; + +export const ADVANCE_SPECIALIST_NAME = "Advance"; + +export const ADVANCE_SPECIALIST_DESCRIPTION = + "Recommend the next stage and next step for open deals using CRM evidence. Recommend only. Never mutate stage unattended."; + +export const ADVANCE_SPECIALIST_INSTRUCTIONS = `You are the Advance specialist for this CRM team agent. + +Your job is to move open deals forward by recommending the next stage and the next human step. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Focus on open deals and their linked contacts. Base every claim on CRM evidence you read in this run. +3. Do not invent stakeholders, activity, or stage history that is not in the record. +4. Detect stall from last activity and deal fields. State the gap in plain language. +5. Recommend a next stage only when evidence supports it. Write that recommendation as a CRM task or note. Never change deal stage yourself. +6. Recommend a concrete next step for the owner: who to contact, what to ask, or what is missing. +7. Write only approved CRM notes and tasks. Put the recommendation, the reason, and the evidence references in that note or task. +8. Finish with run.summary. State the recommended stage or next step and what you wrote. +9. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +10. Never change deal stage, amount, ownership, or close state. +11. Stop after one recommendation cycle on the triggering or selected deal. +`; + +const ADVANCE_STALL_INTERVAL_MINUTES = 24 * 60; + +const ADVANCE_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Advance selected deal", + summary: "Run on demand for a tagged open deal", + }, + { + type: "EVENT", + name: "When a deal is opened", + summary: "Recommend the first next step for a new open deal", + event: "deal.opened", + }, + { + type: "EVENT", + name: "When a deal stage changes", + summary: "Recommend the next step after a stage change", + event: "deal.stage.changed", + }, + { + type: "SCHEDULE", + name: "Daily stall check", + summary: "Scan open deals for stalled next steps", + intervalMinutes: ADVANCE_STALL_INTERVAL_MINUTES, + }, +]; + +const ADVANCE_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the next-step recommendation as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the recommended stage, next step, and evidence", + }, +]; + +export const ADVANCE_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isAdvanceRecommendOnlyActionType(type: string): boolean { + return (ADVANCE_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertAdvanceRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isAdvanceRecommendOnlyActionType(action.type)) { + throw new Error( + `Advance specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type AdvanceDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; + now?: string; +}; + +export function advanceSpecialistDraft( + options: AdvanceDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Advance draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Advance draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? ADVANCE_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : ADVANCE_TRIGGERS.map((trigger) => + trigger.type === "SCHEDULE" + ? { + ...trigger, + nextRunAt: options.now ?? new Date().toISOString(), + } + : trigger, + ); + + const draft: DraftAgentInput = { + name: options.name ?? ADVANCE_SPECIALIST_NAME, + description: options.description ?? ADVANCE_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? ADVANCE_SPECIALIST_INSTRUCTIONS, + lifecycleRole: ADVANCE_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: ADVANCE_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertAdvanceRecommendOnlyActions(draft.actions); + return draft; +} + +export function advanceSpecialistManifest( + options: AdvanceDraftOptions = {}, +): AgentManifest { + const draft = advanceSpecialistDraft(options); + const now = options.now ?? new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace open deals for advance recommendations" + : "Selected deals for manual advance recommendations", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-qualify.ts b/apps/agent/agent/lib/lifecycle-qualify.ts new file mode 100644 index 000000000..501248946 --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-qualify.ts @@ -0,0 +1,174 @@ +import { AGENT_ACTION_TYPES } from "./agent-actions"; +import type { AgentManifest } from "./agent-manifest"; +import { parseAgentManifest } from "./agent-manifest"; +import type { DraftAgentInput, DraftTrigger } from "./builder-runtime"; + +export const QUALIFY_LIFECYCLE_ROLE = "qualify" as const; + +export const QUALIFY_SPECIALIST_NAME = "Qualify"; + +export const QUALIFY_SPECIALIST_DESCRIPTION = + "Decide if a contact or company is worth pipeline time using CRM evidence and seller rules. Recommend only. Never send outreach."; + +export const QUALIFY_SPECIALIST_INSTRUCTIONS = `You are the Qualify specialist for this CRM team agent. + +Your job is to decide whether a contact or company is worth pipeline time. + +Rules: +1. Read only CRM records in the approved scope and connected sources listed in the run. +2. Base every claim on CRM evidence you read in this run. Do not invent firmographics, titles, or fit scores. +3. Treat seller policy as external human config when available. If seller rules are missing, say so and stop inventing policy. +4. Produce a clear qualification decision: worth pursuing, not a fit, or needs human judgment. +5. Write only approved CRM notes and tasks. Put the decision, the reason, and the evidence references in that note or task. +6. Finish with run.summary. State the decision and what you wrote. +7. Never send email, SMS, or any external outreach. Never promise that a message was delivered. +8. Never change deal stage or ownership. +9. Stop after one decision cycle on the triggering or selected record. +`; + +const QUALIFY_TRIGGERS: DraftTrigger[] = [ + { + type: "MANUAL", + name: "Qualify selected record", + summary: "Run on demand for a tagged contact or company", + }, + { + type: "EVENT", + name: "When a contact is created", + summary: "Qualify a new contact for pipeline fit", + event: "contact.created", + }, + { + type: "EVENT", + name: "When a company is created", + summary: "Qualify a new company for pipeline fit", + event: "company.created", + }, +]; + +const QUALIFY_ACTIONS: DraftAgentInput["actions"] = [ + { + type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + provider: "crm", + summary: "Record the qualification decision as a note or owner task", + activityTypes: ["NOTE", "TASK"], + }, + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize the qualification decision and evidence", + }, +]; + +export const QUALIFY_RECOMMEND_ONLY_ACTION_TYPES = [ + AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + AGENT_ACTION_TYPES.RUN_SUMMARY, +] as const; + +export function isRecommendOnlyActionType(type: string): boolean { + return (QUALIFY_RECOMMEND_ONLY_ACTION_TYPES as readonly string[]).includes( + type, + ); +} + +export function assertQualifyRecommendOnlyActions( + actions: ReadonlyArray<{ type: string }>, +): void { + for (const action of actions) { + if (!isRecommendOnlyActionType(action.type)) { + throw new Error( + `Qualify specialist forbids action type ${action.type}. Recommend-only CRM note/task and run summary are allowed.`, + ); + } + } +} + +export type QualifyDraftOptions = { + recordScope?: "SELECTED" | "WORKSPACE"; + resources?: DraftAgentInput["resources"]; + name?: string; + description?: string; + instructions?: string; +}; + +export function qualifySpecialistDraft( + options: QualifyDraftOptions = {}, +): DraftAgentInput { + const recordScope = options.recordScope ?? "WORKSPACE"; + const resources = options.resources ?? []; + const recordResources = resources.filter( + (resource) => resource.kind !== "integration", + ); + + if (recordScope === "SELECTED" && recordResources.length === 0) { + throw new Error("Selected Qualify draft needs at least one CRM resource."); + } + if (recordScope === "WORKSPACE" && recordResources.length > 0) { + throw new Error("Workspace Qualify draft cannot list selected records."); + } + + const triggers = + recordScope === "SELECTED" + ? QUALIFY_TRIGGERS.filter((trigger) => trigger.type === "MANUAL") + : QUALIFY_TRIGGERS; + + const draft: DraftAgentInput = { + name: options.name ?? QUALIFY_SPECIALIST_NAME, + description: options.description ?? QUALIFY_SPECIALIST_DESCRIPTION, + instructions: options.instructions ?? QUALIFY_SPECIALIST_INSTRUCTIONS, + lifecycleRole: QUALIFY_LIFECYCLE_ROLE, + triggers, + recordScope, + resources, + actions: QUALIFY_ACTIONS, + access: [ + recordScope === "WORKSPACE" + ? "Read workspace CRM records" + : "Read selected CRM records", + "Write notes on CRM records", + "Create tasks on CRM records", + ], + }; + + assertQualifyRecommendOnlyActions(draft.actions); + return draft; +} + +export function qualifySpecialistManifest( + options: QualifyDraftOptions = {}, +): AgentManifest { + const draft = qualifySpecialistDraft(options); + const now = new Date().toISOString(); + const manifest = { + kind: "crm-team-agent", + name: draft.name, + description: draft.description, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? now, + } + : trigger.type === "EVENT" + ? { event: trigger.event } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: + draft.recordScope === "WORKSPACE" + ? "Workspace CRM records for intake qualification" + : "Selected CRM records for manual qualification", + resources: draft.resources, + }, + actions: draft.actions, + access: draft.access, + }; + + return parseAgentManifest(manifest); +} diff --git a/apps/agent/agent/lib/lifecycle-roles.ts b/apps/agent/agent/lib/lifecycle-roles.ts new file mode 100644 index 000000000..9ebe5a46a --- /dev/null +++ b/apps/agent/agent/lib/lifecycle-roles.ts @@ -0,0 +1,15 @@ +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export function isLifecycleRole(value: unknown): value is LifecycleRole { + return ( + typeof value === "string" && + (LIFECYCLE_ROLES as readonly string[]).includes(value) + ); +} diff --git a/apps/agent/agent/subagents/agent_builder/instructions.md b/apps/agent/agent/subagents/agent_builder/instructions.md index d4321cf79..2a5aeae4b 100644 --- a/apps/agent/agent/subagents/agent_builder/instructions.md +++ b/apps/agent/agent/subagents/agent_builder/instructions.md @@ -41,6 +41,13 @@ Gmail and Google Calendar are read-only sources when connected. Do not promise email sending, arbitrary webhooks, or any integration the context does not report. +Optional `lifecycleRole` tags a specialist for the sales lifecycle: +`qualify`, `engage`, `advance`, or `close`. Set it when the user asks for that +role. For `qualify` and `advance`, use only `crm.activity.create` and +`run.summary`. Never grant send or Slack actions for those roles. Qualify +recommends fit with notes and tasks only. Advance recommends stage and next step +as notes and tasks only, and never mutates deal stage. + Every executable Slack destination is `chosen` and pinned to an inspected Slack id. When a named person matches exactly one entry in `availableConnections.slackPeople` by CRM name, CRM email, diff --git a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts index 453e3cf39..2b7a0b11f 100644 --- a/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts +++ b/apps/agent/agent/subagents/agent_builder/lib/draft-input.ts @@ -2,6 +2,7 @@ import { CRM_EVENT_TYPES } from "@crm/db/crm-events"; import { z } from "zod"; import { AGENT_ACTION_TYPES } from "../../../lib/agent-actions"; import type { DraftAgentInput } from "../../../lib/builder-runtime"; +import { LIFECYCLE_ROLES } from "../../../lib/lifecycle-roles"; const recordResource = z.object({ kind: z.enum(["company", "contact", "deal"]), @@ -61,6 +62,7 @@ export const builderDraftToolInput = z.object({ name: z.string().trim().min(1).max(100), description: z.string().trim().min(1).max(320), instructions: z.string().trim().min(40).max(20_000), + lifecycleRole: z.enum(LIFECYCLE_ROLES).optional(), triggers: z.array(trigger).min(1).max(10), recordScope: z.enum(["SELECTED", "WORKSPACE"]), resources: z.array(recordResource).max(30), diff --git a/apps/agent/test/lifecycle-advance.spec.ts b/apps/agent/test/lifecycle-advance.spec.ts new file mode 100644 index 000000000..9ff76f635 --- /dev/null +++ b/apps/agent/test/lifecycle-advance.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + ADVANCE_LIFECYCLE_ROLE, + ADVANCE_RECOMMEND_ONLY_ACTION_TYPES, + ADVANCE_SPECIALIST_NAME, + advanceSpecialistDraft, + advanceSpecialistManifest, + assertAdvanceRecommendOnlyActions, + isAdvanceRecommendOnlyActionType, +} from "../agent/lib/lifecycle-advance"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +const FIXED_NOW = "2026-08-12T12:00:00.000Z"; + +describe("lifecycle advance specialist", () => { + it("builds a workspace draft tagged as advance with event and schedule triggers", () => { + const draft = advanceSpecialistDraft({ now: FIXED_NOW }); + + expect(draft.name).toBe(ADVANCE_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(ADVANCE_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + "SCHEDULE", + ]); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean).sort(), + ).toEqual(["deal.opened", "deal.stage.changed"]); + const schedule = draft.triggers.find((trigger) => trigger.type === "SCHEDULE"); + expect(schedule?.intervalMinutes).toBe(24 * 60); + expect(schedule?.nextRunAt).toBe(FIXED_NOW); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...ADVANCE_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = advanceSpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "deal", id: "d1", label: "Acme expansion" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + advanceSpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Advance draft"); + expect(() => + advanceSpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "deal", id: "d1", label: "Acme" }], + }), + ).toThrow("Workspace Advance draft"); + }); + + it("parses the advance template as a valid runner manifest", () => { + const manifest = advanceSpecialistManifest({ now: FIXED_NOW }); + + expect(manifest.lifecycleRole).toBe("advance"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect( + manifest.actions.every((action) => + isAdvanceRecommendOnlyActionType(action.type), + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + expect( + manifest.triggers.some( + (trigger) => + trigger.type === "EVENT" && trigger.config.event === "deal.opened", + ), + ).toBe(true); + expect( + manifest.triggers.some( + (trigger) => + trigger.type === "EVENT" && + trigger.config.event === "deal.stage.changed", + ), + ).toBe(true); + expect( + manifest.triggers.some((trigger) => trigger.type === "SCHEDULE"), + ).toBe(true); + }); + + it("keeps selected advance manifests recommend-only", () => { + const manifest = advanceSpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertAdvanceRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on advance", () => { + expect(() => + assertAdvanceRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertAdvanceRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = advanceSpecialistDraft({ now: FIXED_NOW }); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => { + if (trigger.type === "EVENT") { + return { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as "deal.opened" | "deal.stage.changed", + }; + } + if (trigger.type === "SCHEDULE") { + return { + type: "SCHEDULE" as const, + name: trigger.name, + summary: trigger.summary, + intervalMinutes: trigger.intervalMinutes as number, + nextRunAt: trigger.nextRunAt as string, + }; + } + return { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }; + }), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("advance"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "EVENT" + ? { event: trigger.event } + : trigger.type === "SCHEDULE" + ? { + intervalMinutes: trigger.intervalMinutes, + nextRunAt: trigger.nextRunAt ?? FIXED_NOW, + } + : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace open deals for advance recommendations", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("advance"); + }); + + it("instructions forbid unattended stage mutation and outreach", () => { + const draft = advanceSpecialistDraft(); + expect(draft.instructions).toContain("Never change deal stage"); + expect(draft.instructions).toContain("Never send email"); + expect(draft.description).toContain("Recommend only"); + }); +}); diff --git a/apps/agent/test/lifecycle-qualify.spec.ts b/apps/agent/test/lifecycle-qualify.spec.ts new file mode 100644 index 000000000..afb44a6d7 --- /dev/null +++ b/apps/agent/test/lifecycle-qualify.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "bun:test"; +import { AGENT_ACTION_TYPES } from "../agent/lib/agent-actions"; +import { parseAgentManifest } from "../agent/lib/agent-manifest"; +import { + assertQualifyRecommendOnlyActions, + isRecommendOnlyActionType, + QUALIFY_LIFECYCLE_ROLE, + QUALIFY_RECOMMEND_ONLY_ACTION_TYPES, + QUALIFY_SPECIALIST_NAME, + qualifySpecialistDraft, + qualifySpecialistManifest, +} from "../agent/lib/lifecycle-qualify"; +import { + builderDraftToolInput, + draftInputFromTool, +} from "../agent/subagents/agent_builder/lib/draft-input"; + +describe("lifecycle qualify specialist", () => { + it("builds a workspace intake draft tagged as qualify", () => { + const draft = qualifySpecialistDraft(); + + expect(draft.name).toBe(QUALIFY_SPECIALIST_NAME); + expect(draft.lifecycleRole).toBe(QUALIFY_LIFECYCLE_ROLE); + expect(draft.recordScope).toBe("WORKSPACE"); + expect(draft.triggers.map((trigger) => trigger.type).sort()).toEqual([ + "EVENT", + "EVENT", + "MANUAL", + ]); + expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( + ["contact.created", "company.created"], + ); + expect(draft.actions.map((action) => action.type).sort()).toEqual( + [...QUALIFY_RECOMMEND_ONLY_ACTION_TYPES].sort(), + ); + }); + + it("builds a selected manual draft when records are supplied", () => { + const draft = qualifySpecialistDraft({ + recordScope: "SELECTED", + resources: [{ kind: "contact", id: "c1", label: "Ada Lovelace" }], + }); + + expect(draft.recordScope).toBe("SELECTED"); + expect(draft.triggers).toHaveLength(1); + expect(draft.triggers[0]?.type).toBe("MANUAL"); + expect(draft.resources).toEqual([ + { kind: "contact", id: "c1", label: "Ada Lovelace" }, + ]); + }); + + it("rejects selected drafts without records and workspace drafts with records", () => { + expect(() => + qualifySpecialistDraft({ recordScope: "SELECTED", resources: [] }), + ).toThrow("Selected Qualify draft"); + expect(() => + qualifySpecialistDraft({ + recordScope: "WORKSPACE", + resources: [{ kind: "company", id: "co1", label: "Acme" }], + }), + ).toThrow("Workspace Qualify draft"); + }); + + it("parses the qualify template as a valid runner manifest", () => { + const manifest = qualifySpecialistManifest(); + + expect(manifest.lifecycleRole).toBe("qualify"); + expect(manifest.dataScope.mode).toBe("WORKSPACE"); + expect(manifest.actions.every((action) => isRecommendOnlyActionType(action.type))).toBe( + true, + ); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.RUN_SUMMARY, + ), + ).toBe(true); + expect( + manifest.actions.some( + (action) => action.type === AGENT_ACTION_TYPES.SLACK_MESSAGE_POST, + ), + ).toBe(false); + }); + + it("keeps selected qualify manifests recommend-only", () => { + const manifest = qualifySpecialistManifest({ + recordScope: "SELECTED", + resources: [{ kind: "deal", id: "d1", label: "Acme expansion" }], + }); + + expect(manifest.dataScope.mode).toBe("SELECTED"); + assertQualifyRecommendOnlyActions(manifest.actions); + }); + + it("forbids non-recommend action types on qualify", () => { + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.SLACK_MESSAGE_POST }, + ]), + ).toThrow("forbids action type"); + expect(() => + assertQualifyRecommendOnlyActions([ + { type: AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE }, + { type: AGENT_ACTION_TYPES.RUN_SUMMARY }, + ]), + ).not.toThrow(); + }); + + it("round-trips lifecycleRole through builder draft input", () => { + const draft = qualifySpecialistDraft(); + const parsed = builderDraftToolInput.parse({ + name: draft.name, + description: draft.description, + instructions: draft.instructions, + lifecycleRole: draft.lifecycleRole, + triggers: draft.triggers.map((trigger) => + trigger.type === "EVENT" + ? { + type: "EVENT" as const, + name: trigger.name, + summary: trigger.summary, + event: trigger.event as + | "contact.created" + | "company.created", + } + : { + type: "MANUAL" as const, + name: trigger.name, + summary: trigger.summary, + }, + ), + recordScope: draft.recordScope, + resources: [], + integrations: [], + actions: draft.actions, + }); + + expect(draftInputFromTool(parsed).lifecycleRole).toBe("qualify"); + expect( + parseAgentManifest({ + description: parsed.description, + lifecycleRole: parsed.lifecycleRole, + triggers: draft.triggers.map((trigger) => ({ + type: trigger.type, + name: trigger.name, + summary: trigger.summary, + config: + trigger.type === "EVENT" ? { event: trigger.event } : {}, + })), + dataScope: { + mode: draft.recordScope, + summary: "Workspace CRM records for intake qualification", + resources: [], + }, + actions: draft.actions, + }).lifecycleRole, + ).toBe("qualify"); + }); + + it("leaves generic team agents without a lifecycle role", () => { + const manifest = parseAgentManifest({ + description: "Generic alert", + triggers: [ + { + type: "MANUAL", + name: "Run", + summary: "On demand", + config: {}, + }, + ], + dataScope: { + mode: "WORKSPACE", + summary: "Workspace", + resources: [], + }, + actions: [ + { + type: AGENT_ACTION_TYPES.RUN_SUMMARY, + provider: "crm", + summary: "Summarize", + }, + ], + }); + + expect(manifest.lifecycleRole).toBeUndefined(); + }); +}); diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..afdba82a3 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; -import { schemas } from "@crm/validation"; +import { readLifecycleRole, schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -43,7 +43,13 @@ export class AgentDefinitionsService { updatedAt: true, createdBy: { select: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + select: { id: true, number: true, deployedAt: true, manifest: true }, + }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, }, triggers: { where: { enabled: true }, @@ -55,22 +61,29 @@ export class AgentDefinitionsService { }, }); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - currentVersion: row.currentVersion - ? { - ...row.currentVersion, - deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, - } - : null, - triggers: row.triggers.map((trigger) => ({ - ...trigger, - nextRunAt: trigger.nextRunAt?.toISOString() ?? null, - })), - runCount: row._count.runs, - })); + return rows.map((row) => { + const { versions, currentVersion, ...agent } = row; + const manifest = currentVersion?.manifest ?? versions[0]?.manifest; + const { manifest: _manifest, ...currentVersionPublic } = + currentVersion ?? {}; + return { + ...agent, + lifecycleRole: readLifecycleRole(manifest), + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + currentVersion: currentVersion + ? { + ...currentVersionPublic, + deployedAt: currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: agent._count.runs, + }; + }); } async byId(id: string, userId: string) { @@ -825,6 +838,7 @@ function readCapabilities(manifest: unknown) { (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, ) .join("; "), + lifecycleRole: readLifecycleRole(manifest), actions: [], dataScope: null, channel: null, @@ -838,6 +852,7 @@ function readCapabilities(manifest: unknown) { return { readable: true as const, problem: null, + lifecycleRole: parsed.data.lifecycleRole ?? null, actions: parsed.data.actions, dataScope: parsed.data.dataScope, channel: slack?.destination ?? null, diff --git a/apps/app/components/agent-builder/agent-capabilities.tsx b/apps/app/components/agent-builder/agent-capabilities.tsx index 9fe8fa03b..9bf294d69 100644 --- a/apps/app/components/agent-builder/agent-capabilities.tsx +++ b/apps/app/components/agent-builder/agent-capabilities.tsx @@ -29,6 +29,7 @@ export type Resource = { id: string; kind: string; label: string }; export type Capabilities = { readable: boolean; problem: string | null; + lifecycleRole?: "qualify" | "engage" | "advance" | "close" | null; channel: { kind: "channel" | "user"; id: string; label: string } | null; actions: Array<{ type: string; provider: string; summary: string }>; dataScope: { diff --git a/apps/app/components/agent-builder/team-agent-detail.tsx b/apps/app/components/agent-builder/team-agent-detail.tsx index e85f0cd39..426633c1a 100644 --- a/apps/app/components/agent-builder/team-agent-detail.tsx +++ b/apps/app/components/agent-builder/team-agent-detail.tsx @@ -209,6 +209,14 @@ export function TeamAgentDetail({ enabledTriggers.length === 1 ? enabledTriggers[0]?.nextRunAt : null; const triggerSummary = enabledTriggers.map((trigger) => trigger.name).join(" · ") || "Manual only"; + const detailCapabilities = ( + data as unknown as { capabilities?: Capabilities } + ).capabilities; + const lifecycleRole = + detailCapabilities?.lifecycleRole ?? + (isLifecycleRoleText(reviewManifest.lifecycleRole) + ? reviewManifest.lifecycleRole + : null); return ( @@ -216,6 +224,11 @@ export function TeamAgentDetail({ {displayedName} + {lifecycleRole ? ( + + {lifecycleRole} + + ) : null} {displayedDescription} @@ -587,6 +600,17 @@ function textOf(value: unknown, fallback: string): string { return typeof value === "string" && value.trim() ? value : fallback; } +function isLifecycleRoleText( + value: unknown, +): value is "qualify" | "engage" | "advance" | "close" { + return ( + value === "qualify" || + value === "engage" || + value === "advance" || + value === "close" + ); +} + function formatDate(value: string): string { return DATE_FORMATTER.format(new Date(value)); } diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..e17613700 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -39,6 +39,11 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.name} + {agent.lifecycleRole ? ( + + {agent.lifecycleRole} + + ) : null} {agent.status.toLowerCase()} diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..a4c4d7cd9 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -311,6 +311,12 @@ delegation paths for custom agents. team. Scheduled runner sessions use task mode and therefore cannot pause for a per-action approval; the deployed permission and idempotent runtime checks are the boundary. +- **Lifecycle specialists are Deploy-gated team agents.** Optional manifest field + `lifecycleRole` is one of `qualify` | `engage` | `advance` | `close`. The Qualify + template lives in `apps/agent/agent/lib/lifecycle-qualify.ts`. The Advance template + lives in `apps/agent/agent/lib/lifecycle-advance.ts`. Both are recommend-only: CRM + note/task plus `run.summary`, no send tools, and Advance never mutates deal stage. + Saving never makes a version LIVE. - **Approved instructions are system context.** The runner resolves the pinned version instructions at `session.started`, then calls `inspect_run` for the manifest and current run state. Every runner tool also checks the `team-agent` purpose and diff --git a/packages/validation/src/agents.ts b/packages/validation/src/agents.ts index f1381b672..cd67845b4 100644 --- a/packages/validation/src/agents.ts +++ b/packages/validation/src/agents.ts @@ -91,7 +91,19 @@ export const capabilityResource = z.object({ label: z.string().trim().min(1).max(160), }); +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export const lifecycleRole = z.enum(LIFECYCLE_ROLES); + export const capabilities = z.object({ + lifecycleRole: lifecycleRole.optional(), actions: z.array(capabilityAction), dataScope: z.object({ mode: z.enum(["SELECTED", "WORKSPACE"]), @@ -103,3 +115,12 @@ export const capabilities = z.object({ export type Capabilities = z.infer; export type CapabilityAction = z.infer; export type CapabilityResource = z.infer; + +export function readLifecycleRole(manifest: unknown): LifecycleRole | null { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return null; + } + const role = (manifest as { lifecycleRole?: unknown }).lifecycleRole; + const parsed = lifecycleRole.safeParse(role); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..d30d91789 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -10,8 +10,10 @@ export type { InputOption, InputRequest, InputRequested, + LifecycleRole, Permission, } from "./agents"; +export { LIFECYCLE_ROLES, lifecycleRole, readLifecycleRole } from "./agents"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts new file mode 100644 index 000000000..c93013c28 --- /dev/null +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { capabilities, readLifecycleRole } from "../src/agents"; + +describe("lifecycleRole validation", () => { + it("reads a valid role from a raw manifest", () => { + expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "advance" })).toBe("advance"); + expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); + expect(readLifecycleRole({})).toBeNull(); + }); + + it("accepts capabilities with an optional lifecycleRole", () => { + const base = { + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE" as const, summary: "all", resources: [] }, + }; + expect(capabilities.parse(base).lifecycleRole).toBeUndefined(); + expect( + capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, + ).toBe("qualify"); + expect( + capabilities.parse({ ...base, lifecycleRole: "advance" }).lifecycleRole, + ).toBe("advance"); + expect( + capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, + ).toBe(false); + }); +}); From 1ec13f55319b1cbd4e068637424491197e344dc6 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:17:40 -0400 Subject: [PATCH 16/22] test(mailbox): lock two-way auto-create with provenance tags Phase 4 of the gmail/calendar plan: acceptance coverage for newsletter rejection, rep-sent EMAIL create, calendar CALENDAR create, free/no-reply/ suppressed domain rejection, and autoCreate off. Document phase 4 status. --- apps/api/test/mailbox-auto-create.spec.ts | 581 ++++++++++++++++++++++ docs/plan/gmail-calendar-plan.md | 48 ++ 2 files changed, 629 insertions(+) create mode 100644 apps/api/test/mailbox-auto-create.spec.ts diff --git a/apps/api/test/mailbox-auto-create.spec.ts b/apps/api/test/mailbox-auto-create.spec.ts new file mode 100644 index 000000000..3791f94f4 --- /dev/null +++ b/apps/api/test/mailbox-auto-create.spec.ts @@ -0,0 +1,581 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { + db, + type MailboxSyncModel as MailboxSync, + RecordSource, +} from "@crm/db"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { CompanyDirectoryService } from "../src/companies/company-directory.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { EnrichmentLogService } from "../src/crm/enrichment-log.service"; +import { MailboxMatchService } from "../src/mailbox/mailbox-match.service"; +import { + type IncomingMessage, + ThreadWriterService, +} from "../src/mailbox/thread-writer.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "auto-create-spec"; +const workDomain = `auto-${suffix}.test`; +const freeDomain = "gmail.com"; +const userId = `user-auto-${suffix}`; +const mailbox = `rep-auto-${suffix}@example.test`; +const person = `buyer@${workDomain}`; +const newsletter = `news@${workDomain}`; +const noreply = `noreply@${workDomain}`; +const freePerson = `someone@${freeDomain}`; +const rootNewsletter = ``; +const rootOutbound = ``; +const rootInboundReply = ``; +const rootKnown = ``; + +const agent = { + contactCreated: async () => undefined, + companyCreated: async () => undefined, + withCrmEvents: withDiscardedCrmEvents, + companyRequested: async () => undefined, +} as unknown as AgentTriggerService; + +const stamp = new ActivityStampService(db); +const directory = new CompanyDirectoryService(agent); +const log = new EnrichmentLogService(db, stamp); +const match = new MailboxMatchService(db, directory, agent, log); +const threads = new ThreadWriterService(db, match, stamp); + +let gmailOff: MailboxSync; +let gmailOn: MailboxSync; + +function outboundMessage( + rootId: string, + rfcMessageId: string, + to = person, + name: string | null = "A Buyer", +): IncomingMessage { + return { + rfcMessageId, + rootId, + subject: "Pricing", + from: { email: mailbox, name: "Test Rep" }, + recipients: [{ email: to, name, kind: "to" }], + body: "The numbers you asked for.", + sentAt: new Date("2026-03-01T10:00:00Z"), + gmailMessageId: null, + outlookMessageId: null, + outlookWebLink: null, + }; +} + +function inboundMessage( + rootId: string, + rfcMessageId: string, + from = newsletter, + name: string | null = "Weekly Digest", +): IncomingMessage { + return { + rfcMessageId, + rootId, + subject: "This week at Acme", + from: { email: from, name }, + recipients: [{ email: mailbox, name: "Test Rep", kind: "to" }], + body: "Unsubscribe below. Big sale on widgets.", + sentAt: new Date("2026-03-01T11:00:00Z"), + gmailMessageId: null, + outlookMessageId: null, + outlookWebLink: null, + }; +} + +async function matchContext() { + const [internal, suppressedDomains, suppressedEmails] = await Promise.all([ + match.internalIdentity(), + match.suppressedDomains(), + match.suppressedEmails(), + ]); + return { + ourAddresses: internal.addresses, + ourDomains: internal.domains, + suppressedDomains, + suppressedEmails, + }; +} + +async function clean() { + await db.emailThread.deleteMany({ + where: { + rootMessageId: { + in: [ + rootNewsletter, + rootOutbound, + rootInboundReply, + rootKnown, + ], + }, + }, + }); + await db.contact.deleteMany({ + where: { + email: { + in: [person, newsletter, noreply, freePerson, `second@${workDomain}`], + }, + }, + }); + await db.company.deleteMany({ where: { domain: workDomain } }); + await db.suppressedDomain.deleteMany({ where: { domain: workDomain } }); + await db.mailboxSync.deleteMany({ where: { userId } }); + await db.user.deleteMany({ where: { id: userId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { id: userId, name: "Test Rep", email: mailbox }, + }); + gmailOff = await db.mailboxSync.create({ + data: { userId, source: "gmail", autoCreate: false }, + }); + gmailOn = await db.mailboxSync.create({ + data: { + userId, + source: "outlook", + autoCreate: true, + }, + }); +}); + +afterAll(clean); + +describe("auto-create: two-way engagement (Gmail)", () => { + it("does not create a company from an inbound-only newsletter, even with autoCreate on", async () => { + const stored = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + inboundMessage( + rootNewsletter, + ``, + newsletter, + ), + await threads.context(), + ); + + expect(stored).toBe(false); + expect(await db.company.count({ where: { domain: workDomain } })).toBe(0); + expect(await db.contact.count({ where: { email: newsletter } })).toBe(0); + expect( + await db.emailThread.count({ where: { rootMessageId: rootNewsletter } }), + ).toBe(0); + }); + + it("creates company and contact with source EMAIL when the rep sends and autoCreate is on", async () => { + const stored = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + outboundMessage( + rootOutbound, + ``, + person, + "A Buyer", + ), + await threads.context(), + ); + + expect(stored).toBe(true); + + const company = await db.company.findUnique({ + where: { domain: workDomain }, + select: { id: true, source: true }, + }); + const contact = await db.contact.findUnique({ + where: { email: person }, + select: { id: true, source: true, companyId: true, firstName: true }, + }); + + expect(company?.source).toBe(RecordSource.EMAIL); + expect(contact?.source).toBe(RecordSource.EMAIL); + expect(contact?.companyId).toBe(company?.id ?? null); + expect(contact?.firstName).toBe("A"); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: rootOutbound }, + select: { + companyId: true, + contactId: true, + activity: { select: { id: true, type: true } }, + }, + }); + expect(thread?.companyId).toBe(company?.id ?? null); + expect(thread?.contactId).toBe(contact?.id ?? null); + expect(thread?.activity).not.toBeNull(); + }); + + it("does not create a company when autoCreate is off, even if the rep sent the mail", async () => { + const unknownDomain = `off-${suffix}.test`; + const unknownPerson = `lead@${unknownDomain}`; + const root = ``; + + try { + const stored = await threads.store( + gmailOff, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + unknownPerson, + "Lead Name", + ), + await threads.context(), + ); + + expect(stored).toBe(false); + expect(await db.company.count({ where: { domain: unknownDomain } })).toBe( + 0, + ); + expect(await db.contact.count({ where: { email: unknownPerson } })).toBe( + 0, + ); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: unknownPerson } }); + await db.company.deleteMany({ where: { domain: unknownDomain } }); + } + }); + + it("still attaches to an existing company when autoCreate is off", async () => { + const knownDomain = `known-${suffix}.test`; + const knownPerson = `known@${knownDomain}`; + const root = rootKnown; + + const company = await db.company.create({ + data: { + name: "Known Co", + domain: knownDomain, + source: RecordSource.MANUAL, + }, + select: { id: true }, + }); + await db.contact.create({ + data: { + firstName: "Known", + lastName: "Buyer", + email: knownPerson, + companyId: company.id, + source: RecordSource.MANUAL, + }, + }); + + try { + const stored = await threads.store( + gmailOff, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + knownPerson, + "Known Buyer", + ), + await threads.context(), + ); + + expect(stored).toBe(true); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: root }, + select: { companyId: true }, + }); + expect(thread?.companyId).toBe(company.id); + + const after = await db.company.findUnique({ + where: { id: company.id }, + select: { source: true }, + }); + expect(after?.source).toBe(RecordSource.MANUAL); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: knownPerson } }); + await db.company.deleteMany({ where: { id: company.id } }); + } + }); + + it("creates on a later inbound only after the rep has already sent in the thread", async () => { + const domain = `reply-${suffix}.test`; + const lead = `lead@${domain}`; + const root = rootInboundReply; + + try { + const first = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + outboundMessage( + root, + ``, + lead, + "Reply Lead", + ), + await threads.context(), + ); + expect(first).toBe(true); + + const company = await db.company.findUnique({ + where: { domain }, + select: { id: true, source: true }, + }); + expect(company?.source).toBe(RecordSource.EMAIL); + + const second = await threads.store( + gmailOn, + { mailbox, origin: "gmail" }, + inboundMessage( + root, + ``, + lead, + "Reply Lead", + ), + await threads.context(), + ); + expect(second).toBe(true); + + const thread = await db.emailThread.findUnique({ + where: { rootMessageId: root }, + select: { messageCount: true, companyId: true }, + }); + expect(thread?.messageCount).toBe(2); + expect(thread?.companyId).toBe(company?.id ?? null); + } finally { + await db.emailThread.deleteMany({ where: { rootMessageId: root } }); + await db.contact.deleteMany({ where: { email: lead } }); + await db.company.deleteMany({ where: { domain } }); + } + }); +}); + +describe("auto-create: match rules and provenance", () => { + it("stamps source CALENDAR when allowCreate is true for a new work domain", async () => { + const domain = `cal-${suffix}.test`; + const email = `attendee@${domain}`; + + try { + const result = await match.resolve( + { + participants: [ + { email: mailbox, name: "Test Rep" }, + { email, name: "Cal Attendee" }, + ], + allowCreate: true, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).not.toBeNull(); + expect(result.contactId).not.toBeNull(); + + const company = await db.company.findUnique({ + where: { id: result.companyId! }, + select: { domain: true, source: true }, + }); + const contact = await db.contact.findUnique({ + where: { id: result.contactId! }, + select: { email: true, source: true, firstName: true, lastName: true }, + }); + + expect(company).toEqual({ + domain, + source: RecordSource.CALENDAR, + }); + expect(contact?.email).toBe(email); + expect(contact?.source).toBe(RecordSource.CALENDAR); + expect(contact?.firstName).toBe("Cal"); + expect(contact?.lastName).toBe("Attendee"); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); + + it("creates nothing when allowCreate is false for an unknown domain", async () => { + const domain = `deny-${suffix}.test`; + const email = `ghost@${domain}`; + + const result = await match.resolve( + { + participants: [{ email, name: "Ghost" }], + allowCreate: false, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).toBeNull(); + expect(result.contactId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + }); + + it("creates nothing for a free-host address even when allowCreate is true", async () => { + const result = await match.resolve( + { + participants: [{ email: freePerson, name: "Free Mail" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(result.contactId).toBeNull(); + }); + + it("creates nothing for a no-reply address even when allowCreate is true", async () => { + const result = await match.resolve( + { + participants: [{ email: noreply, name: "No Reply" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain: workDomain } })).toBe(1); + }); + + it("creates nothing for a suppressed domain even when allowCreate is true", async () => { + const domain = `suppressed-${suffix}.test`; + const email = `person@${domain}`; + + await db.suppressedDomain.create({ + data: { domain, reason: "vendor" }, + }); + + try { + const result = await match.resolve( + { + participants: [{ email, name: "Vendor" }], + allowCreate: true, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.external).toEqual([]); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + } finally { + await db.suppressedDomain.deleteMany({ where: { domain } }); + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); + + it("adds a contact on an existing company with provenance when allowCreate is true", async () => { + const domain = `exist-${suffix}.test`; + const email = `newhire@${domain}`; + + const company = await db.company.create({ + data: { + name: "Existing Co", + domain, + source: RecordSource.MANUAL, + }, + select: { id: true }, + }); + + try { + const result = await match.resolve( + { + participants: [{ email, name: "New Hire" }], + allowCreate: true, + source: RecordSource.EMAIL, + ownerId: userId, + }, + await matchContext(), + ); + + expect(result.companyId).toBe(company.id); + expect(result.contactId).not.toBeNull(); + + const contact = await db.contact.findUnique({ + where: { id: result.contactId! }, + select: { source: true, companyId: true }, + }); + expect(contact).toEqual({ + source: RecordSource.EMAIL, + companyId: company.id, + }); + + const after = await db.company.findUnique({ + where: { id: company.id }, + select: { source: true }, + }); + expect(after?.source).toBe(RecordSource.MANUAL); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { id: company.id } }); + } + }); +}); + +describe("auto-create: calendar engagement gate", () => { + it("treats declined-by-us as no create (allowCreate false)", async () => { + const domain = `declined-${suffix}.test`; + const email = `guest@${domain}`; + const declinedByUs = true; + const autoCreate = true; + const allowCreate = autoCreate && !declinedByUs; + + const result = await match.resolve( + { + participants: [{ email, name: "Guest" }], + allowCreate, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(allowCreate).toBe(false); + expect(result.companyId).toBeNull(); + expect(await db.company.count({ where: { domain } })).toBe(0); + }); + + it("allows create when the rep has not declined", async () => { + const domain = `accepted-${suffix}.test`; + const email = `guest@${domain}`; + const declinedByUs = false; + const autoCreate = true; + const allowCreate = autoCreate && !declinedByUs; + + try { + const result = await match.resolve( + { + participants: [{ email, name: "Guest Person" }], + allowCreate, + source: RecordSource.CALENDAR, + ownerId: userId, + }, + await matchContext(), + ); + + expect(allowCreate).toBe(true); + expect(result.companyId).not.toBeNull(); + + const company = await db.company.findUnique({ + where: { id: result.companyId! }, + select: { source: true, domain: true }, + }); + expect(company).toEqual({ + domain, + source: RecordSource.CALENDAR, + }); + } finally { + await db.contact.deleteMany({ where: { email } }); + await db.company.deleteMany({ where: { domain } }); + } + }); +}); diff --git a/docs/plan/gmail-calendar-plan.md b/docs/plan/gmail-calendar-plan.md index ae6fe4dda..f348dcbc6 100644 --- a/docs/plan/gmail-calendar-plan.md +++ b/docs/plan/gmail-calendar-plan.md @@ -819,3 +819,51 @@ with `class-validator` decorators, and to `docs/environment.md`. an account that will not grant the scopes cannot use the CRM anyway. Revisit only if someone needs "keep my login, stop reading my mail", which the policy currently says is not a state we support. + +--- + +## 16. Status and handoff (Auto-create — phase 4) + +### Shipped (phase 4 rules on the existing substrate) + +Phase **4** reuses the Gmail and Calendar sync path. Nest matches and creates +rows only. Intelligence still lives in `apps/agent` via `company.created` / +`contact.created` tasks. No generic agent-copy and no auto-send. + +| Concern | Where | +| --- | --- | +| Two-way Gmail gate | `ThreadWriterService.store` — `allowCreate: row.autoCreate && repliedTo` | +| Calendar gate | `CalendarSyncService.apply` — `allowCreate: row.autoCreate && !declinedByUs` | +| Create + provenance | `MailboxMatchService` stamps `RecordSource.EMAIL` / `CALENDAR` on company and contact | +| Defaults | `GoogleConnectionService.onConnected` — calendar `autoCreate: true`, gmail `false` | +| Toggles | `google.setAutoCreate` / settings connection card | +| Undo | companies/contacts `source` filter + `bulkDelete`; `google.suppressDomain` (+ optional purge) | +| Noise filters | free hosts, no-reply local parts, `SuppressedDomain`, own Workspace domain | +| Acceptance tests | `apps/api/test/mailbox-auto-create.spec.ts` | + +Rules locked by tests: + +- Inbound-only (newsletter) never creates, even when Gmail auto-create is on. +- Rep-sent mail with auto-create on creates company + contact with `source = EMAIL`. +- Auto-create off: unknown domains create nothing; known companies still attach. +- Calendar allowCreate stamps `source = CALENDAR`; declined-by-us does not create. +- Free hosts, no-reply, and suppressed domains create nothing. + +### Ops note (not a code gate) + +Plan §12 still prefers a week of real-mailbox soak before flipping Gmail +auto-create on by default. The product default stays **calendar on, gmail off**. + +### Out of scope here + +- Phase 5 Pub/Sub real-time (`users.watch`). +- Sending mail from the CRM. +- Lifecycle specialist agents (qualify/engage) — separate Deploy-gated work. +- Changing the Gmail default to on. + +### Done when (this lane) + +A meeting with an unknown work domain creates a company and contact tagged +`CALENDAR`. A newsletter creates nothing. Gmail creates only on two-way +engagement when the toggle is on. Provenance filters and suppress-domain undo +remain available. The suite in `mailbox-auto-create.spec.ts` is green. From 9dc2637b66063796a0a0a7fbca8a133458dbe219 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:21:24 -0400 Subject: [PATCH 17/22] feat(agent): team-agent observability for runs, quality, and cost Surface fleet health on agents.observability and the team index, extend install_daily with team_runs/actions/token/cost aggregates, and show per-run cost, tokens, and trace steps. lifecycleRole is parsed for role-aware counts; free text stays off the wire. --- .../src/agent/agent-definitions.service.ts | 51 +++-- .../src/agent/agent-observability.service.ts | 189 ++++++++++++++++++ apps/api/src/agent/agent.module.ts | 2 + apps/api/src/agent/agents.router.ts | 8 + apps/api/src/generated/server.ts | 2 + apps/api/src/telemetry/rollup.service.ts | 133 +++++++++++- apps/api/test/agent-observability.spec.ts | 183 +++++++++++++++++ .../agent-builder/agent-history.tsx | 26 ++- .../agent-builder/team-agents-index.tsx | 92 +++++++++ docs/telemetry.md | 25 +++ packages/telemetry/src/allowlist.ts | 90 +++++++++ packages/telemetry/src/index.ts | 10 + packages/telemetry/test/allowlist.spec.ts | 38 ++++ packages/validation/src/agents.ts | 21 ++ packages/validation/src/index.ts | 2 + .../validation/test/lifecycle-role.spec.ts | 24 +++ 16 files changed, 876 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/agent/agent-observability.service.ts create mode 100644 apps/api/test/agent-observability.spec.ts create mode 100644 packages/validation/test/lifecycle-role.spec.ts diff --git a/apps/api/src/agent/agent-definitions.service.ts b/apps/api/src/agent/agent-definitions.service.ts index 9d6521fd8..afdba82a3 100644 --- a/apps/api/src/agent/agent-definitions.service.ts +++ b/apps/api/src/agent/agent-definitions.service.ts @@ -1,6 +1,6 @@ import type { Db, Prisma } from "@crm/db"; import type { AgentDefinitionStatus } from "@crm/db/enums"; -import { schemas } from "@crm/validation"; +import { readLifecycleRole, schemas } from "@crm/validation"; import { BadRequestException, Injectable, @@ -43,7 +43,13 @@ export class AgentDefinitionsService { updatedAt: true, createdBy: { select: { id: true, name: true, image: true } }, currentVersion: { - select: { id: true, number: true, deployedAt: true }, + select: { id: true, number: true, deployedAt: true, manifest: true }, + }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, }, triggers: { where: { enabled: true }, @@ -55,22 +61,29 @@ export class AgentDefinitionsService { }, }); - return rows.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - currentVersion: row.currentVersion - ? { - ...row.currentVersion, - deployedAt: row.currentVersion.deployedAt?.toISOString() ?? null, - } - : null, - triggers: row.triggers.map((trigger) => ({ - ...trigger, - nextRunAt: trigger.nextRunAt?.toISOString() ?? null, - })), - runCount: row._count.runs, - })); + return rows.map((row) => { + const { versions, currentVersion, ...agent } = row; + const manifest = currentVersion?.manifest ?? versions[0]?.manifest; + const { manifest: _manifest, ...currentVersionPublic } = + currentVersion ?? {}; + return { + ...agent, + lifecycleRole: readLifecycleRole(manifest), + createdAt: agent.createdAt.toISOString(), + updatedAt: agent.updatedAt.toISOString(), + currentVersion: currentVersion + ? { + ...currentVersionPublic, + deployedAt: currentVersion.deployedAt?.toISOString() ?? null, + } + : null, + triggers: agent.triggers.map((trigger) => ({ + ...trigger, + nextRunAt: trigger.nextRunAt?.toISOString() ?? null, + })), + runCount: agent._count.runs, + }; + }); } async byId(id: string, userId: string) { @@ -825,6 +838,7 @@ function readCapabilities(manifest: unknown) { (issue) => `${issue.path.join(".") || "manifest"} ${issue.message}`, ) .join("; "), + lifecycleRole: readLifecycleRole(manifest), actions: [], dataScope: null, channel: null, @@ -838,6 +852,7 @@ function readCapabilities(manifest: unknown) { return { readable: true as const, problem: null, + lifecycleRole: parsed.data.lifecycleRole ?? null, actions: parsed.data.actions, dataScope: parsed.data.dataScope, channel: slack?.destination ?? null, diff --git a/apps/api/src/agent/agent-observability.service.ts b/apps/api/src/agent/agent-observability.service.ts new file mode 100644 index 000000000..ea3137789 --- /dev/null +++ b/apps/api/src/agent/agent-observability.service.ts @@ -0,0 +1,189 @@ +import type { Db } from "@crm/db"; +import { readLifecycleRole } from "@crm/validation"; +import { Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { AgentAccessService } from "./agent-access.service"; +import { TEAM_AGENT_STATUSES } from "./agent-visibility"; + +const WINDOW_HOURS = 24; +const HOUR_MS = 60 * 60 * 1000; +const DEPENDENCY_UNAVAILABLE = "DEPENDENCY_UNAVAILABLE"; + +@Injectable() +export class AgentObservabilityService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly access: AgentAccessService, + ) {} + + async fleet(userId: string) { + await this.access.assertMember(userId); + + const since = new Date(Date.now() - WINDOW_HOURS * HOUR_MS); + + const [ + statusRows, + triggerRows, + openRuns, + runs, + actionTypeRows, + actionStatusRows, + agents, + ] = await Promise.all([ + this.db.agentRun.groupBy({ + by: ["status"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["triggerType"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["status"], + where: { + status: { in: ["QUEUED", "RUNNING", "WAITING_FOR_APPROVAL"] }, + }, + _count: { _all: true }, + }), + this.db.agentRun.findMany({ + where: { createdAt: { gte: since } }, + select: { + status: true, + cancelRequestedAt: true, + errorCode: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + sessionId: true, + version: { select: { manifest: true } }, + _count: { select: { actions: true, events: true } }, + }, + }), + this.db.agentAction.groupBy({ + by: ["type"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentAction.groupBy({ + by: ["status"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentDefinition.findMany({ + where: { status: { in: [...TEAM_AGENT_STATUSES] } }, + select: { + id: true, + name: true, + status: true, + currentVersion: { select: { manifest: true } }, + versions: { + where: { status: { in: ["DRAFT", "READY"] } }, + orderBy: { number: "desc" }, + take: 1, + select: { manifest: true }, + }, + _count: { select: { runs: true } }, + }, + }), + ]); + + const runsByRole: Record = {}; + let cancelled = 0; + let cancelAfterAction = 0; + let dependencyFailures = 0; + let inputTokens = 0; + let outputTokens = 0; + let costUsd = 0; + let sessionsWithTrace = 0; + let toolEvents = 0; + + for (const run of runs) { + const role = readLifecycleRole(run.version.manifest); + const roleKey = role ?? "none"; + runsByRole[roleKey] = (runsByRole[roleKey] ?? 0) + 1; + + if (run.status === "CANCELLED" || run.cancelRequestedAt) { + cancelled += 1; + if (run._count.actions > 0) cancelAfterAction += 1; + } + + if (run.errorCode === DEPENDENCY_UNAVAILABLE) { + dependencyFailures += 1; + } + + inputTokens += run.inputTokens ?? 0; + outputTokens += run.outputTokens ?? 0; + costUsd += Number(run.costUsd ?? 0); + if (run.sessionId) sessionsWithTrace += 1; + toolEvents += run._count.events; + } + + const agentsByRole: Record = {}; + for (const agent of agents) { + const manifest = + agent.currentVersion?.manifest ?? agent.versions[0]?.manifest; + const role = readLifecycleRole(manifest) ?? "none"; + agentsByRole[role] = (agentsByRole[role] ?? 0) + 1; + } + + return { + windowHours: WINDOW_HOURS, + since: since.toISOString(), + runsByStatus: counts(statusRows.map((row) => [row.status, row._count._all])), + runsByTrigger: counts( + triggerRows.map((row) => [row.triggerType, row._count._all]), + ), + runsByLifecycleRole: runsByRole, + openRunsByStatus: counts( + openRuns.map((row) => [row.status, row._count._all]), + ), + actionsByType: counts( + actionTypeRows.map((row) => [row.type, row._count._all]), + ), + actionsByStatus: counts( + actionStatusRows.map((row) => [row.status, row._count._all]), + ), + quality: { + cancelled, + cancelAfterAction, + dependencyFailures, + failed: runsByRoleCount(statusRows, "FAILED"), + succeeded: runsByRoleCount(statusRows, "SUCCEEDED"), + }, + consumption: { + inputTokens, + outputTokens, + costUsd: round(costUsd), + runs: runs.length, + sessionsWithTrace, + runEvents: toolEvents, + }, + agents: { + total: agents.length, + byStatus: counts(agents.map((agent) => [agent.status, 1])), + byLifecycleRole: agentsByRole, + }, + }; + } +} + +function counts(entries: Array<[string, number]>): Record { + const result: Record = {}; + for (const [key, count] of entries) { + result[key] = (result[key] ?? 0) + count; + } + return result; +} + +function runsByRoleCount( + rows: Array<{ status: string; _count: { _all: number } }>, + status: string, +): number { + return rows.find((row) => row.status === status)?._count._all ?? 0; +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/apps/api/src/agent/agent.module.ts b/apps/api/src/agent/agent.module.ts index cc700da83..de2a254a4 100644 --- a/apps/api/src/agent/agent.module.ts +++ b/apps/api/src/agent/agent.module.ts @@ -2,6 +2,7 @@ import { Module } from "@nestjs/common"; import { TrpcModule } from "../trpc/trpc.module"; import { AgentAccessService } from "./agent-access.service"; import { AgentDefinitionsService } from "./agent-definitions.service"; +import { AgentObservabilityService } from "./agent-observability.service"; import { AgentQueueService } from "./agent-queue.service"; import { AgentRunsService } from "./agent-runs.service"; import { AgentTriggerService } from "./agent-trigger.service"; @@ -14,6 +15,7 @@ import { ResearchKeyService } from "./research-key.service"; providers: [ AgentAccessService, AgentDefinitionsService, + AgentObservabilityService, AgentQueueService, AgentRunsService, AgentTriggerService, diff --git a/apps/api/src/agent/agents.router.ts b/apps/api/src/agent/agents.router.ts index 6e4f37859..0f4040a75 100644 --- a/apps/api/src/agent/agents.router.ts +++ b/apps/api/src/agent/agents.router.ts @@ -11,6 +11,7 @@ import type { z } from "zod"; import type { AuthedTrpcContext } from "../trpc/context.types"; import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; import { AgentDefinitionsService } from "./agent-definitions.service"; +import { AgentObservabilityService } from "./agent-observability.service"; import { AgentRunsService } from "./agent-runs.service"; import { agentCancelRunInput, @@ -32,6 +33,8 @@ export class AgentsRouter { private readonly agents: AgentDefinitionsService, @Inject(AgentRunsService) private readonly runs: AgentRunsService, + @Inject(AgentObservabilityService) + private readonly fleetHealth: AgentObservabilityService, ) {} @Query() @@ -39,6 +42,11 @@ export class AgentsRouter { return this.agents.list(ctx.user.id); } + @Query() + async observability(@Ctx() ctx: AuthedTrpcContext) { + return this.fleetHealth.fleet(ctx.user.id); + } + @Mutation({ input: agentReviseInput }) async revise( @Ctx() ctx: AuthedTrpcContext, diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b42b0326b..ffa0b8ff9 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -69,6 +69,8 @@ const appRouter = t.router({ agents: t.router({ list: publicProcedure .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), + observability: publicProcedure + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), revise: publicProcedure .input(agentReviseInput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as unknown as Awaited>), diff --git a/apps/api/src/telemetry/rollup.service.ts b/apps/api/src/telemetry/rollup.service.ts index 928cd990c..c19392bc2 100644 --- a/apps/api/src/telemetry/rollup.service.ts +++ b/apps/api/src/telemetry/rollup.service.ts @@ -18,14 +18,20 @@ import { drainCounters, installDaily, type Properties, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedTaskKind, + permittedTeamActionType, permittedTool, releaseRollup, restoreCounters, telemetryDisabled, } from "@crm/telemetry"; +import { readLifecycleRole } from "@crm/validation"; import { Injectable, Logger } from "@nestjs/common"; import { InjectDatabase } from "../database/database.constants"; import { FunnelService } from "./funnel.service"; @@ -186,7 +192,7 @@ export class RollupService { since: Date, counters: Record, ): Promise { - const [tools, sessions, tasks, attempts, rechecks, conversations] = + const [tools, sessions, tasks, attempts, rechecks, conversations, team] = await Promise.all([ this.toolCalls(since), this.sessions(since), @@ -194,6 +200,7 @@ export class RollupService { this.attempts(since), this.rechecks(since), this.db.agentConversation.count(), + this.teamAgents(since), ]); const total = Object.values(tools.calls).reduce((sum, n) => sum + n, 0); @@ -223,6 +230,130 @@ export class RollupService { recheck_interval_days: rechecks.buckets, agent_conversations: conversations, + + team_runs_by_status: team.runsByStatus, + team_runs_by_trigger: team.runsByTrigger, + team_runs_by_lifecycle_role: team.runsByRole, + team_actions_by_type: team.actionsByType, + team_actions_by_status: team.actionsByStatus, + team_runs_cancelled: team.cancelled, + team_runs_cancel_after_action: team.cancelAfterAction, + team_dependency_failures: team.dependencyFailures, + team_input_tokens: team.inputTokens, + team_output_tokens: team.outputTokens, + team_cost_usd: team.costUsd, + }; + } + + private async teamAgents(since: Date): Promise<{ + runsByStatus: Record; + runsByTrigger: Record; + runsByRole: Record; + actionsByType: Record; + actionsByStatus: Record; + cancelled: number; + cancelAfterAction: number; + dependencyFailures: number; + inputTokens: number; + outputTokens: number; + costUsd: number; + }> { + const [statusRows, triggerRows, runs, actionTypeRows, actionStatusRows] = + await Promise.all([ + this.db.agentRun.groupBy({ + by: ["status"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.groupBy({ + by: ["triggerType"], + where: { createdAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentRun.findMany({ + where: { createdAt: { gte: since } }, + select: { + status: true, + cancelRequestedAt: true, + errorCode: true, + inputTokens: true, + outputTokens: true, + costUsd: true, + version: { select: { manifest: true } }, + _count: { select: { actions: true } }, + }, + }), + this.db.agentAction.groupBy({ + by: ["type"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + this.db.agentAction.groupBy({ + by: ["status"], + where: { plannedAt: { gte: since } }, + _count: { _all: true }, + }), + ]); + + const runsByRole: Record = {}; + let cancelled = 0; + let cancelAfterAction = 0; + let dependencyFailures = 0; + let inputTokens = 0; + let outputTokens = 0; + let costUsd = 0; + + for (const run of runs) { + const role = readLifecycleRole(run.version.manifest); + const roleKey = role ? permittedLifecycleRole(role) : "none"; + runsByRole[roleKey] = (runsByRole[roleKey] ?? 0) + 1; + + if (run.status === "CANCELLED" || run.cancelRequestedAt) { + cancelled += 1; + if (run._count.actions > 0) cancelAfterAction += 1; + } + + if (run.errorCode === "DEPENDENCY_UNAVAILABLE") { + dependencyFailures += 1; + } + + inputTokens += run.inputTokens ?? 0; + outputTokens += run.outputTokens ?? 0; + costUsd += Number(run.costUsd ?? 0); + } + + return { + runsByStatus: merge( + statusRows.map((row) => ({ + key: permittedAgentRunStatus(row.status), + count: row._count._all, + })), + ), + runsByTrigger: merge( + triggerRows.map((row) => ({ + key: permittedAgentTriggerType(row.triggerType), + count: row._count._all, + })), + ), + runsByRole, + actionsByType: merge( + actionTypeRows.map((row) => ({ + key: permittedTeamActionType(row.type), + count: row._count._all, + })), + ), + actionsByStatus: merge( + actionStatusRows.map((row) => ({ + key: permittedAgentActionStatus(row.status), + count: row._count._all, + })), + ), + cancelled, + cancelAfterAction, + dependencyFailures, + inputTokens, + outputTokens, + costUsd: round(costUsd), }; } diff --git a/apps/api/test/agent-observability.spec.ts b/apps/api/test/agent-observability.spec.ts new file mode 100644 index 000000000..be5cbc61c --- /dev/null +++ b/apps/api/test/agent-observability.spec.ts @@ -0,0 +1,183 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DEFAULT_WORKSPACE_NAME, WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { workspaceSlug } from "@crm/db/workspace"; +import { AgentAccessService } from "../src/agent/agent-access.service"; +import { AgentObservabilityService } from "../src/agent/agent-observability.service"; + +const suffix = crypto.randomUUID(); +const userId = `agent-obs-user-${suffix}`; +const memberId = `agent-obs-member-${suffix}`; +let agentId = ""; +let versionId = ""; +const service = new AgentObservabilityService(db, new AgentAccessService(db)); + +beforeAll(async () => { + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + update: {}, + create: { + id: WORKSPACE_ID, + name: DEFAULT_WORKSPACE_NAME, + slug: workspaceSlug(DEFAULT_WORKSPACE_NAME), + createdAt: new Date(), + }, + }); + await db.user.create({ + data: { + id: userId, + name: "Agent Obs Test", + email: `${userId}@example.test`, + }, + }); + await db.member.create({ + data: { + id: memberId, + organizationId: WORKSPACE_ID, + userId, + role: "member", + createdAt: new Date(), + }, + }); + const agent = await db.agentDefinition.create({ + data: { name: "Obs fleet", status: "LIVE", createdById: userId }, + select: { id: true }, + }); + agentId = agent.id; + const version = await db.agentVersion.create({ + data: { + agentId, + number: 1, + status: "DEPLOYED", + instructions: "Report health only.", + manifest: { + lifecycleRole: "qualify", + actions: [ + { type: "run.summary", provider: "crm", summary: "done" }, + ], + dataScope: { mode: "WORKSPACE", summary: "workspace", resources: [] }, + }, + modelId: "test/model", + sandboxPolicy: {}, + createdById: userId, + }, + select: { id: true }, + }); + versionId = version.id; + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: versionId }, + }); + + const succeeded = await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "MANUAL", + status: "SUCCEEDED", + idempotencyKey: `obs-ok-${suffix}`, + correlationId: `obs-ok-corr-${suffix}`, + inputTokens: 10, + outputTokens: 5, + costUsd: "0.01", + sessionId: `session-ok-${suffix}`, + startedAt: new Date(), + finishedAt: new Date(), + }, + select: { id: true }, + }); + await db.agentAction.create({ + data: { + agentId, + runId: succeeded.id, + type: "crm.activity.create", + provider: "crm", + summary: "NOTE only", + status: "SUCCEEDED", + idempotencyKey: `obs-action-${suffix}`, + }, + }); + await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "EVENT", + status: "FAILED", + idempotencyKey: `obs-dep-${suffix}`, + correlationId: `obs-dep-corr-${suffix}`, + errorCode: "DEPENDENCY_UNAVAILABLE", + errorMessage: "Slack missing", + finishedAt: new Date(), + }, + }); + const cancelled = await db.agentRun.create({ + data: { + agentId, + versionId, + triggerType: "SCHEDULE", + status: "CANCELLED", + idempotencyKey: `obs-cancel-${suffix}`, + correlationId: `obs-cancel-corr-${suffix}`, + cancelRequestedAt: new Date(), + finishedAt: new Date(), + }, + select: { id: true }, + }); + await db.agentAction.create({ + data: { + agentId, + runId: cancelled.id, + type: "run.summary", + provider: "crm", + summary: "partial", + status: "SUCCEEDED", + idempotencyKey: `obs-cancel-action-${suffix}`, + }, + }); +}); + +afterAll(async () => { + if (agentId) { + await db.agentAction.deleteMany({ where: { agentId } }); + await db.agentRunEvent.deleteMany({ + where: { run: { agentId } }, + }); + await db.agentRun.deleteMany({ where: { agentId } }); + await db.agentDefinition.update({ + where: { id: agentId }, + data: { currentVersionId: null }, + }); + await db.agentVersion.deleteMany({ where: { agentId } }); + await db.agentDefinition.deleteMany({ where: { id: agentId } }); + } + await db.member.deleteMany({ where: { id: memberId } }); + await db.user.deleteMany({ where: { id: userId } }); +}); + +describe("agent observability fleet", () => { + it("aggregates status, role, quality, and consumption without free text", async () => { + const fleet = await service.fleet(userId); + + expect(fleet.windowHours).toBe(24); + expect(fleet.runsByStatus.SUCCEEDED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByStatus.FAILED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByStatus.CANCELLED).toBeGreaterThanOrEqual(1); + expect(fleet.runsByTrigger.MANUAL).toBeGreaterThanOrEqual(1); + expect(fleet.runsByLifecycleRole.qualify).toBeGreaterThanOrEqual(3); + expect(fleet.actionsByType["crm.activity.create"]).toBeGreaterThanOrEqual( + 1, + ); + expect(fleet.quality.dependencyFailures).toBeGreaterThanOrEqual(1); + expect(fleet.quality.cancelAfterAction).toBeGreaterThanOrEqual(1); + expect(fleet.consumption.inputTokens).toBeGreaterThanOrEqual(10); + expect(fleet.consumption.outputTokens).toBeGreaterThanOrEqual(5); + expect(fleet.consumption.costUsd).toBeGreaterThanOrEqual(0.01); + expect(fleet.consumption.sessionsWithTrace).toBeGreaterThanOrEqual(1); + expect(fleet.agents.byLifecycleRole.qualify).toBeGreaterThanOrEqual(1); + + const encoded = JSON.stringify(fleet); + expect(encoded).not.toContain("NOTE only"); + expect(encoded).not.toContain("Slack missing"); + expect(encoded).not.toContain("Report health only"); + }); +}); diff --git a/apps/app/components/agent-builder/agent-history.tsx b/apps/app/components/agent-builder/agent-history.tsx index 089032f69..734007dee 100644 --- a/apps/app/components/agent-builder/agent-history.tsx +++ b/apps/app/components/agent-builder/agent-history.tsx @@ -247,7 +247,19 @@ function ExpandedRun({ run }: { run: RunRow }) { value={run.initiatedBy?.name ?? "Eve scheduler"} /> - + + + + + 0 + ? `${run.events.length}/${run.totalEvents}` + : "—" + } + last + />
@@ -434,6 +446,18 @@ function duration(startedAt: string | null, finishedAt: string | null): string { return `${Math.max(0, milliseconds / 1000).toFixed(1)}s`; } +function formatCount(value: number | null | undefined): string { + if (value === null || value === undefined) return "—"; + return value.toLocaleString("en-US"); +} + +function formatCost(value: string | null | undefined): string { + if (value === null || value === undefined || value === "") return "—"; + const amount = Number(value); + if (!Number.isFinite(amount)) return "—"; + return `$${amount.toFixed(4)}`; +} + function eventLabel(type: string, data: unknown): string { const payload = recordOf(data); return textOf(payload.summary, humanStatus(type.replace(/\./g, " "))); diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index 4d4b5bf55..a34abe3b4 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -10,6 +10,7 @@ import type { RouterOutputs } from "@/lib/trpc/types"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type Agents = RouterOutputs["agents"]["list"]; +type Fleet = RouterOutputs["agents"]["observability"]; export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { const trpc = useTRPC(); @@ -18,10 +19,12 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { ...trpc.agents.list.queryOptions(), initialData: initialAgents, }); + const fleet = useQuery(trpc.agents.observability.queryOptions()); const rows = agents.data ?? initialAgents; return ( <> + {fleet.data ? : null} {rows.length ? (
{rows.map((agent) => ( @@ -39,6 +42,11 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { {agent.name} + {agent.lifecycleRole ? ( + + {agent.lifecycleRole} + + ) : null} {agent.status.toLowerCase()} @@ -79,3 +87,87 @@ export function TeamAgentsIndex({ initialAgents }: { initialAgents: Agents }) { ); } + +function FleetHealth({ fleet }: { fleet: Fleet }) { + const open = Object.values(fleet.openRunsByStatus).reduce( + (sum: number, count: number) => sum + count, + 0, + ); + + return ( +
+
+

Fleet health (24h)

+

+ Run status, action quality, and token cost for team agents. Aggregate + counts only. +

+
+
+ + + 0} + /> + 0} + /> + 0 + ? ` (${fleet.quality.cancelAfterAction} after action)` + : "" + }`} + /> + + + +
+ {Object.keys(fleet.runsByLifecycleRole).length > 0 ? ( +
+ Roles ·{" "} + {Object.entries(fleet.runsByLifecycleRole) + .map(([role, count]) => `${role} ${count}`) + .join(" · ")} +
+ ) : null} +
+ ); +} + +function Stat({ + label, + value, + warn = false, +}: { + label: string; + value: string; + warn?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/docs/telemetry.md b/docs/telemetry.md index b89a9cdff..416652d7a 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -121,6 +121,29 @@ has been minted — never the id itself. No key, value or last-four is sent. | `recheck_interval_days` | Their intervals, in day bands | | `agent_conversations` | How many `AgentConversation` rows exist | +#### Team agents (Deploy-gated fleet) + +Aggregate counts from `AgentRun` and `AgentAction` in the same 24h window. Never a summary, +prompt, action body, Slack text, or email draft. + +| Property | What it is | +| --- | --- | +| `team_runs_by_status` | Counts by `AgentRunStatus` | +| `team_runs_by_trigger` | Counts by `AgentTriggerType` (`MANUAL` / `SCHEDULE` / `EVENT` / `WEBHOOK`) | +| `team_runs_by_lifecycle_role` | Counts by manifest `lifecycleRole` (`qualify` / `engage` / `advance` / `close`), or `none` when untagged. Unknown values are `other` | +| `team_actions_by_type` | Counts by action type allowlist (`crm.activity.create`, `run.summary`, `slack.message.post`, `crm.outreach.recommend`). Anything else is `other` | +| `team_actions_by_status` | Counts by `AgentActionStatus` | +| `team_runs_cancelled` | Runs cancelled or with `cancelRequestedAt` set | +| `team_runs_cancel_after_action` | Of those, runs that already completed at least one ledgered action (side effects stay) | +| `team_dependency_failures` | Runs whose `errorCode` is `DEPENDENCY_UNAVAILABLE` | +| `team_input_tokens` | Sum of `AgentRun.inputTokens` | +| `team_output_tokens` | Sum of `AgentRun.outputTokens` | +| `team_cost_usd` | Sum of `AgentRun.costUsd` (two decimal places) | + +In-product fleet health for the same aggregates lives on `agents.observability` (tRPC) and the +team agents index. Per-run cost, tokens, and session event counts render in the run history +drawer. Neither surface sends free text about a named person. + Tool names are matched against the authored tools in `apps/agent/agent/tools/` and eve's own builtins. Anything else is counted as `other`. Task kinds are matched against `TASK_KINDS`. @@ -291,6 +314,8 @@ default and the page has no field to type in. - `EmailThread` and `EmailMessage` subjects or bodies, `CalendarEvent` titles, `CalendarAttendee` rows - `Deal` names and amounts. Stage distribution is fine; amounts are not. - `AgentEvent.data`, `AgentConversation` content, prompts, completions, reasoning traces +- `AgentRun.summary`, `AgentRun.input`, `AgentRun.result`, `AgentAction.summary`, action + metadata, or outreach draft bodies — team-agent telemetry is counts, tokens and cost only - `ALLOWED_SIGN_IN`, `AppSetting.contextDevApiKey`, any key, secret, token or connection string - `SuppressedDomain` and `SuppressedContact` values — counts only - **IP address.** Set `$ip: null` and disable geoip. n8n collects IP and has to caveat their diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 1c6d7492b..19d4b4c12 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -44,6 +44,18 @@ export const ALLOWED_PROPERTIES = [ "sandbox_used", "agent_conversations", + "team_runs_by_status", + "team_runs_by_trigger", + "team_runs_by_lifecycle_role", + "team_actions_by_type", + "team_actions_by_status", + "team_runs_cancelled", + "team_runs_cancel_after_action", + "team_dependency_failures", + "team_input_tokens", + "team_output_tokens", + "team_cost_usd", + "facts_by_status", "facts_by_band", "facts_by_method", @@ -227,6 +239,84 @@ export function permittedTaskKind(kind: string | null | undefined): string { return kind && TASK_KIND_SET.has(kind) ? kind : OTHER; } +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +const LIFECYCLE_ROLE_SET = new Set(LIFECYCLE_ROLES); + +export function permittedLifecycleRole( + role: string | null | undefined, +): string { + return role && LIFECYCLE_ROLE_SET.has(role) ? role : OTHER; +} + +export const AGENT_RUN_STATUSES = [ + "QUEUED", + "RUNNING", + "WAITING_FOR_APPROVAL", + "SUCCEEDED", + "FAILED", + "CANCELLED", +] as const; + +const AGENT_RUN_STATUS_SET = new Set(AGENT_RUN_STATUSES); + +export function permittedAgentRunStatus( + status: string | null | undefined, +): string { + return status && AGENT_RUN_STATUS_SET.has(status) ? status : OTHER; +} + +export const AGENT_TRIGGER_TYPES = [ + "MANUAL", + "SCHEDULE", + "EVENT", + "WEBHOOK", +] as const; + +const AGENT_TRIGGER_TYPE_SET = new Set(AGENT_TRIGGER_TYPES); + +export function permittedAgentTriggerType( + type: string | null | undefined, +): string { + return type && AGENT_TRIGGER_TYPE_SET.has(type) ? type : OTHER; +} + +export const TEAM_ACTION_TYPES = [ + "crm.activity.create", + "run.summary", + "slack.message.post", + "crm.outreach.recommend", +] as const; + +const TEAM_ACTION_TYPE_SET = new Set(TEAM_ACTION_TYPES); + +export function permittedTeamActionType( + type: string | null | undefined, +): string { + return type && TEAM_ACTION_TYPE_SET.has(type) ? type : OTHER; +} + +export const AGENT_ACTION_STATUSES = [ + "PLANNED", + "RUNNING", + "SUCCEEDED", + "FAILED", + "CANCELLED", +] as const; + +const AGENT_ACTION_STATUS_SET = new Set(AGENT_ACTION_STATUSES); + +export function permittedAgentActionStatus( + status: string | null | undefined, +): string { + return status && AGENT_ACTION_STATUS_SET.has(status) ? status : OTHER; +} + export const SYNC_SOURCES = ["gmail", "calendar", "outlook"] as const; export type TelemetrySyncSource = (typeof SYNC_SOURCES)[number]; diff --git a/packages/telemetry/src/index.ts b/packages/telemetry/src/index.ts index 96be37136..0e5901143 100644 --- a/packages/telemetry/src/index.ts +++ b/packages/telemetry/src/index.ts @@ -1,24 +1,34 @@ export { + AGENT_ACTION_STATUSES, + AGENT_RUN_STATUSES, AGENT_TOOLS, + AGENT_TRIGGER_TYPES, ALLOWED_PROPERTIES, type AllowedProperty, bucket, dayBucket, EVE_TOOLS, EVIDENCE_KINDS, + LIFECYCLE_ROLES, OTHER, type Properties, type PropertyValue, permitted, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedErrorClass, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedModelId, permittedRoute, permittedSyncSource, permittedTaskKind, + permittedTeamActionType, permittedTool, SYNC_SOURCES, + TEAM_ACTION_TYPES, } from "./allowlist"; export { capture, diff --git a/packages/telemetry/test/allowlist.spec.ts b/packages/telemetry/test/allowlist.spec.ts index e5494eac4..b7ffaef9c 100644 --- a/packages/telemetry/test/allowlist.spec.ts +++ b/packages/telemetry/test/allowlist.spec.ts @@ -10,12 +10,17 @@ import { EVIDENCE_KINDS, OTHER, permitted, + permittedAgentActionStatus, + permittedAgentRunStatus, + permittedAgentTriggerType, permittedErrorClass, permittedEvidenceKind, + permittedLifecycleRole, permittedMethod, permittedModelId, permittedRoute, permittedTaskKind, + permittedTeamActionType, permittedTool, } from "../src/allowlist"; @@ -57,6 +62,39 @@ describe("permitted", () => { it("names every property exactly once", () => { expect(new Set(ALLOWED_PROPERTIES).size).toBe(ALLOWED_PROPERTIES.length); }); + + it("keeps team-agent fleet properties", () => { + expect( + permitted({ + team_runs_by_status: { SUCCEEDED: 2 }, + team_cost_usd: 0.12, + contact_email: "hidden@example.test", + }), + ).toEqual({ + team_runs_by_status: { SUCCEEDED: 2 }, + team_cost_usd: 0.12, + }); + }); +}); + +describe("team agent permits", () => { + it("keeps known run statuses, trigger types, and roles", () => { + expect(permittedAgentRunStatus("SUCCEEDED")).toBe("SUCCEEDED"); + expect(permittedAgentTriggerType("EVENT")).toBe("EVENT"); + expect(permittedLifecycleRole("qualify")).toBe("qualify"); + expect(permittedTeamActionType("crm.activity.create")).toBe( + "crm.activity.create", + ); + expect(permittedAgentActionStatus("FAILED")).toBe("FAILED"); + }); + + it("buckets unknown team keys as other", () => { + expect(permittedAgentRunStatus("HACKED")).toBe(OTHER); + expect(permittedAgentTriggerType("sms")).toBe(OTHER); + expect(permittedLifecycleRole("send")).toBe(OTHER); + expect(permittedTeamActionType("crm.email.send")).toBe(OTHER); + expect(permittedAgentActionStatus("DONE")).toBe(OTHER); + }); }); describe("permittedTool", () => { diff --git a/packages/validation/src/agents.ts b/packages/validation/src/agents.ts index f1381b672..cd67845b4 100644 --- a/packages/validation/src/agents.ts +++ b/packages/validation/src/agents.ts @@ -91,7 +91,19 @@ export const capabilityResource = z.object({ label: z.string().trim().min(1).max(160), }); +export const LIFECYCLE_ROLES = [ + "qualify", + "engage", + "advance", + "close", +] as const; + +export type LifecycleRole = (typeof LIFECYCLE_ROLES)[number]; + +export const lifecycleRole = z.enum(LIFECYCLE_ROLES); + export const capabilities = z.object({ + lifecycleRole: lifecycleRole.optional(), actions: z.array(capabilityAction), dataScope: z.object({ mode: z.enum(["SELECTED", "WORKSPACE"]), @@ -103,3 +115,12 @@ export const capabilities = z.object({ export type Capabilities = z.infer; export type CapabilityAction = z.infer; export type CapabilityResource = z.infer; + +export function readLifecycleRole(manifest: unknown): LifecycleRole | null { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return null; + } + const role = (manifest as { lifecycleRole?: unknown }).lifecycleRole; + const parsed = lifecycleRole.safeParse(role); + return parsed.success ? parsed.data : null; +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 97e8de82a..d30d91789 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -10,8 +10,10 @@ export type { InputOption, InputRequest, InputRequested, + LifecycleRole, Permission, } from "./agents"; +export { LIFECYCLE_ROLES, lifecycleRole, readLifecycleRole } from "./agents"; export type { AuthTest, Installation, JoinPayload, Reply } from "./slack"; export class InvalidInput extends Error { diff --git a/packages/validation/test/lifecycle-role.spec.ts b/packages/validation/test/lifecycle-role.spec.ts new file mode 100644 index 000000000..ebb4ddd36 --- /dev/null +++ b/packages/validation/test/lifecycle-role.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { capabilities, readLifecycleRole } from "../src/agents"; + +describe("lifecycleRole validation", () => { + it("reads a valid role from a raw manifest", () => { + expect(readLifecycleRole({ lifecycleRole: "qualify" })).toBe("qualify"); + expect(readLifecycleRole({ lifecycleRole: "nope" })).toBeNull(); + expect(readLifecycleRole({})).toBeNull(); + }); + + it("accepts capabilities with an optional lifecycleRole", () => { + const base = { + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], + dataScope: { mode: "WORKSPACE" as const, summary: "all", resources: [] }, + }; + expect(capabilities.parse(base).lifecycleRole).toBeUndefined(); + expect( + capabilities.parse({ ...base, lifecycleRole: "qualify" }).lifecycleRole, + ).toBe("qualify"); + expect( + capabilities.safeParse({ ...base, lifecycleRole: "send" }).success, + ).toBe(false); + }); +}); From f2aa1f1bf1cbf42ed34e30e970519e3adc186660 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 00:21:55 -0400 Subject: [PATCH 18/22] feat: deal score and forecast context on Phase 11 path Add Deal score/forecast columns, research-lane deal-score tasks, Nest enqueue on stage change and nightly sweep, agent write tool, and sheet UI. forecastContextManual wins when set. Intelligence stays in apps/agent. --- apps/agent/agent/lib/deal-intelligence.ts | 90 +++++++++ apps/agent/agent/lib/dispatch.ts | 2 + .../agent/tools/write_deal_intelligence.ts | 45 +++++ .../deal-intelligence.integration.spec.ts | 127 ++++++++++++ apps/api/src/agent/agent-trigger.service.ts | 16 +- apps/api/src/deals/deal-score.config.ts | 6 + apps/api/src/deals/deal-score.service.ts | 112 ++++++++++ apps/api/src/deals/deals.contracts.ts | 1 + apps/api/src/deals/deals.module.ts | 5 +- apps/api/src/deals/deals.service.ts | 17 ++ apps/api/test/deal-score.spec.ts | 191 ++++++++++++++++++ .../crm/record-sheet/deal-sheet.tsx | 86 ++++++++ docs/agent.md | 10 +- packages/db/package.json | 1 + .../migration.sql | 9 + packages/db/prisma/schema.prisma | 7 + packages/db/src/agent-tasks.ts | 2 + packages/db/src/deal-score.ts | 60 ++++++ packages/db/test/deal-score.spec.ts | 53 +++++ 19 files changed, 835 insertions(+), 5 deletions(-) create mode 100644 apps/agent/agent/lib/deal-intelligence.ts create mode 100644 apps/agent/agent/tools/write_deal_intelligence.ts create mode 100644 apps/agent/test/deal-intelligence.integration.spec.ts create mode 100644 apps/api/src/deals/deal-score.config.ts create mode 100644 apps/api/src/deals/deal-score.service.ts create mode 100644 apps/api/test/deal-score.spec.ts create mode 100644 packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql create mode 100644 packages/db/src/deal-score.ts create mode 100644 packages/db/test/deal-score.spec.ts diff --git a/apps/agent/agent/lib/deal-intelligence.ts b/apps/agent/agent/lib/deal-intelligence.ts new file mode 100644 index 000000000..af2d89f80 --- /dev/null +++ b/apps/agent/agent/lib/deal-intelligence.ts @@ -0,0 +1,90 @@ +import { db } from "@crm/db"; +import { + blankToNull, + clampDealScore, + DEAL_SCORE, + isValidDealScore, +} from "@crm/db/deal-score"; + +export type WriteDealIntelligenceInput = { + dealId: string; + score: number; + summary: string; + forecastContext: string; +}; + +export type WriteDealIntelligenceResult = + | { + written: true; + score: number; + scoredAt: string; + } + | { + written: false; + reason: string; + }; + +export async function writeDealIntelligence( + input: WriteDealIntelligenceInput, +): Promise { + const score = clampDealScore(input.score); + if (!isValidDealScore(score)) { + return { + written: false, + reason: `Score must be an integer from ${DEAL_SCORE.min} to ${DEAL_SCORE.max}.`, + }; + } + + const summary = blankToNull(input.summary); + if (!summary) { + return { + written: false, + reason: "Score summary is required.", + }; + } + if (summary.length > DEAL_SCORE.summaryMax) { + return { + written: false, + reason: `Score summary must be at most ${DEAL_SCORE.summaryMax} characters.`, + }; + } + + const forecastContext = blankToNull(input.forecastContext); + if (!forecastContext) { + return { + written: false, + reason: "Forecast context is required.", + }; + } + if (forecastContext.length > DEAL_SCORE.forecastMax) { + return { + written: false, + reason: `Forecast context must be at most ${DEAL_SCORE.forecastMax} characters.`, + }; + } + + const deal = await db.deal.findUnique({ + where: { id: input.dealId }, + select: { id: true }, + }); + if (!deal) { + return { written: false, reason: "No such deal." }; + } + + const scoredAt = new Date(); + await db.deal.update({ + where: { id: input.dealId }, + data: { + dealScore: score, + dealScoreSummary: summary, + dealScoredAt: scoredAt, + forecastContext, + }, + }); + + return { + written: true, + score, + scoredAt: scoredAt.toISOString(), + }; +} diff --git a/apps/agent/agent/lib/dispatch.ts b/apps/agent/agent/lib/dispatch.ts index 5f0b9a048..3add17057 100644 --- a/apps/agent/agent/lib/dispatch.ts +++ b/apps/agent/agent/lib/dispatch.ts @@ -420,6 +420,8 @@ function work(kind: string, reason: string): string { return "This company's brand, industry, location and links are filled in separately and may already be there. Read the account, fill anything still missing, and write a brief if there is something worth saying."; case "workspace-profile": return "Write the profile of the company you work for, so that every other session knows who we are. Read our own site and keep it short."; + case "deal-score": + return "Score this deal from 0–100 and write the forecast context. Read the deal history first. Base the score on stage age, activity recency and cadence, contact coverage (champion and economic buyer), and what the notes say. Call write_deal_intelligence once with the score, a one-paragraph rationale, and a rolling timeline summary. Do not send outreach. Do not change stage or ownership. Do not overwrite forecastContextManual."; default: return `Handle this: ${reason}`; } diff --git a/apps/agent/agent/tools/write_deal_intelligence.ts b/apps/agent/agent/tools/write_deal_intelligence.ts new file mode 100644 index 000000000..b08d7d9eb --- /dev/null +++ b/apps/agent/agent/tools/write_deal_intelligence.ts @@ -0,0 +1,45 @@ +import { DEAL_SCORE } from "@crm/db/deal-score"; +import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { writeDealIntelligence } from "../lib/deal-intelligence"; +import { assertResearchPurpose } from "../lib/session-purpose"; + +export default defineTool({ + description: + "Write the deal score (0–100), one-paragraph rationale, and AI forecast context on a deal. Replaces the previous AI score and forecast. Does not overwrite forecastContextManual. Base every claim on the deal timeline you already read.", + inputSchema: z.object({ + dealId: z.string(), + score: z + .number() + .int() + .min(DEAL_SCORE.min) + .max(DEAL_SCORE.max) + .describe( + "Health 0–100 from stage age, activity cadence, contact coverage, and note content.", + ), + summary: z + .string() + .min(40) + .max(DEAL_SCORE.summaryMax) + .describe( + "One paragraph on why this score. Present tense. No fluff. Cite stage age, activity, contacts, and notes.", + ), + forecastContext: z + .string() + .min(40) + .max(DEAL_SCORE.forecastMax) + .describe( + "Rolling summary of the deal timeline for forecast. What moved, what blocks, what is next.", + ), + }), + async execute(input, ctx) { + assertResearchPurpose(ctx); + + return writeDealIntelligence({ + dealId: input.dealId, + score: input.score, + summary: input.summary, + forecastContext: input.forecastContext, + }); + }, +}); diff --git a/apps/agent/test/deal-intelligence.integration.spec.ts b/apps/agent/test/deal-intelligence.integration.spec.ts new file mode 100644 index 000000000..583f9e106 --- /dev/null +++ b/apps/agent/test/deal-intelligence.integration.spec.ts @@ -0,0 +1,127 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { effectiveForecastContext } from "@crm/db/deal-score"; +import { writeDealIntelligence } from "../agent/lib/deal-intelligence"; +import { brief } from "../agent/lib/dispatch"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `deal-intel-${suffix}.test`; +const ownerId = `deal-intel-owner-${suffix}`; + +let companyId = ""; +let dealId = ""; + +async function clean() { + if (dealId) { + await db.deal.deleteMany({ where: { id: dealId } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Intel Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Intel Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const deal = await db.deal.create({ + data: { + name: `Intel Deal ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + forecastContextManual: "Rep override stays put.", + }, + select: { id: true }, + }); + dealId = deal.id; +}); + +afterAll(async () => { + await clean(); +}); + +const summary = + "Stage is young with a champion on the account, but economic buyer coverage is thin and activity has slowed."; +const forecast = + "Demo booked last week; next step is a technical review. Risk is no CFO contact and two quiet weeks on email."; + +describe("writeDealIntelligence", () => { + it("writes score fields without overwriting manual forecast", async () => { + const result = await writeDealIntelligence({ + dealId, + score: 67, + summary, + forecastContext: forecast, + }); + + expect(result.written).toBe(true); + if (!result.written) return; + + const deal = await db.deal.findUnique({ + where: { id: dealId }, + select: { + dealScore: true, + dealScoreSummary: true, + dealScoredAt: true, + forecastContext: true, + forecastContextManual: true, + }, + }); + + expect(deal?.dealScore).toBe(67); + expect(deal?.dealScoreSummary).toBe(summary); + expect(deal?.forecastContext).toBe(forecast); + expect(deal?.forecastContextManual).toBe("Rep override stays put."); + expect(deal?.dealScoredAt).not.toBeNull(); + expect( + effectiveForecastContext( + deal?.forecastContext, + deal?.forecastContextManual, + ), + ).toBe("Rep override stays put."); + }); + + it("rejects empty summary", async () => { + const result = await writeDealIntelligence({ + dealId, + score: 50, + summary: " ", + forecastContext: forecast, + }); + expect(result.written).toBe(false); + }); + + it("briefs the research lane for deal-score tasks", () => { + const text = brief({ + id: "t1", + contactId: null, + companyId: null, + dealId, + kind: "deal-score", + reason: "Stage changed", + payload: null, + budget: 6, + attempts: 1, + priority: 80, + dueAt: new Date(), + }); + + expect(text).toContain("write_deal_intelligence"); + expect(text).toContain("0–100"); + expect(text).toContain("forecastContextManual"); + }); +}); diff --git a/apps/api/src/agent/agent-trigger.service.ts b/apps/api/src/agent/agent-trigger.service.ts index ea5226eeb..41a27747e 100644 --- a/apps/api/src/agent/agent-trigger.service.ts +++ b/apps/api/src/agent/agent-trigger.service.ts @@ -246,6 +246,16 @@ export class AgentTriggerService { }); } + async dealScore(dealId: string, reason: string): Promise { + return this.enqueue({ + dealId, + kind: "deal-score", + reason, + priority: PRIORITY.dealScore, + budget: 6, + }); + } + builderConversationQueued(): void { this.pokeRoute("/internal/crm/builder-dispatch"); } @@ -364,6 +374,7 @@ export class AgentTriggerService { task: { contactId?: string; companyId?: string; + dealId?: string; kind: string; reason: string; priority: number; @@ -378,7 +389,7 @@ export class AgentTriggerService { const write = async (tx: Prisma.TransactionClient) => { await lockIdempotencyKey( tx, - `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.subject?.value ?? ""}`, + `agent-task:${task.kind}:${task.contactId ?? ""}:${task.companyId ?? ""}:${task.dealId ?? ""}:${task.subject?.value ?? ""}`, ); const pending = await tx.agentTask.findFirst({ where: { @@ -386,6 +397,7 @@ export class AgentTriggerService { finishedAt: null, ...(task.contactId ? { contactId: task.contactId } : {}), ...(task.companyId ? { companyId: task.companyId } : {}), + ...(task.dealId ? { dealId: task.dealId } : {}), ...(task.subject ? { payload: { @@ -403,6 +415,7 @@ export class AgentTriggerService { data: { contactId: task.contactId ?? null, companyId: task.companyId ?? null, + dealId: task.dealId ?? null, kind: task.kind, reason: task.reason, priority: task.priority, @@ -424,6 +437,7 @@ export class AgentTriggerService { kind: task.kind, contactId: task.contactId, companyId: task.companyId, + dealId: task.dealId, }); if (!client) this.poke(); diff --git a/apps/api/src/deals/deal-score.config.ts b/apps/api/src/deals/deal-score.config.ts new file mode 100644 index 000000000..b91d9c6f8 --- /dev/null +++ b/apps/api/src/deals/deal-score.config.ts @@ -0,0 +1,6 @@ +const HOUR_MS = 60 * 60 * 1_000; +const DAY_MS = 24 * HOUR_MS; + +export const DEAL_SCORE_SWEEP = { + auto: { everyMs: DAY_MS, cacheKey: "deal-score:auto" }, +} as const; diff --git a/apps/api/src/deals/deal-score.service.ts b/apps/api/src/deals/deal-score.service.ts new file mode 100644 index 000000000..eb93d5f22 --- /dev/null +++ b/apps/api/src/deals/deal-score.service.ts @@ -0,0 +1,112 @@ +import { onSignedIn } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; +import { + DEAL_SCORE, + needsDealScore, + scoreRescoreCutoff, +} from "@crm/db/deal-score"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; +import type { Cache } from "cache-manager"; +import { AgentTriggerService } from "../agent/agent-trigger.service"; +import { InjectDatabase } from "../database/database.constants"; +import { DEAL_SCORE_SWEEP } from "./deal-score.config"; + +export type DealScoreSweepResult = { + scanned: number; + queued: number; + alreadyQueued: number; +}; + +@Injectable() +export class DealScoreService implements OnModuleInit { + private readonly logger = new Logger(DealScoreService.name); + + constructor( + @InjectDatabase() private readonly db: Db, + private readonly agent: AgentTriggerService, + @Inject(CACHE_MANAGER) private readonly cache: Cache, + ) {} + + onModuleInit(): void { + onSignedIn(() => { + void this.auto(); + }); + } + + async auto(): Promise<{ started: boolean }> { + if (await this.cache.get(DEAL_SCORE_SWEEP.auto.cacheKey)) { + return { started: false }; + } + await this.cache.set( + DEAL_SCORE_SWEEP.auto.cacheKey, + true, + DEAL_SCORE_SWEEP.auto.everyMs, + ); + + void (async () => { + try { + const result = await this.sweep(); + if (result.queued > 0 || result.scanned > 0) { + this.logger.log({ + message: "Deal-score sweep finished", + ...result, + }); + } + } catch (error) { + this.logger.error( + { message: "Deal-score sweep failed" }, + error instanceof Error ? error.stack : String(error), + ); + } + })(); + + return { started: true }; + } + + async sweep(now = new Date()): Promise { + const cutoff = scoreRescoreCutoff(now); + + const deals = await this.db.deal.findMany({ + where: { + stage: { in: [...OPEN_DEAL_STAGES] }, + OR: [{ dealScoredAt: null }, { dealScoredAt: { lte: cutoff } }], + }, + orderBy: [ + { dealScoredAt: { sort: "asc", nulls: "first" } }, + { updatedAt: "asc" }, + { id: "asc" }, + ], + take: DEAL_SCORE.maxPerRun, + select: { + id: true, + name: true, + dealScoredAt: true, + }, + }); + + let queued = 0; + let alreadyQueued = 0; + + for (const deal of deals) { + if ( + !needsDealScore({ + dealScoredAt: deal.dealScoredAt, + now, + }) + ) { + continue; + } + + const reason = deal.dealScoredAt + ? `Nightly rescore for ${deal.name.trim() || "deal"}` + : `Score open deal ${deal.name.trim() || "deal"}`; + const created = await this.agent.dealScore(deal.id, reason); + if (created) queued += 1; + else alreadyQueued += 1; + } + + return { scanned: deals.length, queued, alreadyQueued }; + } +} diff --git a/apps/api/src/deals/deals.contracts.ts b/apps/api/src/deals/deals.contracts.ts index ef8ee4326..0170cb1d4 100644 --- a/apps/api/src/deals/deals.contracts.ts +++ b/apps/api/src/deals/deals.contracts.ts @@ -58,6 +58,7 @@ const dealUpdateInput = z.object({ amountCents, currency: currencyCode.optional(), expectedCloseDate: z.string().nullable().optional(), + forecastContextManual: z.string().max(2_000).nullable().optional(), fields: recordFieldValues.optional(), }); diff --git a/apps/api/src/deals/deals.module.ts b/apps/api/src/deals/deals.module.ts index 310aed015..2ab5c0b78 100644 --- a/apps/api/src/deals/deals.module.ts +++ b/apps/api/src/deals/deals.module.ts @@ -3,12 +3,13 @@ import { AgentModule } from "../agent/agent.module"; import { CurrencyModule } from "../currency/currency.module"; import { FieldsModule } from "../fields/fields.module"; import { TrpcModule } from "../trpc/trpc.module"; +import { DealScoreService } from "./deal-score.service"; import { DealsRouter } from "./deals.router"; import { DealsService } from "./deals.service"; @Module({ imports: [AgentModule, FieldsModule, TrpcModule, CurrencyModule], - providers: [DealsService, DealsRouter], - exports: [DealsService], + providers: [DealsService, DealsRouter, DealScoreService], + exports: [DealsService, DealScoreService], }) export class DealsModule {} diff --git a/apps/api/src/deals/deals.service.ts b/apps/api/src/deals/deals.service.ts index 5bb09fecf..ddf8f5368 100644 --- a/apps/api/src/deals/deals.service.ts +++ b/apps/api/src/deals/deals.service.ts @@ -202,6 +202,11 @@ export class DealsService { expectedCloseDate: true, closedAt: true, closedReason: true, + dealScore: true, + dealScoreSummary: true, + dealScoredAt: true, + forecastContext: true, + forecastContextManual: true, createdAt: true, company: { select: { ...COMPANY_SELECT, industry: true } }, owner: { select: OWNER_SELECT }, @@ -229,6 +234,7 @@ export class DealsService { stageChangedAt: deal.stageChangedAt.toISOString(), expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, closedAt: deal.closedAt?.toISOString() ?? null, + dealScoredAt: deal.dealScoredAt?.toISOString() ?? null, createdAt: deal.createdAt.toISOString(), contacts: contacts.map(({ role, contact }) => ({ ...contact, role })), }; @@ -312,6 +318,12 @@ export class DealsService { if (input.expectedCloseDate !== undefined) { data.expectedCloseDate = parseDate(input.expectedCloseDate); } + if (input.forecastContextManual !== undefined) { + data.forecastContextManual = + input.forecastContextManual === null + ? null + : blankToNull(input.forecastContextManual); + } if (input.amountCents !== undefined || input.currency !== undefined) { const current = await this.db.deal.findUnique({ @@ -478,6 +490,11 @@ export class DealsService { await this.stamp.touch({ companyId: deal.companyId, dealId: deal.id }, now); + void this.agent.dealScore( + deal.id, + `Stage changed from ${deal.stage} to ${input.stage}`, + ); + this.logger.log({ message: "Deal stage changed", dealId: deal.id, diff --git a/apps/api/test/deal-score.spec.ts b/apps/api/test/deal-score.spec.ts new file mode 100644 index 000000000..aaba8e8d4 --- /dev/null +++ b/apps/api/test/deal-score.spec.ts @@ -0,0 +1,191 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { DealStage, db } from "@crm/db"; +import { DEAL_SCORE } from "@crm/db/deal-score"; +import { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { DealScoreService } from "../src/deals/deal-score.service"; + +const suffix = process.env.TEST_RUN_ID ?? crypto.randomUUID().slice(0, 8); +const domain = `deal-score-${suffix}.test`; +const ownerId = `deal-score-owner-${suffix}`; +const now = new Date("2026-08-12T12:00:00.000Z"); +const staleScoredAt = new Date("2026-08-10T12:00:00.000Z"); +const freshScoredAt = new Date("2026-08-12T08:00:00.000Z"); + +const agent = new AgentTriggerService(db); +const cache = { + store: new Map(), + async get(key: string) { + return this.store.get(key); + }, + async set(key: string, value: unknown) { + this.store.set(key, value); + }, +}; +const service = new DealScoreService(db, agent, cache as never); + +let companyId = ""; +let unscoredDealId = ""; +let staleDealId = ""; +let freshDealId = ""; +let closedDealId = ""; +let previousBridgeSecret: string | undefined; + +async function clean() { + const deals = [unscoredDealId, staleDealId, freshDealId, closedDealId].filter( + Boolean, + ); + if (deals.length > 0) { + await db.agentTask.deleteMany({ where: { dealId: { in: deals } } }); + await db.deal.deleteMany({ where: { id: { in: deals } } }); + } + await db.company.deleteMany({ where: { domain } }); + await db.user.deleteMany({ where: { id: ownerId } }); +} + +beforeAll(async () => { + previousBridgeSecret = process.env.AGENT_BRIDGE_SECRET; + delete process.env.AGENT_BRIDGE_SECRET; + await clean(); + + await db.user.create({ + data: { + id: ownerId, + name: "Deal Score Owner", + email: `${ownerId}@example.test`, + emailVerified: true, + }, + }); + + const company = await db.company.create({ + data: { name: `Score Co ${suffix}`, domain }, + select: { id: true }, + }); + companyId = company.id; + + const unscored = await db.deal.create({ + data: { + name: `Unscored ${suffix}`, + companyId, + ownerId, + stage: DealStage.DEMO_BOOKED, + }, + select: { id: true }, + }); + unscoredDealId = unscored.id; + + const stale = await db.deal.create({ + data: { + name: `Stale score ${suffix}`, + companyId, + ownerId, + stage: DealStage.QUALIFIED_TO_BUY, + dealScore: 40, + dealScoreSummary: "Old summary that needs refresh.", + dealScoredAt: staleScoredAt, + forecastContext: "Old forecast.", + }, + select: { id: true }, + }); + staleDealId = stale.id; + + const fresh = await db.deal.create({ + data: { + name: `Fresh score ${suffix}`, + companyId, + ownerId, + stage: DealStage.CONTRACT_SENT, + dealScore: 80, + dealScoreSummary: "Still fresh enough.", + dealScoredAt: freshScoredAt, + forecastContext: "Fresh forecast.", + }, + select: { id: true }, + }); + freshDealId = fresh.id; + + const closed = await db.deal.create({ + data: { + name: `Closed score ${suffix}`, + companyId, + ownerId, + stage: DealStage.CLOSED_WON, + closedAt: staleScoredAt, + dealScoredAt: null, + }, + select: { id: true }, + }); + closedDealId = closed.id; +}); + +afterAll(async () => { + await clean(); + if (previousBridgeSecret === undefined) { + delete process.env.AGENT_BRIDGE_SECRET; + } else { + process.env.AGENT_BRIDGE_SECRET = previousBridgeSecret; + } +}); + +describe("deal-score enqueue", () => { + it("queues open deals that lack a score or are past the rescore window", async () => { + const first = await service.sweep(now); + + expect(first.scanned).toBeGreaterThanOrEqual(2); + expect(first.queued).toBeGreaterThanOrEqual(2); + + const tasks = await db.agentTask.findMany({ + where: { + kind: DEAL_SCORE.kind, + dealId: { + in: [unscoredDealId, staleDealId, freshDealId, closedDealId], + }, + finishedAt: null, + }, + select: { dealId: true, priority: true }, + }); + + const ids = new Set(tasks.map((task) => task.dealId)); + expect(ids.has(unscoredDealId)).toBe(true); + expect(ids.has(staleDealId)).toBe(true); + expect(ids.has(freshDealId)).toBe(false); + expect(ids.has(closedDealId)).toBe(false); + expect(tasks.every((task) => task.priority === 80)).toBe(true); + }); + + it("does not double-queue while a deal-score task is open", async () => { + const second = await service.sweep(now); + + expect(second.queued).toBe(0); + expect(second.alreadyQueued).toBeGreaterThanOrEqual(2); + + const open = await db.agentTask.count({ + where: { + kind: DEAL_SCORE.kind, + dealId: { in: [unscoredDealId, staleDealId] }, + finishedAt: null, + }, + }); + expect(open).toBe(2); + }); + + it("queues a single deal-score task from the trigger helper", async () => { + await db.agentTask.deleteMany({ + where: { kind: DEAL_SCORE.kind, dealId: freshDealId }, + }); + + const created = await agent.dealScore(freshDealId, "Stage changed"); + const again = await agent.dealScore(freshDealId, "Stage changed again"); + + expect(created).toBe(true); + expect(again).toBe(false); + + const open = await db.agentTask.count({ + where: { + kind: DEAL_SCORE.kind, + dealId: freshDealId, + finishedAt: null, + }, + }); + expect(open).toBe(1); + }); +}); diff --git a/apps/app/components/crm/record-sheet/deal-sheet.tsx b/apps/app/components/crm/record-sheet/deal-sheet.tsx index 78f37749b..74e71d948 100644 --- a/apps/app/components/crm/record-sheet/deal-sheet.tsx +++ b/apps/app/components/crm/record-sheet/deal-sheet.tsx @@ -221,6 +221,13 @@ export function DealSheet({ dealId }: { dealId: string }) { )} + + {deal.dealScore === null ? ( + + ) : ( + {deal.dealScore} + )} + {deal.expectedCloseDate ? ( @@ -368,11 +375,90 @@ function DealOverview({ deal }: { deal: Deal }) { /> + + save({ forecastContextManual })} + /> + ); } +function DealScoreCard({ deal }: { deal: Deal }) { + return ( + + {deal.dealScore === null ? ( +

+ No score yet. The agent scores open deals after a stage change and on + the nightly pass. +

+ ) : ( + + + {deal.dealScore} + / 100 + + {deal.dealScoredAt ? ( + + + + ) : null} + {deal.dealScoreSummary ? ( + + {deal.dealScoreSummary} + + ) : null} + + )} +
+ ); +} + +function ForecastContextCard({ + deal, + saving, + onSave, +}: { + deal: Deal; + saving: boolean; + onSave: (value: string | null) => void; +}) { + const manual = deal.forecastContextManual?.trim() ?? ""; + const ai = deal.forecastContext?.trim() ?? ""; + + return ( + + {ai ? ( +
+

+ From the agent +

+

{ai}

+
+ ) : ( +

+ No agent forecast yet. +

+ )} + {manual ? ( +

+ Your note wins when set. Clear it to show the agent text again. +

+ ) : null} + onSave(next === "" ? null : next)} + /> +
+ ); +} + function WhereItStands({ deal }: { deal: Deal }) { const openRecord = useOpenRecord(); diff --git a/docs/agent.md b/docs/agent.md index 89eb53774..df018f9e4 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -57,8 +57,14 @@ behind sixty LLM runs for 25 minutes (`test/lanes.integration.spec.ts`). **The r what the work is; the lane only says whether it needs a conversation.** **Priority**: `brand` 900 · `portrait` 800 · `workspace` 500 · `requested` 300 · -`meeting` 200 · `identify` 100 · `sweep` 50 · `companyProfile` 40 · `recheck` 0. The -top two are what a rep reads *before* deciding what to open. +`meeting` 200 · `identify` 100 · `dealScore` 80 · `sweep` 50 · `companyProfile` 40 · +`recheck` 0. The top two are what a rep reads *before* deciding what to open. + +**`deal-score`** is a research-lane kind. Nest enqueues it on stage change and on a +nightly open-deal sweep. The session reads the deal timeline and calls +`write_deal_intelligence` once. That tool writes `dealScore`, `dealScoreSummary`, +`dealScoredAt`, and `forecastContext`. It never overwrites `forecastContextManual`. +The sheet shows the manual forecast when set. **`claimDue` sorts what it claims** — Postgres does not order `UPDATE … RETURNING` by its sub-select's `ORDER BY`. diff --git a/packages/db/package.json b/packages/db/package.json index 9ef448183..d5270908c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -11,6 +11,7 @@ "./client": "./src/client.ts", "./currency": "./src/currency.ts", "./crm-events": "./src/crm-events.ts", + "./deal-score": "./src/deal-score.ts", "./deal-stage": "./src/deal-stage.ts", "./enums": "./src/generated/prisma/enums.ts", "./favicon": "./src/favicon.ts", diff --git a/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql b/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql new file mode 100644 index 000000000..ea503bd5a --- /dev/null +++ b/packages/db/prisma/migrations/20260812010000_deal_score_forecast/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "deal" ADD COLUMN "dealScore" INTEGER, +ADD COLUMN "dealScoreSummary" TEXT, +ADD COLUMN "dealScoredAt" TIMESTAMP(3), +ADD COLUMN "forecastContext" TEXT, +ADD COLUMN "forecastContextManual" TEXT; + +-- CreateIndex +CREATE INDEX "deal_dealScoredAt_idx" ON "deal"("dealScoredAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 28a49c397..f9eb7bf53 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -927,6 +927,12 @@ model Deal { lastActivityAt DateTime? + dealScore Int? + dealScoreSummary String? + dealScoredAt DateTime? + forecastContext String? + forecastContextManual String? + contacts DealContact[] activities Activity[] fieldValues FieldValue[] @@ -938,6 +944,7 @@ model Deal { @@index([stage]) @@index([expectedCloseDate]) @@index([lastActivityAt]) + @@index([dealScoredAt]) @@index([baseAmount]) @@index([currency]) @@map("deal") diff --git a/packages/db/src/agent-tasks.ts b/packages/db/src/agent-tasks.ts index 33f60eb17..ac5879558 100644 --- a/packages/db/src/agent-tasks.ts +++ b/packages/db/src/agent-tasks.ts @@ -11,6 +11,7 @@ export const TASK_KINDS = [ "slack-people-match", "slack-channel-join", "agent-event", + "deal-score", ] as const; export type TaskKind = (typeof TASK_KINDS)[number]; @@ -47,4 +48,5 @@ export const PRIORITY = { slackPeople: 150, slackJoin: 950, event: 700, + dealScore: 80, } as const; diff --git a/packages/db/src/deal-score.ts b/packages/db/src/deal-score.ts new file mode 100644 index 000000000..4cc257a05 --- /dev/null +++ b/packages/db/src/deal-score.ts @@ -0,0 +1,60 @@ +const DAY_MS = 86_400_000; + +export const DEAL_SCORE = { + kind: "deal-score", + min: 0, + max: 100, + summaryMax: 800, + forecastMax: 2_000, + maxPerRun: 100, + rescoreAfterDays: 1, +} as const; + +export function clampDealScore(score: number): number { + if (!Number.isFinite(score)) return DEAL_SCORE.min; + return Math.min(DEAL_SCORE.max, Math.max(DEAL_SCORE.min, Math.round(score))); +} + +export function isValidDealScore(score: number): boolean { + return ( + Number.isInteger(score) && + score >= DEAL_SCORE.min && + score <= DEAL_SCORE.max + ); +} + +export function blankToNull(value: string | null | undefined): string | null { + if (value === null || value === undefined) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function effectiveForecastContext( + forecastContext: string | null | undefined, + forecastContextManual: string | null | undefined, +): string | null { + const manual = blankToNull(forecastContextManual); + if (manual !== null) return manual; + return blankToNull(forecastContext); +} + +export function scoreRescoreCutoff( + now: Date, + rescoreAfterDays: number = DEAL_SCORE.rescoreAfterDays, +): Date { + return new Date( + now.getTime() - Math.max(rescoreAfterDays, 0) * DAY_MS, + ); +} + +export function needsDealScore(input: { + dealScoredAt: Date | null; + now: Date; + rescoreAfterDays?: number; +}): boolean { + if (!input.dealScoredAt) return true; + return ( + input.dealScoredAt.getTime() <= + scoreRescoreCutoff(input.now, input.rescoreAfterDays).getTime() + ); +} diff --git a/packages/db/test/deal-score.spec.ts b/packages/db/test/deal-score.spec.ts new file mode 100644 index 000000000..e75608a67 --- /dev/null +++ b/packages/db/test/deal-score.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "bun:test"; +import { + blankToNull, + clampDealScore, + DEAL_SCORE, + effectiveForecastContext, + isValidDealScore, + needsDealScore, + scoreRescoreCutoff, +} from "../src/deal-score"; + +describe("deal score helpers", () => { + it("clamps scores to 0–100 integers", () => { + expect(clampDealScore(-4)).toBe(0); + expect(clampDealScore(140.7)).toBe(100); + expect(clampDealScore(72.4)).toBe(72); + expect(isValidDealScore(0)).toBe(true); + expect(isValidDealScore(100)).toBe(true); + expect(isValidDealScore(72.5)).toBe(false); + expect(isValidDealScore(-1)).toBe(false); + }); + + it("prefers manual forecast context when set", () => { + expect( + effectiveForecastContext("AI says close next month", "Rep: wait for CFO"), + ).toBe("Rep: wait for CFO"); + expect(effectiveForecastContext("AI summary", null)).toBe("AI summary"); + expect(effectiveForecastContext("AI summary", " ")).toBe("AI summary"); + expect(effectiveForecastContext(null, null)).toBe(null); + expect(blankToNull(" note ")).toBe("note"); + expect(blankToNull("")).toBe(null); + }); + + it("flags deals that need a rescore after the cutoff", () => { + const now = new Date("2026-08-12T12:00:00.000Z"); + expect(needsDealScore({ dealScoredAt: null, now })).toBe(true); + expect( + needsDealScore({ + dealScoredAt: new Date("2026-08-12T08:00:00.000Z"), + now, + }), + ).toBe(false); + expect( + needsDealScore({ + dealScoredAt: new Date("2026-08-10T12:00:00.000Z"), + now, + }), + ).toBe(true); + expect(scoreRescoreCutoff(now).toISOString()).toBe( + new Date(now.getTime() - DEAL_SCORE.rescoreAfterDays * 86_400_000).toISOString(), + ); + }); +}); From ef0cb4f1286cf8cffae078b4f5b54e07c7a0f1bf Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 02:53:07 -0400 Subject: [PATCH 19/22] fix(api): optional-chain occurredAt in calendar-sync typecheck --- apps/api/test/calendar-sync.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/test/calendar-sync.spec.ts b/apps/api/test/calendar-sync.spec.ts index c99cd54d6..549ce3d47 100644 --- a/apps/api/test/calendar-sync.spec.ts +++ b/apps/api/test/calendar-sync.spec.ts @@ -193,7 +193,7 @@ describe("CalendarSyncService", () => { expect(stored?.activity?.type).toBe(ActivityType.MEETING); expect(stored?.activity?.subject).toBe("Pricing review"); expect(stored?.activity?.companyId).toBe(companyId); - expect(stored?.activity?.occurredAt.toISOString()).toBe(startsAt); + expect(stored?.activity?.occurredAt?.toISOString()).toBe(startsAt); expect(stored?.attendees.map((a) => a.email)).toEqual( [mailbox, person].sort(), ); From 36f825118f78d660e0a2c8ee0a3545d66d936f39 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 02:54:16 -0400 Subject: [PATCH 20/22] style: biome format for agentic wave files --- apps/agent/agent/lib/brand.ts | 3 +-- apps/agent/agent/lib/brief-identity.ts | 11 ++-------- apps/agent/agent/lib/capabilities.ts | 7 +++---- apps/agent/agent/lib/job-change.ts | 4 +--- apps/agent/agent/lib/linkedin-candidates.ts | 2 +- apps/agent/agent/lib/perplexity.ts | 1 - .../agent/agent/tools/get_linkedin_profile.ts | 14 ++----------- apps/agent/test/approval.spec.ts | 6 +++--- apps/agent/test/brief-identity.spec.ts | 8 +++---- apps/agent/test/capabilities.spec.ts | 6 +++--- apps/agent/test/facts.integration.spec.ts | 5 +++-- apps/agent/test/identity-verdict.spec.ts | 7 ++++++- .../agent/test/job-change.integration.spec.ts | 7 ++----- .../test/keyless-brand.integration.spec.ts | 9 +------- apps/agent/test/lifecycle-advance.spec.ts | 9 ++++++-- apps/agent/test/lifecycle-close.spec.ts | 6 +++--- apps/agent/test/lifecycle-engage.spec.ts | 5 ++++- apps/agent/test/lifecycle-qualify.spec.ts | 21 +++++++++---------- apps/agent/test/meeting-prep-dispatch.spec.ts | 2 +- .../src/agent/agent-observability.service.ts | 4 +++- apps/api/src/backfill/backfill.service.ts | 4 +--- apps/api/src/deals/deal-score.service.ts | 2 +- apps/api/test/agent-observability.spec.ts | 4 +--- apps/api/test/calendar-sync.spec.ts | 16 +++++++++----- apps/api/test/gmail-sync.spec.ts | 13 ++++++++++-- apps/api/test/mailbox-auto-create.spec.ts | 7 +------ packages/db/src/deal-score.ts | 4 +--- packages/db/test/deal-score.spec.ts | 4 +++- 28 files changed, 89 insertions(+), 102 deletions(-) diff --git a/apps/agent/agent/lib/brand.ts b/apps/agent/agent/lib/brand.ts index 6c9b77d30..dafc4a3db 100644 --- a/apps/agent/agent/lib/brand.ts +++ b/apps/agent/agent/lib/brand.ts @@ -60,8 +60,7 @@ export async function runBrand({ if (!(await contextDevEnabled())) { return { enriched: false, - reason: - "Context.dev is not configured, so there is nowhere to look.", + reason: "Context.dev is not configured, so there is nowhere to look.", }; } diff --git a/apps/agent/agent/lib/brief-identity.ts b/apps/agent/agent/lib/brief-identity.ts index 1e3909267..5f7f2fa83 100644 --- a/apps/agent/agent/lib/brief-identity.ts +++ b/apps/agent/agent/lib/brief-identity.ts @@ -1,9 +1,5 @@ import { FactBand } from "@crm/db"; -import { - type Evidence, - type EvidenceKind, - scoreEvidence, -} from "./evidence"; +import { type Evidence, type EvidenceKind, scoreEvidence } from "./evidence"; import { isDerivedName } from "./names"; export const IDENTITY_PROOF_KINDS = [ @@ -56,10 +52,7 @@ export function refuseBriefReason(input: { const scored = scoreEvidence(input.evidence); - if ( - scored.band === null || - scored.band === FactBand.POSSIBLE - ) { + if (scored.band === null || scored.band === FactBand.POSSIBLE) { return "Nothing here is sourced well enough to put on the record."; } diff --git a/apps/agent/agent/lib/capabilities.ts b/apps/agent/agent/lib/capabilities.ts index 3f65570d5..77c8480b8 100644 --- a/apps/agent/agent/lib/capabilities.ts +++ b/apps/agent/agent/lib/capabilities.ts @@ -94,7 +94,8 @@ export function capabilitiesFrom( { ...fromEnv("PERPLEXITY_API_KEY"), label: "Web research", - gives: "open-web context with citations for research, not for identity matching", + gives: + "open-web context with citations for research, not for identity matching", }, { id: CONTEXT_DEV, @@ -202,9 +203,7 @@ export function enableChecklistMarkdown( for (const item of FULL_AGENTIC_CHECKLIST) { if (item.kind === "env") { const on = byId.get(item.id)?.enabled === true; - lines.push( - `- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`, - ); + lines.push(`- [${on ? "x" : " "}] \`${item.source}\` — ${item.label}`); } else { const on = byId.get(item.id)?.enabled === true; lines.push( diff --git a/apps/agent/agent/lib/job-change.ts b/apps/agent/agent/lib/job-change.ts index c4be66ada..fc8239c4d 100644 --- a/apps/agent/agent/lib/job-change.ts +++ b/apps/agent/agent/lib/job-change.ts @@ -47,9 +47,7 @@ export async function raiseJobChange( }); if (!contact) return { raised: false, reason: "No such contact." }; - const name = [contact.firstName, contact.lastName] - .filter(Boolean) - .join(" "); + const name = [contact.firstName, contact.lastName].filter(Boolean).join(" "); const subject = `${name} has moved to ${change.to}`; const body = [ diff --git a/apps/agent/agent/lib/linkedin-candidates.ts b/apps/agent/agent/lib/linkedin-candidates.ts index 4da0382d3..c0b1e866d 100644 --- a/apps/agent/agent/lib/linkedin-candidates.ts +++ b/apps/agent/agent/lib/linkedin-candidates.ts @@ -1,4 +1,4 @@ -import { search, type SearchResult } from "./context-dev"; +import { type SearchResult, search } from "./context-dev"; import { slugFromProfileUrl } from "./linkdapi"; import { searchTerms } from "./names"; diff --git a/apps/agent/agent/lib/perplexity.ts b/apps/agent/agent/lib/perplexity.ts index e3d2a4c97..e7377d87a 100644 --- a/apps/agent/agent/lib/perplexity.ts +++ b/apps/agent/agent/lib/perplexity.ts @@ -80,4 +80,3 @@ export async function ask( clearTimeout(timer); } } - diff --git a/apps/agent/agent/tools/get_linkedin_profile.ts b/apps/agent/agent/tools/get_linkedin_profile.ts index 6390ce589..97bd6a251 100644 --- a/apps/agent/agent/tools/get_linkedin_profile.ts +++ b/apps/agent/agent/tools/get_linkedin_profile.ts @@ -52,18 +52,8 @@ export default defineTool({ } const profile = result.data; - const verdict = identityChecks( - profile, - email, - companyName, - companyDomain, - ); - const evidence = identityEvidence( - profile, - verdict, - email, - companyName, - ); + const verdict = identityChecks(profile, email, companyName, companyDomain); + const evidence = identityEvidence(profile, verdict, email, companyName); const history = includeHistory && profile.urn ? await getExperience(profile.urn) : null; diff --git a/apps/agent/test/approval.spec.ts b/apps/agent/test/approval.spec.ts index f75e69e11..7161bc8f8 100644 --- a/apps/agent/test/approval.spec.ts +++ b/apps/agent/test/approval.spec.ts @@ -70,9 +70,9 @@ describe("sensitiveWhen", () => { it("lets automated runs raise a job change without re-parenting", async () => { expect(await decide(policy, appSession, {})).toBe("not-applicable"); - expect( - await decide(policy, appSession, { contactId: "c1" }), - ).toBe("not-applicable"); + expect(await decide(policy, appSession, { contactId: "c1" })).toBe( + "not-applicable", + ); }); it("denies automated re-parenting of a contact company", async () => { diff --git a/apps/agent/test/brief-identity.spec.ts b/apps/agent/test/brief-identity.spec.ts index 476301196..4dbbfc874 100644 --- a/apps/agent/test/brief-identity.spec.ts +++ b/apps/agent/test/brief-identity.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "bun:test"; -import type { Evidence } from "../agent/lib/evidence"; import { + type ContactIdentitySnapshot, contactIdentityIsTrustworthy, evidenceProvesIdentity, refuseBriefReason, - type ContactIdentitySnapshot, } from "../agent/lib/brief-identity"; +import type { Evidence } from "../agent/lib/evidence"; const of = (...kinds: Evidence["kind"][]): Evidence[] => kinds.map((kind) => ({ kind, detail: `saw ${kind}` })); @@ -28,9 +28,7 @@ const known: ContactIdentitySnapshot = { describe("evidenceProvesIdentity", () => { it("accepts employer-and-name and thread reply", () => { - expect(evidenceProvesIdentity(of("linkedin.employer-and-name"))).toBe( - true, - ); + expect(evidenceProvesIdentity(of("linkedin.employer-and-name"))).toBe(true); expect(evidenceProvesIdentity(of("crm.thread-reply"))).toBe(true); }); diff --git a/apps/agent/test/capabilities.spec.ts b/apps/agent/test/capabilities.spec.ts index 6882fdfc0..641073f4a 100644 --- a/apps/agent/test/capabilities.spec.ts +++ b/apps/agent/test/capabilities.spec.ts @@ -1,11 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { CONTEXT_DEV, - FULL_AGENTIC_CHECKLIST, - FULL_AGENTIC_ENV_VARS, capabilitiesFrom, - enabled, enableChecklistMarkdown, + enabled, + FULL_AGENTIC_CHECKLIST, + FULL_AGENTIC_ENV_VARS, markdownFor, unavailable, } from "../agent/lib/capabilities"; diff --git a/apps/agent/test/facts.integration.spec.ts b/apps/agent/test/facts.integration.spec.ts index 20af9d6b7..0b69dedee 100644 --- a/apps/agent/test/facts.integration.spec.ts +++ b/apps/agent/test/facts.integration.spec.ts @@ -291,7 +291,9 @@ describe("writeBrief", () => { }); expect(result.written).toBe(false); - expect(result.reason).toMatch(/Identity is not trustworthy|sourced well enough/); + expect(result.reason).toMatch( + /Identity is not trustworthy|sourced well enough/, + ); }); it("refuses meeting-attendance alone as garbage identity", async () => { @@ -307,4 +309,3 @@ describe("writeBrief", () => { expect(result.reason).toMatch(/Identity is not trustworthy/); }); }); - diff --git a/apps/agent/test/identity-verdict.spec.ts b/apps/agent/test/identity-verdict.spec.ts index 3ed381cf2..c198e2abe 100644 --- a/apps/agent/test/identity-verdict.spec.ts +++ b/apps/agent/test/identity-verdict.spec.ts @@ -34,7 +34,12 @@ const wrongCompany = { describe("identityChecks", () => { it("requires employer and name together", () => { expect( - identityChecks(tomi, "tokonkwo@northwind.com", "Northwind", "northwind.com"), + identityChecks( + tomi, + "tokonkwo@northwind.com", + "Northwind", + "northwind.com", + ), ).toEqual({ employerMatches: true, nameMatches: true, diff --git a/apps/agent/test/job-change.integration.spec.ts b/apps/agent/test/job-change.integration.spec.ts index 7ae15ca9c..9dd3c225a 100644 --- a/apps/agent/test/job-change.integration.spec.ts +++ b/apps/agent/test/job-change.integration.spec.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it } from "bun:test"; import { ActivityType, db } from "@crm/db"; import type { Evidence } from "../agent/lib/evidence"; -import { recordFact, lastEmployerChange } from "../agent/lib/facts"; +import { lastEmployerChange, recordFact } from "../agent/lib/facts"; import { raiseJobChange } from "../agent/lib/job-change"; const suffix = process.env.TEST_RUN_ID ?? "job-change-spec"; @@ -21,10 +21,7 @@ const seen = (kind: Evidence["kind"], detail = "observed"): Evidence => ({ async function cleanup() { await db.activity.deleteMany({ where: { - OR: [ - { contact: { email } }, - { createdBy: { email: ownerEmail } }, - ], + OR: [{ contact: { email } }, { createdBy: { email: ownerEmail } }], }, }); await db.contactFact.deleteMany({ where: { contact: { email } } }); diff --git a/apps/agent/test/keyless-brand.integration.spec.ts b/apps/agent/test/keyless-brand.integration.spec.ts index 3f0ad89ab..ebbbc2408 100644 --- a/apps/agent/test/keyless-brand.integration.spec.ts +++ b/apps/agent/test/keyless-brand.integration.spec.ts @@ -1,11 +1,4 @@ -import { - afterAll, - afterEach, - beforeAll, - describe, - expect, - it, -} from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"; import { db, EnrichmentStatus, type Prisma } from "@crm/db"; import { SETTINGS_ID } from "@crm/db/settings"; import { runBrand } from "../agent/lib/brand"; diff --git a/apps/agent/test/lifecycle-advance.spec.ts b/apps/agent/test/lifecycle-advance.spec.ts index 9ff76f635..34c478d23 100644 --- a/apps/agent/test/lifecycle-advance.spec.ts +++ b/apps/agent/test/lifecycle-advance.spec.ts @@ -31,9 +31,14 @@ describe("lifecycle advance specialist", () => { "SCHEDULE", ]); expect( - draft.triggers.map((trigger) => trigger.event).filter(Boolean).sort(), + draft.triggers + .map((trigger) => trigger.event) + .filter(Boolean) + .sort(), ).toEqual(["deal.opened", "deal.stage.changed"]); - const schedule = draft.triggers.find((trigger) => trigger.type === "SCHEDULE"); + const schedule = draft.triggers.find( + (trigger) => trigger.type === "SCHEDULE", + ); expect(schedule?.intervalMinutes).toBe(24 * 60); expect(schedule?.nextRunAt).toBe(FIXED_NOW); expect(draft.actions.map((action) => action.type).sort()).toEqual( diff --git a/apps/agent/test/lifecycle-close.spec.ts b/apps/agent/test/lifecycle-close.spec.ts index 69f24791c..e875149a9 100644 --- a/apps/agent/test/lifecycle-close.spec.ts +++ b/apps/agent/test/lifecycle-close.spec.ts @@ -42,9 +42,9 @@ describe("lifecycle close specialist", () => { "EVENT", "MANUAL", ]); - expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( - ["deal.closed"], - ); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean), + ).toEqual(["deal.closed"]); expect(draft.resources).toEqual([]); }); diff --git a/apps/agent/test/lifecycle-engage.spec.ts b/apps/agent/test/lifecycle-engage.spec.ts index df99b5f76..37bee79df 100644 --- a/apps/agent/test/lifecycle-engage.spec.ts +++ b/apps/agent/test/lifecycle-engage.spec.ts @@ -41,7 +41,10 @@ describe("lifecycle engage specialist", () => { "MANUAL", ]); expect( - draft.triggers.map((trigger) => trigger.event).filter(Boolean).sort(), + draft.triggers + .map((trigger) => trigger.event) + .filter(Boolean) + .sort(), ).toEqual(["deal.opened", "deal.stage.changed"]); }); diff --git a/apps/agent/test/lifecycle-qualify.spec.ts b/apps/agent/test/lifecycle-qualify.spec.ts index afb44a6d7..999ade1fa 100644 --- a/apps/agent/test/lifecycle-qualify.spec.ts +++ b/apps/agent/test/lifecycle-qualify.spec.ts @@ -27,9 +27,9 @@ describe("lifecycle qualify specialist", () => { "EVENT", "MANUAL", ]); - expect(draft.triggers.map((trigger) => trigger.event).filter(Boolean)).toEqual( - ["contact.created", "company.created"], - ); + expect( + draft.triggers.map((trigger) => trigger.event).filter(Boolean), + ).toEqual(["contact.created", "company.created"]); expect(draft.actions.map((action) => action.type).sort()).toEqual( [...QUALIFY_RECOMMEND_ONLY_ACTION_TYPES].sort(), ); @@ -66,9 +66,11 @@ describe("lifecycle qualify specialist", () => { expect(manifest.lifecycleRole).toBe("qualify"); expect(manifest.dataScope.mode).toBe("WORKSPACE"); - expect(manifest.actions.every((action) => isRecommendOnlyActionType(action.type))).toBe( - true, - ); + expect( + manifest.actions.every((action) => + isRecommendOnlyActionType(action.type), + ), + ).toBe(true); expect( manifest.actions.some( (action) => action.type === AGENT_ACTION_TYPES.CRM_ACTIVITY_CREATE, @@ -123,9 +125,7 @@ describe("lifecycle qualify specialist", () => { type: "EVENT" as const, name: trigger.name, summary: trigger.summary, - event: trigger.event as - | "contact.created" - | "company.created", + event: trigger.event as "contact.created" | "company.created", } : { type: "MANUAL" as const, @@ -148,8 +148,7 @@ describe("lifecycle qualify specialist", () => { type: trigger.type, name: trigger.name, summary: trigger.summary, - config: - trigger.type === "EVENT" ? { event: trigger.event } : {}, + config: trigger.type === "EVENT" ? { event: trigger.event } : {}, })), dataScope: { mode: draft.recordScope, diff --git a/apps/agent/test/meeting-prep-dispatch.spec.ts b/apps/agent/test/meeting-prep-dispatch.spec.ts index 24284c4c6..4b1f1f78f 100644 --- a/apps/agent/test/meeting-prep-dispatch.spec.ts +++ b/apps/agent/test/meeting-prep-dispatch.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; +import { PRIORITY } from "@crm/db/agent-tasks"; import { brief } from "../agent/lib/dispatch"; import type { LeasedTask } from "../agent/lib/tasks"; -import { PRIORITY } from "@crm/db/agent-tasks"; function task(kind: string, attempts = 1): LeasedTask { return { diff --git a/apps/api/src/agent/agent-observability.service.ts b/apps/api/src/agent/agent-observability.service.ts index ea3137789..d5729a27f 100644 --- a/apps/api/src/agent/agent-observability.service.ts +++ b/apps/api/src/agent/agent-observability.service.ts @@ -131,7 +131,9 @@ export class AgentObservabilityService { return { windowHours: WINDOW_HOURS, since: since.toISOString(), - runsByStatus: counts(statusRows.map((row) => [row.status, row._count._all])), + runsByStatus: counts( + statusRows.map((row) => [row.status, row._count._all]), + ), runsByTrigger: counts( triggerRows.map((row) => [row.triggerType, row._count._all]), ), diff --git a/apps/api/src/backfill/backfill.service.ts b/apps/api/src/backfill/backfill.service.ts index 4a63b5d74..c979ad34f 100644 --- a/apps/api/src/backfill/backfill.service.ts +++ b/apps/api/src/backfill/backfill.service.ts @@ -177,9 +177,7 @@ export class BackfillService implements OnModuleInit { private async runContacts(): Promise { const photosEnabled = blobEnabled(); - const needsPhoto = photosEnabled - ? await this.contactsNeedingPhoto() - : null; + const needsPhoto = photosEnabled ? await this.contactsNeedingPhoto() : null; const [photoTotal, photoRows] = needsPhoto ? await Promise.all([ diff --git a/apps/api/src/deals/deal-score.service.ts b/apps/api/src/deals/deal-score.service.ts index eb93d5f22..1df983146 100644 --- a/apps/api/src/deals/deal-score.service.ts +++ b/apps/api/src/deals/deal-score.service.ts @@ -1,11 +1,11 @@ import { onSignedIn } from "@crm/auth"; import type { Db } from "@crm/db"; -import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import { DEAL_SCORE, needsDealScore, scoreRescoreCutoff, } from "@crm/db/deal-score"; +import { OPEN_DEAL_STAGES } from "@crm/db/deal-stage"; import { CACHE_MANAGER } from "@nestjs/cache-manager"; import { Inject, Injectable, Logger, type OnModuleInit } from "@nestjs/common"; import type { Cache } from "cache-manager"; diff --git a/apps/api/test/agent-observability.spec.ts b/apps/api/test/agent-observability.spec.ts index be5cbc61c..e073cb597 100644 --- a/apps/api/test/agent-observability.spec.ts +++ b/apps/api/test/agent-observability.spec.ts @@ -52,9 +52,7 @@ beforeAll(async () => { instructions: "Report health only.", manifest: { lifecycleRole: "qualify", - actions: [ - { type: "run.summary", provider: "crm", summary: "done" }, - ], + actions: [{ type: "run.summary", provider: "crm", summary: "done" }], dataScope: { mode: "WORKSPACE", summary: "workspace", resources: [] }, }, modelId: "test/model", diff --git a/apps/api/test/calendar-sync.spec.ts b/apps/api/test/calendar-sync.spec.ts index 549ce3d47..276589583 100644 --- a/apps/api/test/calendar-sync.spec.ts +++ b/apps/api/test/calendar-sync.spec.ts @@ -114,13 +114,21 @@ async function clean() { await db.calendarAttendee.deleteMany({ where: { event: { - OR: [{ iCalUid }, { iCalUid: `${iCalUid}-other` }, { syncedByUserId: userId }], + OR: [ + { iCalUid }, + { iCalUid: `${iCalUid}-other` }, + { syncedByUserId: userId }, + ], }, }, }); await db.calendarEvent.deleteMany({ where: { - OR: [{ iCalUid }, { iCalUid: `${iCalUid}-other` }, { syncedByUserId: userId }], + OR: [ + { iCalUid }, + { iCalUid: `${iCalUid}-other` }, + { syncedByUserId: userId }, + ], }, }); await db.contact.deleteMany({ where: { email: person } }); @@ -208,9 +216,7 @@ describe("CalendarSyncService", () => { it("dedupes on (iCalUID, originalStartTime) when the same window re-runs", async () => { pages = [ { - items: [ - event({ summary: "Pricing review (updated)", location: "HQ" }), - ], + items: [event({ summary: "Pricing review (updated)", location: "HQ" })], nextSyncToken: "sync-2", }, ]; diff --git a/apps/api/test/gmail-sync.spec.ts b/apps/api/test/gmail-sync.spec.ts index bca5f297e..1667d6fcd 100644 --- a/apps/api/test/gmail-sync.spec.ts +++ b/apps/api/test/gmail-sync.spec.ts @@ -84,7 +84,11 @@ const gmail = { async getMessage(_token: string, id: string) { const data = messagesById.get(id); if (!data) { - return { outcome: "failed" as const, reason: "missing", retryable: false }; + return { + outcome: "failed" as const, + reason: "missing", + retryable: false, + }; } return { outcome: "ok" as const, data }; }, @@ -115,7 +119,12 @@ function gmailMessage(options: { }): GmailMessage { const headers = [ { name: "Message-ID", value: options.rfcMessageId }, - { name: "From", value: options.fromName ? `${options.fromName} <${options.from}>` : options.from }, + { + name: "From", + value: options.fromName + ? `${options.fromName} <${options.from}>` + : options.from, + }, { name: "To", value: options.to }, { name: "Subject", value: options.subject }, { name: "Date", value: options.sentAt }, diff --git a/apps/api/test/mailbox-auto-create.spec.ts b/apps/api/test/mailbox-auto-create.spec.ts index 3791f94f4..595724b1c 100644 --- a/apps/api/test/mailbox-auto-create.spec.ts +++ b/apps/api/test/mailbox-auto-create.spec.ts @@ -103,12 +103,7 @@ async function clean() { await db.emailThread.deleteMany({ where: { rootMessageId: { - in: [ - rootNewsletter, - rootOutbound, - rootInboundReply, - rootKnown, - ], + in: [rootNewsletter, rootOutbound, rootInboundReply, rootKnown], }, }, }); diff --git a/packages/db/src/deal-score.ts b/packages/db/src/deal-score.ts index 4cc257a05..f2dce7d7a 100644 --- a/packages/db/src/deal-score.ts +++ b/packages/db/src/deal-score.ts @@ -42,9 +42,7 @@ export function scoreRescoreCutoff( now: Date, rescoreAfterDays: number = DEAL_SCORE.rescoreAfterDays, ): Date { - return new Date( - now.getTime() - Math.max(rescoreAfterDays, 0) * DAY_MS, - ); + return new Date(now.getTime() - Math.max(rescoreAfterDays, 0) * DAY_MS); } export function needsDealScore(input: { diff --git a/packages/db/test/deal-score.spec.ts b/packages/db/test/deal-score.spec.ts index e75608a67..44b9067f9 100644 --- a/packages/db/test/deal-score.spec.ts +++ b/packages/db/test/deal-score.spec.ts @@ -47,7 +47,9 @@ describe("deal score helpers", () => { }), ).toBe(true); expect(scoreRescoreCutoff(now).toISOString()).toBe( - new Date(now.getTime() - DEAL_SCORE.rescoreAfterDays * 86_400_000).toISOString(), + new Date( + now.getTime() - DEAL_SCORE.rescoreAfterDays * 86_400_000, + ).toISOString(), ); }); }); From ae843345086dda72cb6a03491d136c99b1b6ebd6 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 02:55:06 -0400 Subject: [PATCH 21/22] style: format team-agents-index cost stat --- apps/app/components/agent-builder/team-agents-index.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/app/components/agent-builder/team-agents-index.tsx b/apps/app/components/agent-builder/team-agents-index.tsx index a34abe3b4..af7e01e48 100644 --- a/apps/app/components/agent-builder/team-agents-index.tsx +++ b/apps/app/components/agent-builder/team-agents-index.tsx @@ -132,10 +132,7 @@ function FleetHealth({ fleet }: { fleet: Fleet }) { label="Output tokens" value={fleet.consumption.outputTokens.toLocaleString("en-US")} /> - +
{Object.keys(fleet.runsByLifecycleRole).length > 0 ? (
From de9ad1ab6011443d61c02d52c0008171996676de Mon Sep 17 00:00:00 2001 From: David Date: Wed, 12 Aug 2026 02:56:02 -0400 Subject: [PATCH 22/22] fix(telemetry): allowlist write_deal_intelligence tool --- packages/telemetry/src/allowlist.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/telemetry/src/allowlist.ts b/packages/telemetry/src/allowlist.ts index 19d4b4c12..eafe12c1f 100644 --- a/packages/telemetry/src/allowlist.ts +++ b/packages/telemetry/src/allowlist.ts @@ -151,6 +151,7 @@ export const AGENT_TOOLS = [ "set_contact_socials", "set_field_value", "write_brief", + "write_deal_intelligence", "write_workspace_profile", ] as const;