crm agentic-first CRM
trycompai/crm 把 CRM 设计成 Agent 的工作台:API 只入队事实,Agent 按预算研究和记录,前端 Agent Tab 负责把可追踪会话交给销售代表。
它解决什么,凭什么值得拆
这个项目最有意思的地方是产品定义倒置: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”更可信。
▸核心数据
源码证据 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 记录。
任务队列先行,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 更抗并发。
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 掉线后还有可解释终态。
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 });
两条 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 是这条分界线。
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 传入任务认证属性。
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 不该拿到数据库和网络。
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});
事实不是生成的,是带证据结算的
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 字段。
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 场景里“错客户事实比空字段更糟”的产品风险。
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;
前端 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 渲染成可点击选项。
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 找回线程。
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}
测试覆盖验证的是 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 会移动已有预订而不是重复排队。
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 系统最容易出事故的几条边界。
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 });
技术亮点之外,别忽略这些
第一,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 项目拆解。