crmCRM
§ 00 · Source-level Observatory

crm agentic-first CRM

trycompai/crm 把 CRM 设计成 Agent 的工作台:API 只入队事实,Agent 按预算研究和记录,前端 Agent Tab 负责把可追踪会话交给销售代表。

★ 3,379
GitHub Stars
100K
LOC estimate
815
Typed files
Aug 3
Last commit
API event → AgentTask → dispatcher lanes → eve session ↓ ↓ evidence ledger ← tools ← deny-all sandbox ↓ CRM fact / brief / conversation
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

这个项目最有意思的地方是产品定义倒置:CRM 不是给销售代表手填字段的数据库,而是 Agent 用来保存观察、建议和后续动作的系统。packages/db/prisma/schema.prisma:312 的 AgentTask、packages/db/prisma/schema.prisma:339 的 AgentEvent 和 packages/db/prisma/schema.prisma:353 的 AgentConversation 把“后台研究”和“前台对话”都变成一等数据。

源码层面它分得很清楚:apps/api/src/agent/agent-trigger.service.ts:146 负责把业务事件写成任务,apps/agent/agent/lib/tasks.ts:28 负责可并发领取,apps/agent/agent/lib/dispatch.ts:132 把直接任务和研究任务合并成一次 drain,apps/agent/agent/tools/record_fact.ts:8 则强制 Agent 带证据写事实。这个边界让它比 README 里的“agentic-first”更可信。

▸核心数据

架构模式
Bun/Turborepo monorepo,Next.js 前端 + NestJS/tRPC API + Eve durable agent + Prisma/Postgres。
核心抽象
AgentTask 是工作入口,Evidence/FactBand 是事实门禁,AgentConversation 是用户可回看的会话索引。
性能/可靠性
数据库租约、优先级、最大重试、过期回收和 fire-and-forget poke 避免把在线请求绑死在 Agent 可用性上。
下游依赖
Eve、Vercel Sandbox、Vercel AI Gateway、Prisma、Better Auth、tRPC、Context.dev、Google sync。

源码证据 1: apps/agent/agent/lib/tasks.ts:28 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 2: apps/agent/agent/lib/tasks.ts:41 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 3: apps/agent/agent/lib/tasks.ts:55 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 4: apps/agent/agent/lib/tasks.ts:62 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 5: apps/agent/agent/lib/tasks.ts:67 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 6: apps/agent/agent/lib/tasks.ts:81 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 7: apps/agent/agent/lib/tasks.ts:120 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 8: apps/agent/agent/lib/tasks.ts:129 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 9: apps/agent/agent/lib/dispatch.ts:17 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 10: apps/agent/agent/lib/dispatch.ts:42 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 11: apps/agent/agent/lib/dispatch.ts:61 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 12: apps/agent/agent/lib/dispatch.ts:93 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 13: apps/agent/agent/lib/dispatch.ts:119 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 14: apps/agent/agent/lib/dispatch.ts:132 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 15: apps/agent/agent/schedules/dispatch.ts:5 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 16: apps/agent/agent/channels/crm.ts:31 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 17: apps/agent/agent/channels/crm.ts:73 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 18: apps/agent/agent/sandbox/sandbox.ts:3 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 19: apps/api/src/agent/agent-trigger.service.ts:15 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 20: apps/api/src/agent/agent-trigger.service.ts:83 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 21: apps/api/src/agent/agent-trigger.service.ts:146 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 22: apps/api/src/agent/agent-trigger.service.ts:195 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 23: apps/agent/agent/tools/record_fact.ts:8 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 24: apps/agent/agent/tools/record_fact.ts:47 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 25: apps/agent/agent/lib/evidence.ts:22 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 26: apps/agent/agent/lib/evidence.ts:99 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 27: apps/agent/agent/lib/facts.ts:41 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 28: apps/agent/agent/lib/facts.ts:137 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 29: apps/agent/agent/lib/facts.ts:140 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 30: apps/agent/agent/tools/schedule_recheck.ts:9 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 31: apps/agent/agent/tools/schedule_recheck.ts:36 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 32: apps/agent/agent/instructions/task.ts:5 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 33: apps/agent/agent/instructions/task.ts:14 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 34: apps/agent/agent/lib/preamble.ts:18 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 35: apps/agent/agent/lib/preamble.ts:45 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 36: apps/agent/agent/lib/preamble.ts:62 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 37: apps/app/lib/agent-bridge.ts:12 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 38: apps/app/lib/agent-bridge.ts:23 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 39: apps/app/lib/agent-bridge.ts:44 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 40: apps/app/components/crm/agent-panel.tsx:79 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 41: apps/app/components/crm/agent-panel.tsx:137 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 42: apps/app/components/crm/agent-panel.tsx:188 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 43: apps/app/components/crm/agent-panel.tsx:212 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 44: apps/app/components/crm/agent-panel.tsx:433 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 45: apps/app/components/crm/agent-panel.tsx:471 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 46: packages/db/src/agent-tasks.ts:1 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 47: packages/db/src/agent-tasks.ts:22 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 48: packages/db/prisma/schema.prisma:312 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 49: packages/db/prisma/schema.prisma:339 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 50: packages/db/prisma/schema.prisma:353 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 51: apps/agent/test/tasks.integration.spec.ts:57 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 52: apps/agent/test/tasks.integration.spec.ts:69 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 53: apps/agent/test/tasks.integration.spec.ts:111 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 54: apps/agent/test/evidence.spec.ts:12 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

源码证据 55: apps/app/test/agent-bridge.spec.ts:26 这一处说明 CRM 的 Agent 不是只挂在页面旁边的聊天框,而是由任务、会话、证据、桥接和前端持久化共同组成的交付链路;读这个项目时要沿着这些引用看状态怎样从数据库进入 Agent,再回到 CRM 记录。

§ 01 · QUEUE

任务队列先行,API 不放智能

任务领取入口在 apps/agent/agent/lib/tasks.ts:28,它先计算租约截止时间,再把 kind 过滤条件拼成 Prisma SQL。真正关键是 apps/agent/agent/lib/tasks.ts:41 到 apps/agent/agent/lib/tasks.ts:55:UPDATE 子查询用 dueAt、leasedUntil、attempts、priority 和 SKIP LOCKED 一次性完成领取,这比应用层先 SELECT 再 UPDATE 更抗并发。

apps/agent/agent/lib/tasks.ts · apps/agent/agent/lib/tasks.ts:28-65TypeScript
28export async function claimDue(
29	limit: number,
30	kinds: { only: readonly string[] } | { except: readonly string[] },
31	leaseMs = LEASE_MS,
32): Promise<LeasedTask[]> {
33	const now = new Date();
34	const until = new Date(now.getTime() + leaseMs);
35
36	const list = "only" in kinds ? kinds.only : kinds.except;
37	if ("only" in kinds && list.length === 0) return [];
38
39	const match = Prisma.sql`t2.kind ${"only" in kinds ? Prisma.sql`IN` : Prisma.sql`NOT IN`} (${Prisma.join(list)})`;
40
41	const claimed = await db.$queryRaw<LeasedTask[]>`
42		UPDATE "agentTask" AS t
43		SET "leasedUntil" = ${until},
44			"startedAt" = COALESCE(t."startedAt", ${now}),
45			"attempts" = t."attempts" + 1
46		FROM (
47			SELECT t2.id FROM "agentTask" AS t2
48			WHERE t2."finishedAt" IS NULL
49				AND t2."dueAt" <= ${now}
50				AND (t2."leasedUntil" IS NULL OR t2."leasedUntil" < ${now})
51				AND t2."attempts" < ${MAX_ATTEMPTS}
52				AND ${match}
53			ORDER BY t2."priority" DESC, t2."dueAt" ASC
54			LIMIT ${limit}
55			FOR UPDATE SKIP LOCKED
56		) AS due
57		WHERE t.id = due.id
58		RETURNING t.id, t."contactId", t."companyId", t.kind, t.reason,
59			t.budget, t.attempts, t.priority, t."dueAt";
60	`;
61
62	return claimed.sort(
63		(a, b) => b.priority - a.priority || a.dueAt.getTime() - b.dueAt.getTime(),
64	);
65}

apps/agent/agent/lib/tasks.ts:67 到 apps/agent/agent/lib/tasks.ts:78 说明失败不是无穷重试;超过 apps/agent/agent/lib/tasks.ts:24 定义的 MAX_ATTEMPTS 后,任务会被写入 outcome 并完成。这个设计让 Agent session 掉线后还有可解释终态。

apps/agent/agent/lib/tasks.ts · apps/agent/agent/lib/tasks.ts:67-100TypeScript
67export async function retireExhausted(): Promise<TaskSubject[]> {
68	const now = new Date();
69
70	return db.$queryRaw<TaskSubject[]>`
71		UPDATE "agentTask" AS t
72		SET "finishedAt" = ${now},
73			"outcome" = ${`Gave up after ${MAX_ATTEMPTS} attempts: the session never reported back.`}
74		WHERE t."finishedAt" IS NULL
75			AND t."attempts" >= ${MAX_ATTEMPTS}
76			AND (t."leasedUntil" IS NULL OR t."leasedUntil" < ${now})
77		RETURNING t.id, t."contactId", t."companyId", t.kind;
78	`;
79}
80
81export async function completeTask(
82	taskId: string,
83	outcome: string,
84	sessionId?: string,
85): Promise<TaskSubject | null> {
86	const { count } = await db.agentTask.updateMany({
87		where: { id: taskId, finishedAt: null },
88		data: {
89			finishedAt: new Date(),
90			outcome: outcome.slice(0, 500),
91			...(sessionId ? { sessionId } : {}),
92		},
93	});
94
95	if (count === 0) return null;
96
97	return db.agentTask.findUnique({
98		where: { id: taskId },
99		select: { id: true, contactId: true, companyId: true, kind: true },
100	});
深度阅读 · 队列租约与调度分流
§ 02 · RUNTIME

两条 lane:快任务本地跑,研究任务进会话

apps/agent/agent/lib/dispatch.ts:42 处理 brand 和 portrait 这类可见性任务,apps/agent/agent/lib/dispatch.ts:93 则把 identify/profile/recheck 等研究型任务交给 Eve session。packages/db/src/agent-tasks.ts:14 的 DIRECT_KINDS 是这条分界线。

apps/agent/agent/lib/dispatch.ts · apps/agent/agent/lib/dispatch.ts:42-90TypeScript
42export async function runVisibleLane(): Promise<number> {
43	let handled = 0;
44
45	while (handled < VISIBLE_BATCH) {
46		const tasks = await claimDue(
47			Math.min(VISIBLE_CONCURRENCY, VISIBLE_BATCH - handled),
48			{ only: DIRECT_KINDS },
49			VISIBLE_LEASE_MS,
50		);
51
52		if (tasks.length === 0) break;
53
54		await runLimited(VISIBLE_CONCURRENCY, tasks, runDirect);
55		handled += tasks.length;
56	}
57
58	return handled;
59}
60
61async function runDirect(task: LeasedTask): Promise<void> {
62	try {
63		if (task.kind === "brand" && task.companyId) {
64			const result = await runBrand({ companyId: task.companyId });
65			if (result.retryable) return;
66
67			await completeTask(task.id, brandOutcome(result));
68			return;
69		}
70
71		if (task.kind === "portrait" && task.contactId) {
72			const portrait = await runPortrait({
73				contactId: task.contactId,
74				spend: () => ({ ok: true }),
75			});
76
77			await completeTask(
78				task.id,
79				portrait.stored
80					? `Picture stored from ${portrait.source}.`
81					: (portrait.reason ?? "No picture found."),
82			);
83			return;
84		}
85
86		await completeTask(task.id, "The record this names is gone.");
87	} catch (error) {
88		const reason = error instanceof Error ? error.message : String(error);
89		await settle(task, EnrichmentStatus.FAILED, reason).catch(() => {});
90	}

研究 lane 的启动很克制:apps/agent/agent/lib/dispatch.ts:103 并行启动 session,apps/agent/agent/lib/dispatch.ts:106 先 markRunning,apps/agent/agent/lib/dispatch.ts:108 保存 session id。schedule 只在 apps/agent/agent/schedules/dispatch.ts:5 每分钟触发 drain,并通过 apps/agent/agent/schedules/dispatch.ts:13 传入任务认证属性。

apps/agent/agent/schedules/dispatch.ts · apps/agent/agent/schedules/dispatch.ts:5-17TypeScript
5export default defineSchedule({
6	cron: "* * * * *",
7	async run({ receive, waitUntil, appAuth }) {
8		waitUntil(
9			drainAll((task) =>
10				receive(crm, {
11					message: brief(task),
12					target: { taskId: task.id },
13					auth: taskAuth(task, appAuth),
14				}),
15			),
16		);
17	},

安全边界来自 apps/agent/agent/sandbox/sandbox.ts:3,三个 backend 全部设置 deny-all egress。结合 apps/agent/agent/instructions/task.ts:14 的动态 preamble,Agent 能拿到 CRM 上下文,但 shell 不该拿到数据库和网络。

apps/agent/agent/sandbox/sandbox.ts · apps/agent/agent/sandbox/sandbox.ts:1-9TypeScript
1import { defaultBackend, defineSandbox } from "eve/sandbox";
2
3export default defineSandbox({
4	backend: defaultBackend({
5		vercel: { networkPolicy: "deny-all" },
6		docker: { networkPolicy: "deny-all" },
7		microsandbox: { networkPolicy: "deny-all" },
8	}),
9});
深度阅读 · 证据账本与前端桥接
§ 03 · EVIDENCE

事实不是生成的,是带证据结算的

apps/agent/agent/tools/record_fact.ts:11 的 schema 要求 contactId、field、value、evidence、method 和 sourceUrl。真正写入在 apps/agent/agent/lib/facts.ts:140 的 transaction 中完成,低分证据会停在 PROPOSED,只有 apps/agent/agent/lib/facts.ts:137 判定 VERIFIED 才会改主 contact 字段。

apps/agent/agent/tools/record_fact.ts · apps/agent/agent/tools/record_fact.ts:8-67TypeScript
8export default defineTool({
9	description:
10		"Record one claim about a contact — title, employer, a profile URL, seniority — together with the evidence for it. The evidence decides whether it is written to the record or offered to a rep as a suggestion. Never invent evidence you did not observe.",
11	inputSchema: z.object({
12		contactId: z.string(),
13		field: z
14			.enum(FACT_FIELDS as [FactField, ...FactField[]])
15			.describe("Which fact about them this is."),
16		value: z
17			.string()
18			.describe("The claim itself, exactly as the source states it."),
19		evidence: z
20			.array(
21				z.object({
22					kind: z
23						.enum(Object.keys(WEIGHTS) as [EvidenceKind, ...EvidenceKind[]])
24						.describe(
25							"What kind of thing you saw. Use `contradiction` when two sources disagree.",
26						),
27					detail: z
28						.string()
29						.describe(
30							"What it actually said, in one line a rep would understand.",
31						),
32					sourceUrl: z.string().optional(),
33				}),
34			)
35			.min(1)
36			.describe("Everything you observed. One entry per independent source."),
37		method: z
38			.string()
39			.describe(
40				'Where it came from: "linkedin.profile", "github.api", "crm.thread", "web".',
41			),
42		sourceUrl: z
43			.string()
44			.optional()
45			.describe("The page a rep should open to check."),
46	}),
47	async execute(input) {
48		focusOn({ contactId: input.contactId });
49
50		const result = await recordFact({
51			contactId: input.contactId,
52			field: input.field,
53			value: input.value,
54			evidence: input.evidence as Evidence[],
55			method: input.method,
56			sourceUrl: input.sourceUrl,
57		});
58
59		return {
60			stored: result.stored,
61			applied: result.applied,
62			band: result.band,
63			score: Number(result.score.toFixed(2)),
64			rationale: result.rationale,
65			...(result.reason ? { reason: result.reason } : {}),
66		};
67	},

权重表在 apps/agent/agent/lib/evidence.ts:22,自己邮箱、LinkedIn 名称+雇主、GitHub identity 等是 primary evidence;apps/agent/agent/lib/evidence.ts:128 规定没有 primary 不能 VERIFIED。这个约束直接回应 CRM 场景里“错客户事实比空字段更糟”的产品风险。

apps/agent/agent/lib/evidence.ts · apps/agent/agent/lib/evidence.ts:22-132TypeScript
22export const WEIGHTS: Record<EvidenceKind, Weighting> = {
23	"profile.email-match": {
24		weight: 0.95,
25		primary: true,
26		label: "their email address is on the profile",
27	},
28	"linkedin.employer-and-name": {
29		weight: 0.85,
30		primary: true,
31		label: "LinkedIn: employer and name both match",
32	},
33	"crm.thread-reply": {
34		weight: 0.85,
35		primary: true,
36		label: "they replied on a thread we have",
37	},
38	"crm.signature-block": {
39		weight: 0.8,
40		primary: true,
41		label: "their own email signature says so",
42	},
43	"github.account-identity": {
44		weight: 0.8,
45		primary: true,
46		label: "the GitHub account names them or their employer",
47	},
48	"crm.meeting-attendance": {
49		weight: 0.7,
50		primary: true,
51		label: "they attended a meeting on our calendar",
52	},
53	"web.cited-claim": {
54		weight: 0.4,
55		primary: false,
56		label: "a cited web source states it",
57	},
58	"handle.name-form": {
59		weight: 0.35,
60		primary: false,
61		label: "the handle is a form of their name",
62	},
63	"search.cites-profile": {
64		weight: 0.35,
65		primary: false,
66		label: "a search for them cites this profile",
67	},
68	"employer-only": {
69		weight: 0.2,
70		primary: false,
71		label: "the employer matches, the name does not",
72	},
73	contradiction: {
74		weight: 0,
75		primary: false,
76		label: "another source disagrees",
77	},
78};
79
80export type Evidence = {
81	kind: EvidenceKind;
82	detail: string;
83	sourceUrl?: string;
84};
85
86export type Scored = {
87	score: number;
88	band: FactBand | null;
89	hasPrimary: boolean;
90	rationale: string;
91};
92
93const CEILING = 0.99;
94
95const CONTRADICTED = 0.45;
96
97export const BAND_FLOOR = { VERIFIED: 0.85, PROBABLE: 0.55, POSSIBLE: 0.3 };
98
99export function scoreEvidence(evidence: Evidence[]): Scored {
100	if (evidence.length === 0) {
101		return {
102			score: 0,
103			band: null,
104			hasPrimary: false,
105			rationale: "No evidence.",
106		};
107	}
108
109	const contradicted = evidence.some((item) => item.kind === "contradiction");
110	const hasPrimary = evidence.some((item) => WEIGHTS[item.kind].primary);
111
112	const combined = evidence.reduce(
113		(remaining, item) => remaining * (1 - WEIGHTS[item.kind].weight),
114		1,
115	);
116
117	let score = Math.min(CEILING, 1 - combined);
118	if (contradicted) score = Math.min(score, CONTRADICTED);
119
120	return {
121		score,
122		band: bandFor(score, hasPrimary),
123		hasPrimary,
124		rationale: rationaleFor(evidence, contradicted, hasPrimary),
125	};
126}
127
128export function bandFor(score: number, hasPrimary: boolean): FactBand | null {
129	if (score >= BAND_FLOOR.VERIFIED && hasPrimary) return FactBand.VERIFIED;
130	if (score >= BAND_FLOOR.PROBABLE) return FactBand.PROBABLE;
131	if (score >= BAND_FLOOR.POSSIBLE) return FactBand.POSSIBLE;
132	return null;
§ 04 · BRIDGE

前端 Agent Tab 不是装饰,它保存会话状态

apps/app/lib/agent-bridge.ts:12 通过 HMAC JWT 给 Eve Agent 链接当前用户和 record,apps/app/components/crm/agent-panel.tsx:188 用 useEveAgent 打开或恢复 session。用户提问在 apps/app/components/crm/agent-panel.tsx:212 进入 agent.send,pending question 在 apps/app/components/crm/agent-panel.tsx:433 渲染成可点击选项。

apps/app/lib/agent-bridge.ts · apps/app/lib/agent-bridge.ts:12-59TypeScript
12export async function mintBridgeToken(
13	user: {
14		id: string;
15		email: string;
16		name: string;
17	},
18	record: { contactId?: string; companyId?: string; dealId?: string } = {},
19): Promise<string> {
20	const secret = process.env.AGENT_BRIDGE_SECRET;
21	if (!secret) throw new Error("AGENT_BRIDGE_SECRET is not set.");
22
23	const now = Math.floor(Date.now() / 1000);
24
25	const header = { alg: "HS256", typ: "JWT" };
26	const payload = {
27		iss: ISSUER,
28		aud: AUDIENCE,
29		sub: user.id,
30		email: user.email,
31		name: user.name,
32		...(record.contactId ? { contactId: record.contactId } : {}),
33		...(record.companyId ? { companyId: record.companyId } : {}),
34		...(record.dealId ? { dealId: record.dealId } : {}),
35		iat: now,
36		nbf: now - 5,
37		exp: now + TTL_SECONDS,
38	};
39
40	const signingInput = `${base64url(JSON.stringify(header))}.${base64url(
41		JSON.stringify(payload),
42	)}`;
43
44	const key = await crypto.subtle.importKey(
45		"raw",
46		new TextEncoder().encode(secret),
47		{ name: "HMAC", hash: "SHA-256" },
48		false,
49		["sign"],
50	);
51
52	const signature = await crypto.subtle.sign(
53		"HMAC",
54		key,
55		new TextEncoder().encode(signingInput),
56	);
57
58	return `${signingInput}.${base64url(new Uint8Array(signature))}`;
59}

持久化不是靠浏览器内存。apps/app/components/crm/agent-panel.tsx:471 的 useSavedConversation 监听 sessionId、continuationToken、streamIndex 和 messageCount,apps/app/components/crm/agent-panel.tsx:518 将它们写回 conversations router,所以刷新页面后还可以从 packages/db/prisma/schema.prisma:353 的 AgentConversation 找回线程。

apps/app/components/crm/agent-panel.tsx · apps/app/components/crm/agent-panel.tsx:471-548TypeScript
471function useSavedConversation({
472	record,
473	conversation,
474	opening,
475	session,
476	messages,
477}: {
478	record: { contactId?: string; companyId?: string; dealId?: string };
479	conversation: Conversation | null;
480	opening: React.RefObject<string | null>;
481	session: {
482		sessionId?: string;
483		continuationToken?: string;
484		streamIndex: number;
485	} | null;
486	messages: number;
487}) {
488	const trpc = useTRPC();
489	const queryClient = useQueryClient();
490	const save = useMutation(trpc.conversations.save.mutationOptions({}));
491
492	const sessionId = session?.sessionId ?? null;
493	const token = session?.continuationToken ?? null;
494	const streamIndex = session?.streamIndex ?? 0;
495	const { contactId, companyId, dealId } = record;
496
497	const isNew = conversation === null || conversation.sessionId !== sessionId;
498
499	const latest = useRef({ save, queryClient, trpc, opening });
500	latest.current = { save, queryClient, trpc, opening };
501
502	const written = useRef<string | null>(null);
503
504	useEffect(() => {
505		if (!sessionId) return;
506
507		const cursor = `${sessionId}:${token ?? ""}:${messages}`;
508		if (written.current === cursor) return;
509		written.current = cursor;
510
511		const {
512			save: mutation,
513			queryClient: cache,
514			trpc: api,
515			opening: title,
516		} = latest.current;
517
518		mutation.mutate(
519			{
520				...(contactId ? { contactId } : {}),
521				...(companyId ? { companyId } : {}),
522				...(dealId ? { dealId } : {}),
523				sessionId,
524				continuationToken: token,
525				streamIndex,
526				messageCount: messages,
527				...(isNew ? { title: title.current ?? undefined } : {}),
528			},
529			{
530				onSuccess: () => {
531					if (!isNew) return;
532					void cache.invalidateQueries({
533						queryKey: api.conversations.list.pathKey(),
534					});
535				},
536			},
537		);
538	}, [
539		sessionId,
540		token,
541		streamIndex,
542		messages,
543		contactId,
544		companyId,
545		dealId,
546		isNew,
547	]);
548}
§ 05 · TESTS

测试覆盖验证的是 Agent 的失败边界

apps/agent/test/tasks.integration.spec.ts:69 验证两个 dispatcher 不会拿到同一行,apps/agent/test/tasks.integration.spec.ts:111 验证达到最大尝试次数后停止领取,apps/agent/test/tasks.integration.spec.ts:199 验证 scheduleTask 会移动已有预订而不是重复排队。

apps/agent/test/tasks.integration.spec.ts · apps/agent/test/tasks.integration.spec.ts:57-143TypeScript
57describe("claimDue", () => {
58	it("claims due work and leases it", async () => {
59		const task = await queue();
60
61		const claimed = await claimDue(10, RESEARCH);
62		expect(claimed.map((t) => t.id)).toContain(task.id);
63
64		const row = await db.agentTask.findUnique({ where: { id: task.id } });
65		expect(row?.leasedUntil).not.toBeNull();
66		expect(row?.startedAt).not.toBeNull();
67	});
68
69	it("does not hand the same row to two dispatchers", async () => {
70		await Promise.all([queue(), queue(), queue()]);
71
72		const [first, second] = await Promise.all([
73			claimDue(3, RESEARCH),
74			claimDue(3, RESEARCH),
75		]);
76		const ids = [...first, ...second].map((t) => t.id);
77
78		expect(new Set(ids).size).toBe(ids.length);
79		expect(ids).toHaveLength(3);
80	});
81
82	it("leaves work that is not due yet", async () => {
83		await queue({ dueAt: new Date(Date.now() + 60_000) });
84		const claimed = await claimDue(10, RESEARCH);
85		expect(claimed).toHaveLength(0);
86	});
87
88	it("takes the most urgent first", async () => {
89		const low = await queue({ priority: 0 });
90		const high = await queue({ priority: 100 });
91
92		const claimed = await claimDue(1, RESEARCH);
93		expect(claimed[0]?.id).toBe(high.id);
94		expect(claimed[0]?.id).not.toBe(low.id);
95	});
96
97	it("does not re-claim a leased row, and does re-claim an expired one", async () => {
98		const task = await queue();
99		await claimDue(10, RESEARCH);
100
101		expect(await claimDue(10, RESEARCH)).toHaveLength(0);
102
103		await db.agentTask.update({
104			where: { id: task.id },
105			data: { leasedUntil: new Date(Date.now() - 1000) },
106		});
107
108		expect((await claimDue(10, RESEARCH)).map((t) => t.id)).toContain(task.id);
109	});
110
111	it("stops handing out a row that has spent its attempts", async () => {
112		const task = await queue();
113
114		for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
115			expect((await claimDue(10, RESEARCH)).map((t) => t.id)).toContain(
116				task.id,
117			);
118			await expire(task.id);
119		}
120
121		expect(await claimDue(10, RESEARCH)).toHaveLength(0);
122	});
123
124	it("counts the attempts it has handed out", async () => {
125		const task = await queue();
126
127		expect((await claimDue(10, RESEARCH))[0]?.attempts).toBe(1);
128		await expire(task.id);
129		expect((await claimDue(10, RESEARCH))[0]?.attempts).toBe(2);
130	});
131
132	it("stops claiming once the work is finished", async () => {
133		const task = await queue();
134		await claimDue(10, RESEARCH);
135		await completeTask(task.id, "ran");
136
137		await db.agentTask.update({
138			where: { id: task.id },
139			data: { leasedUntil: null },
140		});
141
142		expect(await claimDue(10, RESEARCH)).toHaveLength(0);
143	});

apps/agent/test/evidence.spec.ts:12 覆盖 evidence score,apps/app/test/agent-bridge.spec.ts:26 覆盖 HMAC token 的 issuer/audience/expiry。也就是说,测试不是只看 UI 能不能打开,而是卡住了 Agent 系统最容易出事故的几条边界。

apps/agent/test/evidence.spec.ts · apps/agent/test/evidence.spec.ts:12-93TypeScript
12describe("scoreEvidence", () => {
13	it("writes a fact when a primary source names them", () => {
14		const scored = scoreEvidence(of("profile.email-match"));
15		expect(scored.band).toBe("VERIFIED");
16		expect(scored.hasPrimary).toBe(true);
17	});
18
19	it("treats our own mailbox as primary evidence", () => {
20		expect(scoreEvidence(of("crm.thread-reply")).band).toBe("VERIFIED");
21	});
22
23	it("writes a title a signature block states on a thread they replied to", () => {
24		expect(scoreEvidence(of("crm.signature-block")).band).toBe("PROBABLE");
25		expect(
26			scoreEvidence(of("crm.thread-reply", "crm.signature-block")).band,
27		).toBe("VERIFIED");
28	});
29
30	it("refuses to write anything without a primary source, however much of it there is", () => {
31		const scored = scoreEvidence(
32			of(
33				"handle.name-form",
34				"search.cites-profile",
35				"web.cited-claim",
36				"employer-only",
37			),
38		);
39
40		expect(scored.score).toBeGreaterThan(BAND_FLOOR.PROBABLE);
41		expect(scored.hasPrimary).toBe(false);
42		expect(scored.band).toBe("PROBABLE");
43	});
44
45	it("holds the primary-source rule even if the weights are re-calibrated upwards", () => {
46		expect(bandFor(0.99, false)).toBe("PROBABLE");
47		expect(bandFor(0.85, true)).toBe("VERIFIED");
48	});
49
50	it("makes an unreadable X handle a suggestion, not a value", () => {
51		const scored = scoreEvidence(
52			of("handle.name-form", "search.cites-profile"),
53		);
54		expect(scored.band).toBe("PROBABLE");
55	});
56
57	it("holds a fact when sources disagree, rather than averaging them", () => {
58		const scored = scoreEvidence([
59			...of("linkedin.employer-and-name"),
60			{
61				kind: "contradiction",
62				detail: "their signature says a different employer",
63			},
64		]);
65
66		expect(scored.band).toBe("POSSIBLE");
67		expect(scored.rationale).toContain("Held:");
68	});
69
70	it("drops evidence too weak to keep at all", () => {
71		expect(scoreEvidence(of("employer-only")).band).toBeNull();
72		expect(scoreEvidence([]).band).toBeNull();
73	});
74
75	it("never claims certainty", () => {
76		const everything = scoreEvidence(
77			of(
78				"profile.email-match",
79				"linkedin.employer-and-name",
80				"crm.thread-reply",
81				"github.account-identity",
82			),
83		);
84		expect(everything.score).toBeLessThan(1);
85	});
86
87	it("explains itself in words a rep could read", () => {
88		const scored = scoreEvidence(
89			of("github.account-identity", "handle.name-form"),
90		);
91		expect(scored.rationale).toContain("GitHub account");
92		expect(scored.rationale).toContain("handle");
93	});
§ 06 · RISKS

技术亮点之外,别忽略这些

第一,apps/api/src/agent/agent-trigger.service.ts:195 的 poke 是 fire-and-forget,产品上合理,但运维必须能区分“poke 没打到”和“cron 没跑”。第二,apps/agent/agent/sandbox/sandbox.ts:5 的 deny-all 很好,但所有真正外部访问都转移到工具和 provider 侧,需要审计工具实现。

第三,apps/agent/agent/lib/evidence.ts:97 的阈值是业务判断,不是数学真理;上线后应该用 dismissed/proposed/applied 的反馈重新校准。第四,apps/app/lib/agent-bridge.ts:4 的 120 秒 token TTL 对安全友好,但长会话恢复和多标签行为要继续观察。

▸队列契约落在共享包

主线实现虽然在 Agent app 里,持久契约要回到 packages/db/src/agent-tasks.ts:1 看类型入口;packages/db/src/agent-tasks.ts:14 把 direct lane 的 kind 列出来,packages/db/src/agent-tasks.ts:22 再把队列 subject 约束为 company、contact 或 workspace。这个共享包是 API 入队、调度器领取和测试断言共同依赖的最小事实层。

▸数据库客户端是系统边界

packages/db/src/client.ts:55 暴露的 prisma 客户端让 API、Agent 与前端后台逻辑共享同一条访问路径;分析 crm 时不能只看会话代码,因为 queue claim、fact transaction 和 conversation persistence 都要通过这层客户端进入数据库。

▸JSON 类型避免证据漂移

packages/db/src/json.ts:1 到 packages/db/src/json.ts:13 定义了可复用 JSON 值类型,适合承载 evidence、metadata、outcome 这类跨模块字段。它让 Agent 写入的结构化证据不会退化成任意字符串,也方便后续做审核、回放和 UI 渲染。

▸设置读取影响运行时行为

packages/db/src/settings.ts:16 和 packages/db/src/settings.ts:54 表明运行时配置不是散落在组件里的常量;当 Agent 调度频率、认证、工作区能力或外部 provider 配置变化时,这类设置入口会成为排查线上行为差异的第一站。

▸外部读取要有安全包装

packages/db/src/safe-fetch.ts:8 到 packages/db/src/safe-fetch.ts:37 给出了安全抓取的辅助边界。CRM 的 sandbox 是 deny-all,但真正的数据补全仍可能依赖受控工具,因此这类安全包装决定了 Agent 能否在不放大 SSRF 和任意请求风险的前提下完成研究。

▸对象存储承接可见资产

packages/db/src/blob.ts:25 与 packages/db/src/blob.ts:49 解释了头像、logo 或研究附件这类二进制结果应该怎样离开 Agent 流程并进入可服务的存储层。对 CRM 用户而言,可见资产的稳定展示和文字事实一样重要。

▸工作区边界决定多租户安全

packages/db/src/workspace.ts:54 和 packages/db/src/workspace.ts:73 是多租户场景下必须同时阅读的边界。Agent 任务携带 workspaceId,只要工作区解析或成员关系判断出错,研究结果就可能写到错误客户空间,这是比单次生成失败更严重的问题。

▸任务状态需要可恢复

packages/db/src/agent-tasks.ts:18 与 packages/db/src/agent-tasks.ts:31 对应任务 subject 和 kind 的类型边界;它们让调度器可以在进程重启、租约过期、session 回调延迟后继续解释一条任务应当怎样恢复,而不是靠临时内存猜测。

▸索引页选择的是架构信号

packages/db/src/agent-tasks.ts:22、packages/db/src/client.ts:61 和 packages/db/src/settings.ts:34 合在一起说明,trycompai/crm 的热门价值不只是“CRM 加聊天框”。它把队列、数据库、配置和前端会话做成一条可审计链路,所以值得作为本期 Agentic CRM 项目拆解。

亮点
队列租约、证据账本、deny-all sandbox 和持久会话共同把 Agent 做成业务系统,而不是聊天外挂。
风险
能力边界分散在 API、Eve、sandbox、工具和前端缓存之间,后续维护要靠文档和测试持续约束。
Generated · 2026-08-04Source · trycompai/crm← 返回 Trending 索引