数据截至 (上游 commit e741923f72c3)
Copilot(Mothership):让 AI 来搭工作流的那半边
30 秒导读: 在 Sim 的聊天框里说一句"帮我做个每天早上抓 RSS 发 Slack 的流程",画布上真的会长出块、连出边、部署上线。本章讲这件事在本仓库这一侧是怎么实现的。
路径约定: 本章所有源码路径都是克隆根的完整相对路径(
apps/sim/…、packages/…、scripts/…),与本组其它章一致。同一小节内重复出现的文件,第二次起用文件名简写(如engine.ts:203);完整路径一律可在 §17 代码地图里查到。行号锚定 frontmatter 里的sourceCommit。
1. 先划边界:哪半边不在这个仓库里
这一节必须先读,否则后面所有代码都会被误读。
LLM 主循环不在本仓库。 它跑在一个独立的 Go 服务里,地址由 SIM_AGENT_API_URL 决定,默认 https://www.copilot.sim.ai(apps/sim/lib/copilot/constants.ts:3-11,SIM_AGENT_API_URL_DEFAULT / SIM_AGENT_API_URL)。这个仓库里找不到任何一处"拼 system prompt、调 Anthropic/OpenAI、跑 ReAct 循环"的代码。
那本仓库拥有什么?四样东西:
| 本仓库拥有 | 具体是什么 | 入口 |
|---|---|---|
| 工具目录 | 95 个工具的 id / 参 数 schema / 执行方归属 | apps/sim/lib/copilot/generated/tool-catalog-v1.ts |
| 工具执行 | 其中 72 个在 Sim 侧真正跑起来 | apps/sim/lib/copilot/tool-executor/ |
| 流式契约 | Go → Sim 的 SSE 事件信封,以及 Sim → 浏览器的转发 | apps/sim/lib/copilot/generated/mothership-stream-v1.ts |
| 会话持久化 | 聊天、消息、run、异步工具调用、画布检查点 | packages/db/schema.ts + apps/sim/app/api/mothership/ |
一句话直觉:Go 是大脑,本仓库是手脚 + 神经 + 记忆。大脑说"调用 edit_workflow,参数如下",手脚负责真的把块加到画布上,神经负责把这一来一回的事件流可靠地送到浏览器,记忆负责让浏览器刷新后还能接上。
有意思的是,工具目录本身也不是本仓库写的——它是从 Go 仓库的 JSON 契约生成出来的(见 §4)。所以"哪些工具存在"这件事的真源在 Go 那边,本仓库只是持有一份生成副本并保证不漂移。
命名说明:代码里叫
copilot和mothership(两个名字混用,新代码偏向mothership),产品文案里这个东西叫 "Sim / Chat"。本章沿用代码里的名字。
2. 它对用户是什么样
聊天框里输入一句人话,发生的事情有三类:
- 改画布 —— 加块、连边、改参数、把块塞进循环子流程。
- 改工作区 —— 建工作 流、建知识库、建表、传文件、配置 OAuth 凭据。
- 跑东西 —— 执行工作流、单步跑某个块、部署成 API/Chat/MCP。
以及一条非交互式入口:给工作区的专属邮箱发一封邮件,agent 读邮件、干活、回信(§12)。
用起来的形状大致是这样(示意,非源码):
// 浏览器发一条消息,拿回一条 SSE 流
const res = await fetch('/api/mothership/chat', {
method: 'POST',
body: JSON.stringify({
message: '在当前工作流里加一个 Slack 块,接在 Agent 后面',
workflowId: 'wf_123',
chatId: 'chat_abc',
}),
})
// 流里滚出来的是:文本增量 → 工具调用 → 工具结果 → 完成
for await (const evt of readSSE(res.body)) {
console.log(evt.type, evt.payload) // text / tool / span / run / complete
}
重点看:客户端只跟 /api/mothership/chat 说话,完全不知道 Go 服务的存在。
3. 顶层全景
怎么读这张图: 从左到右是一次对话的时间轴;竖线是三个进程(浏览器 / Sim Next.js / Go 服务);横箭头是跨进程调用。关键在于中间那一列——它是本章的全部内容。
[浏览器] [Sim = 本仓库 Next.js] [Go 服务 = 仓库外]
| | |
| POST /mothership/chat | |
|------------------------->| ① 鉴权 / 建会话 / 组请求包 |
| | (工具目录 + VFS + 上下文) |
| |----------- HTTP POST ------------>|
| | | LLM 主循环
| |<-------- SSE 事件流 --------------| 决定调什么工具
| | ② 解析 / 校验信封 / 分发 |
| ③ 转发 SSE 给浏览器 | route='sim' → 本地执行工具 |
|<-------------------------| (改画布 / 查库 / 发部署) |
| | |
| | ④ 工具跑完,POST 结果回去恢复 |
| |------ /api/tools/resume ---------->| 循环继续
| | |
| | ⑤ 落库 copilot_messages |
各部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| 统一聊天入口 | 鉴权、建/找会话、决定走 workflow 还是 workspace 分支 | apps/sim/lib/copilot/chat/post.ts:1022(handleUnifiedChatPost) |
| SSE 生产者 | 建 ReadableStream,把事件既写给浏览器又写进 Redis | apps/sim/lib/copilot/request/lifecycle/start.ts:87(createSSEStream) |
| 检查点循环 | 驱动"首帧 → 暂停 → 跑工具 → 恢复"的多腿请求 | apps/sim/lib/copilot/request/lifecycle/run.ts:841(runCheckpointLoop) |
| SSE 读循环 | 拉 Go 的流、解析、校验信封、分主/子 agent 通道分发 | apps/sim/lib/copilot/request/go/stream.ts:146(runStreamLoop) |
| 工具路由 | 按 catalog 判断一个工具归谁执行 | apps/sim/lib/copilot/tool-executor/router.ts:25(isSimExecuted) |
| 工具执行器 | 查 handler 表、带看门狗超时地跑 | apps/sim/lib/copilot/tool-executor/executor.ts:36(executeTool) |
| 改图工具 | 把受限操作集应用到画布 JSON 并回校验 | apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts:99 |
| 工作区 VFS | 把整个工作区摊平成"文件",供模型 read/glob/grep | apps/sim/lib/copilot/vfs/workspace-vfs.ts:653(WorkspaceVFS) |
4. 契约层:工具目录和流协议都是"生成物"
4.1 要解决的小问题
Sim 和 Go 是两个仓库、两种语言。它们必须对两件事逐字节达成一致:
- 有哪些工具、参数长什么样、谁负责执行;
- SSE 事件信封长什么样。
一旦漂移,轻则模型调了个不存在的工具,重则流解析炸掉。
4.2 思路:单一真源 + 生成 + CI 卡不住就报错
真源是 Go 仓库里的 JSON 契约。看生成脚本的输入路径就明白了:
const DEFAULT_CATALOG_PATH = resolve(ROOT, '../copilot/copilot/contracts/tool-catalog-v1.json')
scripts/sync-tool-catalog.ts:8。../copilot 是本仓库之外的兄弟目录——这是"主循环不在本仓库"最硬的证据之一。流协议同理,scripts/sync-mothership-stream-contract.ts:11 指向 ../copilot/copilot/contracts/mothership-stream-v1.schema.json。
生成链路一共八个脚本,由 scripts/generate-mship-contracts.ts:17 的 GENERATORS 数组统一驱动:
| npm script | 干什么 |
|---|---|
bun run mship:generate | 跑全部八个生成器,再用 biome 统一格式化输出目录 |
bun run mship:check | 重新生成一遍,和已提交文件逐字节比对,不同就退出非零 |
bun run mship-tools:generate | 只生成工具目录(tool-catalog-v1.ts + tool-schemas-v1.ts) |
bun run mship-contracts:generate | 只生成流协议(mothership-stream-v1.ts + -schema.ts) |
四条 script 的定义在 package.json:35-52。--check 的实现就是一句朴素的字符串比较:两个输出文件里任一份不等于磁盘内容,就抛 Generated tool catalog is stale. Run: bun run mship-tools:generate(scripts/sync-tool-catalog.ts:219-225)。
一个容易忽略的坑:scripts/generate-mship-contracts.ts:37 把 tool-schemas-v1.ts 从格式化环节里排除掉了,因为 biome 的 --unsafe 括号引号修复器会重排 TOOL_RUNTIME_SCHEMAS 的每个键,导致生成器两侧永远对不上。这是"生成物 + 格式化器"组合的经典摩擦点。
4.3 目录条目长什么样
ToolCatalogEntry(apps/sim/lib/copilot/generated/tool-catalog-v1.ts:5-281)的关键字段:
route: 'client' | 'go' | 'sim' | 'subagent' // :206
subagentId?: 'agent' | 'auth' | 'deploy' | ... // :207
mode: 'async' | 'sync'
requiredPermission?: 'admin' | 'read' | 'write'
注意 route 有 4 个取值,不是三个——subagent 是独立的一档。95 个条目的分布是:
| route | 数量 | 谁执行 | mode | 例子 |
|---|---|---|---|---|
sim | 72 | 本仓库的 Node 进程 | 全部 async | edit_workflow、read、grep、deploy_api |
subagent | 12 | Go 侧的子 agent(本仓库只转发) | 全部 async | workflow、knowledge、research、superagent |
go | 7 | Go 服务自己 | 全部 sync | search_online、scrape_page、user_memory |
client | 4 | 浏览器(headless 时降级到服务端) | 全部 async | run_workflow、run_block |
12 个 subagent 的 subagentId 全集:workflow / knowledge / table / deploy / research / media / run / auth / file / agent / scheduled_task / superagent。它们的参数 schema 出奇地简单——只有一个 request: string,比如 Auth 的描述就是 "What authentication/credential action is needed."(tool-catalog-v1.ts:315-327)。也就是说,主 agent 对子 agent 说的也是人话,子 agent 自己再展开成具体工具调用。
mode 和 route 完全共变:只有 go 路由的工具是 sync,其余全是 async(inferred:这是 §7 检查点协议的直接推论——只有需要跨进程等待结果的工具才需要把 Go 的循环挂起)。
4.4 TOOL_CATALOG 的形状
生成器为每个工具吐一个具名常量,最后拼成一张表(apps/sim/lib/copilot/generated/tool-catalog-v1.ts:7178):
export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[EditWorkflow.id]: EditWorkflow,
// ... 95 项
}
具名常量的价值在于别处引用工具 id 时不写字符串字面量。apps/sim/lib/copilot/tool-executor/register-handlers.ts:138 写的是 [GetBlockOutputs.id]: h(executeGetBlockOutputs) 而不是 ['get_block_outputs']: ...——工具改名时 TypeScript 会直接编译失败。
5. 路由与执行:一个工具调用怎么落地
5.1 路由:一张表查完事
判断一个工具归谁执行,全部逻辑是对生成目录的一次查表。整个 apps/sim/lib/copilot/tool-executor/router.ts 只有查表函数,没有 if-else 树、没有正则匹配前缀:
export function getToolEntry(toolId: string): ToolCatalogEntry | undefined {
return TOOL_CATALOG[toolId]
}
export function isSimExecuted(toolId: string): boolean {
return getToolEntry(toolId)?.route === 'sim'
}
router.ts:10-28。热路径上真正被调用的只有 isSimExecuted,两处:
| 调用点 | 拿它决定什么 |
|---|---|
apps/sim/lib/copilot/request/handlers/tool.ts:514(staticSimExecuted) | 收到一条 tool 事件后,要不要在 Sim 侧分发执行 |
apps/sim/lib/copilot/tool-executor/executor.ts:63 | 能不能用本地注册的 handler,而不是丢给通用工具层 |
同一个文件里还写好了这套查表的批量泛化形式——routeToolCall(router.ts:20)返回 {route, mode, subagentId} 三元组,partitionToolBatch(router.ts:50)把一批调用切成五桶(sim / go / subagent / client / unknown)。它们目前没有生产调用方(见 §14),是预留的 API 面而不是当前热路径;apps/sim/lib/copilot/tool-executor/index.ts:3-4 对外导出 getToolEntry、isSimExecuted 和新增的 toolRequiresApproval 三个(另两行是 executeTool/ensureHandlersRegistered 再导出)。
5.2 执行:handler 注册表 + 一处 降级
executeTool(apps/sim/lib/copilot/tool-executor/executor.ts:36)的第一件事是判断能不能用本地 handler:
const canUseRegisteredHandler =
isKnownTool(toolId) &&
(isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId)))
executor.ts:52-53。第二个分支是为 headless 模式准备的降级:run_workflow 这类 client 工具正常应该在浏览器里跑,但两条入口没有浏览器——邮件收件箱(§12)和工作流里的 Mothership 块(apps/sim/executor/handlers/mothership/mothership-handler.ts:746,它回调本仓库的 /api/mothership/execute,见 :348)——于是回落到服务端注册的同名 handler。不满足条件的就丢给通用工具层 executeAppTool。
handler 表由 ensureHandlersRegistered()(apps/sim/lib/copilot/tool-executor/register-handlers.ts:120)一次性装配,内容分两批:
- 手写映射:50 条,如
[GrepTool.id]: h(executeVfsGrep)(register-handlers.ts:172-178); - 自动桥接:
buildServerToolHandlers()(register-handlers.ts:202)遍历apps/sim/lib/copilot/tools/server/router.ts里注册的 server tool 名字,用统一适配器包一层。