跳到主要内容

数据截至 (上游 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 那边,本仓库只是持有一份生成副本并保证不漂移。

命名说明:代码里叫 copilotmothership(两个名字混用,新代码偏向 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,把事件既写给浏览器又写进 Redisapps/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/grepapps/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:17GENERATORS 数组统一驱动:

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:37tool-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'

注意 route4 个取值,不是三个——subagent 是独立的一档。95 个条目的分布是:

route数量谁执行mode例子
sim72本仓库的 Node 进程全部 asyncedit_workflowreadgrepdeploy_api
subagent12Go 侧的子 agent(本仓库只转发)全部 asyncworkflowknowledgeresearchsuperagent
go7Go 服务自己全部 syncsearch_onlinescrape_pageuser_memory
client4浏览器(headless 时降级到服务端)全部 asyncrun_workflowrun_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 自己再展开成具体工具调用。

moderoute 完全共变:只有 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 对外导出 getToolEntryisSimExecuted 和新增的 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 名字,用统一适配器包一层。

5.3 看门狗:不允许一个工具卡死整条链

这是被专门加固过的地方。apps/sim/lib/copilot/request/tools/executor.ts:214 维护了一张"合法长跑工具"名单:

const LONG_RUNNING_TOOL_IDS: ReadonlySet<string> = new Set([
Run.id, RunWorkflow.id, FunctionExecute.id, GenerateImage.id,
GenerateVideo.id, Research.id, KnowledgeBase.id, CreateFile.id, /* ... */
])

选闸门的函数是 toolWatchdogTimeoutMs(executor.ts:243):名单里的拿 60 分钟上限(TOOL_WATCHDOG_LONG_RUNNING_MS,apps/sim/lib/copilot/constants.ts:31,等于 ORCHESTRATION_TIMEOUT_MS),名单外的一律 60 秒(TOOL_WATCHDOG_DEFAULT_MS,constants.ts:22)。超时的处理不是"忽略",而是主动构造一个错误结果:

`Tool '${toolName}' timed out after ${...}s on the Sim executor and was abandoned.`

apps/sim/lib/copilot/request/tools/executor.ts:272。原因写在 apps/sim/lib/copilot/constants.ts:16-21 的注释里:一个既不 resolve 也不 reject 的工具,会让恢复门(§7)永远等下去,连带把整个聊天的 pending-stream 锁一起卡住。给 Go 一个"失败"比让它永远等着要好。


6. 传输层:SSE 的三段接力

6.1 三段分别是什么

Go 服务 ──SSE──▶ ① 读循环 ──▶ ② 事件处理器 ──▶ ③ StreamWriter ──▶ 浏览器
runStreamLoop sseHandlers 双写 Redis

一次事件的完整旅程:被 processSSEStream 从字节流里切出来 → 过信封校验 → 按 type 分发给处理器改内存状态 → 由 StreamWriter 同时写给浏览器和 Redis outbox。

6.2 出站请求:每一次都挂 OTel span

Sim → Go 的所有请求走 fetchGo(apps/sim/lib/copilot/request/go/fetch.ts:29)。它做两件事:

  1. 开一个子 span,属性里塞 method / url / status / 耗时 / 响应大小(fetch.ts:44-58);
  2. 注入 W3C traceparent,让 Go 侧的 span 挂进同一棵 trace(fetch.ts:61traceHeaders,实现在 apps/sim/lib/copilot/request/go/propagation.ts:32)。

一个值得抄的细节在 fetch.ts:88:只有"可行动"的状态码才把 span 标成 ERROR。400/401/403/404 这类正常拒绝保持 UNSET,否则错误看板会被预期内的拒绝淹没。注释里明说 Go 那边的 telemetry 中间件有一条镜像规则(5xx + 402/409/429)。

另一个坑记录在 fetch.ts:7-11:tracer 必须按调用惰性解析,因为 Next.js 16 + Turbopack 开发模式下模块级的 trace.getTracer() 可能早于 TracerProvider 安装,把一个 NoOp tracer 永久冻结进闭包,静默丢掉每一个 Sim → Go 的 span。

6.3 解析:一个致命错误类型

processSSEStream(apps/sim/lib/copilot/request/go/parser.ts:22)是标准的 SSE 行缓冲解析器,但错误分级设计得很克制:

export class FatalSseEventError extends Error {}

parser.ts:6。区别在于:JSON 解析失败 = 致命,抛出终止整条流;而 onEvent 回调抛的普通异常只 logger.warn 后继续(parser.ts:72-80)。理由很朴素——一个渲染不出来的事件不该杀掉整轮对话,但一个解析不了的字节流意味着协议已经错位,继续读只会读到垃圾。

runStreamLoop 把校验失败也升格成 FatalSseEventError(apps/sim/lib/copilot/request/go/stream.ts:325):信封过不了 parsePersistedStreamEventEnvelope 就直接终止。

6.4 分发:主通道和子 agent 通道严格分离

stream.ts:505-513 这段的注释值得整段读:

if (streamEvent.scope?.lane === 'subagent') {
if (handleSubagentRouting(streamEvent, context)) { /* 交给 subAgentHandlers */ }
return context.streamComplete || undefined
}

规则是:subagent 通道的事件只按自己的 scope 路由。缺 parentToolCallId 的畸形事件被丢弃,而不是回落到主通道。apps/sim/lib/copilot/request/handlers/index.ts:37handleSubagentRouting 解释了为什么不能用"当前活跃 subagent"这种全局指针兜底——多个 subagent 并发时,交错的事件会被全部错误归因给最后启动的那个。

八种事件类型各有处理器(handlers/index.ts:19-28sseHandlers):

type处理器干什么
texthandleTextEvent累积 assistant 正文 / thinking 块
toolhandleToolEvent建工具调用状态,触发本地执行
spanhandleSpanEvent子 agent 生命周期、结构化结果
runhandleRunEvent检查点暂停 / 恢复 / 上下文压缩
sessionhandleSessionEvent会话 id、标题、trace id
resourcehandleResourceEvent资源引用增删
complete / error各自处理器终态

子 agent 通道只认其中三种(subAgentHandlers,handlers/index.ts:30-34):text / tool / span

6.5 会话层:让浏览器刷新后能接上

apps/sim/lib/copilot/request/session/ 是这条流的持久化外壳:

模块职责关键符号
writer.ts:23双写:SSE 给浏览器 + 批量落 Redis + 15s 心跳StreamWriter
buffer.ts:13:14Redis outbox,键前缀 mothership_stream:,TTL 1 小时appendEvents / readEvents
recovery.ts:22客户端带 cursor 重连时检测"该补的事件已经过期"checkForReplayGap
abort.ts中止标记 + 聊天级流锁,防同一会话并发两条流acquirePendingChatStream
sse.ts:6编码工具,加了 Content-Encoding: none 防中间层缓冲encodeSSEEnvelope

buffer.ts:48withRedisRetry 用固定退避 [0, 50, 150]ms 重试三次(RETRY_DELAYS_MS,buffer.ts:17),并在拿不到 Redis client 时直接抛 Redis is required for mothership stream durability(buffer.ts:54)——Redis 在这里不是缓存,是必需依赖

6.6 读循环的可观测性:一个专门为 bug 类设计的指标

apps/sim/lib/copilot/request/go/stream.ts:239-258 定义了一组纯 JS 计数器,循环结束时一次性打成一个 OTel span(stampSseReadLoopSpan,stream.ts:663)。设计理由写在注释里,直接把三个量拆开:

  • longestInboundGapMs —— 两次 reader.read() 拿到字节的最大间隔,"上游沉默"的上界;
  • longestDispatchMs —— 单个处理器占用主线程的最长时间,"我们自己 CPU 卡住"的上界;
  • totalDispatchMs —— 处理器总耗时。

因为 JS 单线程,inbound gap 天然包含 dispatch 时间,所以必须两个一起看才能区分是 Go 慢还是自己慢。

更妙的是 terminal_event_missing 这个布尔属性(stream.ts:686)。它专门标记一类 bug:调用方认为这一腿是最后一腿(streamComplete === true),但线上从没来过 completeerror——也就是"响应凭空消失"。计算时要减去检查点暂停这一情况,因为暂停时同样会置 streamComplete=true 却本来就不该有终态事件。判据是 awaitingAsyncContinuation 是否存在(stream.ts:618)。


7. 核心机制:checkpoint pause / resume

7.1 要解决的小问题

Go 在推理到一半时决定"我要调 edit_workflow"。但这个工具只有 Sim 能跑,而且可能跑好几秒。Go 的 HTTP 响应流不能干等——那样连接会超时,Go 的进程也被一个外部依赖绑住。

7.2 思路:把一次对话拆成多腿 HTTP 请求

腿 1: POST /api/copilot → SSE 到 run.checkpoint_pause 为止,流关闭
Sim 本地跑工具 ...
腿 2: POST /api/tools/resume → SSE 继续,可能再次 pause
Sim 再跑工具 ...
腿 N: POST /api/tools/resume → SSE 到 complete,结束

对浏览器来说这始终是一条流——多腿是 Sim 内部的事,StreamWriter 从头到尾没关过。

7.3 暂停端:一个事件改两个标志位

context.awaitingAsyncContinuation = {
checkpointId: event.payload.checkpointId,
executionId: ..., runId: ...,
pendingToolCallIds: event.payload.pendingToolCallIds,
frames: frames.length > 0 ? frames : undefined,
}
context.streamComplete = true

apps/sim/lib/copilot/request/handlers/run.ts:83-97(handleRunEvent)。streamComplete = true 让读循环干净退出,awaitingAsyncContinuation 则是"这不是真结束"的凭证。

7.4 恢复端:等工具、拼结果、再发一腿

runCheckpointLoop(apps/sim/lib/copilot/request/lifecycle/run.ts:841)是个 for(;;) 循环,每轮:

  1. 判断本轮是首帧还是恢复腿(route === '/api/tools/resume',run.ts:563);
  2. runStreamLoop,目标 URL 是 ${mothershipBaseURL}${route} —— goRoute 是 Go 服务上的路径,不是 Sim 自己的路由(run.ts:633-634);
  3. 拿到 awaitingAsyncContinuation 后,await Promise.allSettled(context.pendingToolPromises.values()) 等所有本地工具落地(run.ts:753);
  4. { streamId, checkpointId, userId, workspaceId, results } 组成新 payload,route 换成 /api/tools/resume(run.ts:857-864),进入下一轮;并发恢复走另一条路径,直接 POST ${baseURL}/api/tools/resume(run.ts:426)。

等待有兜底:超过看门狗宽限期还没结果的工具会被记为 hungToolCallIds 并从 pending 表里摘掉(run.ts:757-769),避免一个工具拖死整条链。

7.5 并发恢复:每个子 agent 一条恢复链

当每个 frame 自带 checkpointId 时(isPerSubagentContinuation,run.ts:258),Sim 并发驱动多条恢复链,而不是打包成一次 resume。这样快的子 agent 不用等慢的兄弟。

这里有个非常克制的并发设计,写在 run.ts:275-289 的注释里:JS 单线程,多条腿只在 await 点交错,所以大部分状态按引用共享(contentBlocks、toolCalls、subagent 映射),只有四类字段按腿隔离:

隔离的字段为什么必须隔离
streamComplete / awaitingAsyncContinuation一条腿结束不能停掉兄弟的读循环
accumulatedContent / finalAssistantContent否则 += 合并会把汇合前的正文乘以腿数
usage / cost子腿的陈旧数值不能覆盖汇合腿的真实总量
errors一条腿的可重试错误回滚不能按下标截断兄弟的数组

makeResumeLegContext(run.ts:290)和 mergeResumeLegOutputs(run.ts:306)是必须成对修改的一对函数,仓库里专门有 resume-leg-context.test.ts 做契约测试。

7.6 落库

暂停状态同时写进数据库,copilot_runs.status 置为 paused_waiting_for_tool(run.ts:580)。相关表见 §11。


8. 改图工具 edit_workflow

这是整个子系统里工程含量最高的一支——把模型说的话精确落到一张真实的有向图上。图的数据模型见 从画布到 DAG

本节的文件都在 apps/sim/lib/copilot/tools/server/workflow/edit-workflow/ 下,首次出现给全路径,后续用文件名简写。

8.1 关键设计:受限操作集

模型不能直接返回一整张新画布,只能返回一串受限操作:

operation_type: 'add' | 'edit' | 'delete' | 'insert_into_subflow' | 'extract_from_subflow'

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts:115-119。每个操作只有三个字段:operation_typeblock_idparams

为什么值得?因为可增量校验。一整张新画布只能整体接受或整体拒绝;一串操作可以逐个应用、逐个记录跳过原因,让"90% 对的编辑"部分生效,而不是全盘退回。

8.2 应用顺序不是模型给的顺序

orderOperations(apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.ts:129-148)强制重排:

delete → extract_from_subflow → add → insert_into_subflow(拓扑排序) → edit

每一步的理由都写在注释里:先删是为了腾出 ID;edit 放最后是为了让"连到刚新建的块"这种引用能成立。insert_into_subflow 之间还要跑一次 Kahn 拓扑排序(topologicalSortInserts,engine.ts:51),保证父容器先于子块被创建。

8.3 前向引用自愈:pendingConnections

模型经常写出"A 连到 B",而 B 要在同一批操作的后面才创建,甚至要等下一次 edit_workflow 调用。天真的实现会丢掉这条边。

Sim 的做法是把边挂起来createValidatedEdge(apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.ts:496-540)发现目标不存在时,不丢弃,而是记到源块的 block.data.pendingConnections 上(跟着画布一起持久化),然后由引擎分四趟收尾:

Pass 1 逐个应用操作
Pass 2 处理本批 add/insert 产生的 deferredConnections
Pass 3 resolvePendingConnections —— 扫所有块的 pendingConnections,
目标已存在的就建边,还不存在的留着(可能是几轮对话之后的事)
Pass 4 removeInvalidScopeEdges —— 删跨作用域的非法边

engine.ts:306-342(Pass 3 的实现在 engine.ts:388)。第四趟必须放最后,注释说得很清楚:如果按操作逐个删,两个正被移进同一个子流程的块之间的边会被误删。

8.4 给模型的反馈要分类,不能一锅端

这一条是本章最值得抄走的设计。跳过项被切成两类(apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts:328-330):

类别含义结果字段对模型说什么
真失败块不存在、名字重复、类型不允许、句柄非法skippedItems"这些操作没生效,需要处理"
良性延迟invalid_edge_target,目标块还没建deferredConnections"这不是失败,别重发"

判据是一个显式集合(types.ts:75-82):

export const DEFERRED_SKIPPED_ITEM_TYPES: ReadonlySet<SkippedItemType> = new Set([
'invalid_edge_target',
])

注释直白点破了动机:一个字面理解的模型会把"skipped"当成"要重试",于是把自愈操作反复重发,陷入死循环。

deferredMessage 的措辞也是刻意的(index.ts:409):"This is NOT a failure and does NOT need fixing... Do not re-issue them."——把 prompt 工程写进了工具返回值。

同时每一项都保留机器可读的 type 字段(block_not_found / block_locked / duplicate_block_name / ...),这样模型可以按类别分支,而不是去模式匹配散文。

8.5 四道校验关卡

改完不算完,index.ts 的 execute 里串了四道关:

检查什么符号
前置凭据 / API key 在应用前先过滤掉非法的preValidateCredentialInputs(index.ts:150)
结构schema 合法性 + 自动 sanitize,失败直接抛validateWorkflowState(index.ts:222)
解析(Tier 2)选择器引用的凭据/知识库/MCP server 在工作区里真存在吗collectUnresolvedReferences(index.ts:175)
图 lint孤立块、缺入口、分支端口非法、必填字段空lintEditedWorkflowState(apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.ts:118)

Tier 2 那层的注释解释了它为什么必要:一个"形状正确但 id 解析不到"的工具引用,在运行时会被静默丢弃——agent 悄无声息地少了个工具。所以要在编辑期就通过 lint 和输入校验两个通道一起报出来。

最后 lint 结果被格式化成一段人话塞进 workflowLintMessage(index.ts:353),连同 workflowState 一起返回给模型。

8.6 收尾:布局、落库、通知

  • getTargetedLayoutImpact + applyTargetedLayout(index.ts:309-327)——只对受影响的块重排版,不动用户手工摆好的其他块;
  • saveWorkflowToNormalizedTables(index.ts:358)写归一化表;
  • 最后 fire-and-forget 一个 POST /api/workflow-updated 给 socket 服务器(index.ts:379),让协作画布上其他人的浏览器实时看到变化。

9. get_blocks_metadata:把块注册表反过来喂给模型

模型要能加 Slack 块,就得知道 Slack 块有哪些参数、哪些是必填、operation 有哪些取值。Sim 的答案是:把块注册表序列化成模型能读的结构

getBlocksMetadataServerTool(apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts:112)对每个请求的 blockId 组装:

  • inputSchema —— 与 operation 无关的公共参数;
  • operationInputSchema —— 按 operation 分组的参数(splitParametersByOperation);
  • tools[] —— 该块能访问的底层工具及其 params/outputs,描述从 apps/sim/tools/registry.ts 现取;
  • triggers[] —— 触发器配置字段(见 暂停、恢复与触发);
  • bestPractices —— 块定义里手写的经验规则;
  • yamlDocumentation —— 直接把 apps/docs/content/docs/yaml/blocks/{id}.mdx 整个文件读进来(get-blocks-metadata-tool.ts:360-378)。

最后一条很有意思:面向人写的文档站 MDX,原样进了给模型的工具返回值。一份文档,两个受众。

权限也在这一层生效:allowedIntegrations(用户配置与环境变量两张允许列表的交集,intersectIntegrationAllowlists,见 get-blocks-metadata-tool.ts:129-134lib/permission-groups/integration-allowlist.ts:5)不包含的块直接跳过,模型压根看不到它——用不可见代替拒绝


10. 工作区文件系统(VFS)

10.1 为什么要这个

LLM 在"读文件、grep、glob"这套隐喻上表现远好于"调 20 个不同的查询 API"。于是 Sim 把整个工作区摊平成一棵虚拟目录树

布局写在 apps/sim/lib/copilot/vfs/workspace-vfs.ts:608-650 的文档注释里,节选:

WORKSPACE_CONTEXT.md 完整动态上下文
workflows/{name}/state.json 画布状态(块 + 内嵌连接)
workflows/{name}/lint.json source/sink、必填、凭据解析问题
workflows/{folder}/{name}/... 文件夹里的工作流,支持嵌套
knowledgebases/{name}/documents.json
tables/{name}/meta.json
environment/credentials.json
components/blocks/{type}.json 块 schema(静态,进程内缓存)
components/triggers/{provider}/{id}.json

模型拿到的工具就三个:readglobgrep(apps/sim/lib/copilot/tool-executor/register-handlers.ts:172-178apps/sim/lib/copilot/tools/handlers/vfs.ts)。

10.2 关键设计:懒加载三档

WorkspaceVFS 里有两个 Map:

  • files: Map<string, string>(workspace-vfs.ts:656)—— 已物化的便宜内容(结构、元数据);
  • lazy: Map<string, () => Promise<string|null>>(workspace-vfs.ts:663)—— 昂贵内容的加载器。

三个操作对懒加载的消耗刻意不同:

操作解析多少懒加载项理由
glob(workspace-vfs.ts:1138)只匹配键名,连内容都不看
read(:713)恰好一个resolveLazyPath(path)
grep(:645)作用域内的只有 grep 扫内容,只有它该付这个钱

注释点明了动机:在此之前,一次 read 会付掉"每个工作流的图加载 + lint + stringify"的全部代价。

materialize(:514)对十来个数据源并发拉取,并给每个阶段打上耗时(timed()),挂到 span 上——注释说这是因为 v0.7 有过一次 lint.json 的性能回归,当时表现为 read/glob/grep 里一段无法归因的死时间。

10.3 (历史)别名层已删除

旧版 apps/sim/lib/copilot/vfs/workflow-aliases.ts 曾做一层路径改写,让模型看到 workflows/我的流程/changelog.md.plans/ 这样自然的路径。该文件及整套 changelog/plans 别名已在上游删除,当前 VFS 布局(apps/sim/lib/copilot/vfs/workspace-vfs.ts:610-651)只有 workflows/{name}/meta.jsonstate.jsonlint.jsonexecutions.jsondeployment.json 等直出路径,不再有 .changelogs/ / .plans/ 备份文件夹映射。

序列化器都在 apps/sim/lib/copilot/vfs/serializers.ts,一个资源类型一个函数(serializeWorkflowMetaserializeKBMetaserializeCredentials…)。


11. 会话、持久化与回滚

11.1 两个分支

resolveBranch(apps/sim/lib/copilot/chat/post.ts:812)先决定这次对话属于哪一类:

分支触发条件Go 路由拿到什么工具
workflow请求带 workflowIdworkflowName/api/copilot(post.ts:855)聚焦单个工作流
workspace只带 workspaceId/api/mothership(post.ts:924)全工作区 + mothership 工具

对应 copilot_chats.type'copilot' / 'mothership'

11.2 数据表

表都在 packages/db/schema.ts:

存什么行号
copilotChats会话:归属、标题、模型、资源、conversationId:2010
copilotMessages消息:content 是 jsonb 的 contentBlocks,软删 + (chatId, messageId) 唯一:2055
workflowCheckpoints每条用户消息之前的整张画布快照:2116
copilotRuns一次执行:status、model、streamId 唯一索引:2181
copilotRunCheckpoints恢复点:会话快照 + agent 状态 + provider 请求:2229
copilotAsyncToolCalls异步工具:args / status / result,toolCallId 唯一:2255

copilotAsyncToolCalls 上的 claimedAt / claimedBy 两列值得注意——这是多实例部署下的抢占字段,保证同一个工具调用不被两个进程同时执行。

11.3 回滚:一次编辑一个快照

用户消息发出前,前端先 POST /api/copilot/checkpoints 把当前画布 JSON 整个存下来(apps/sim/app/api/copilot/checkpoints/route.ts:92)。之后每条 AI 消息旁边就有个"回到这里"。

回滚路径(apps/sim/app/api/copilot/checkpoints/revert/route.ts):查快照 → 校验用户对该工作流有 write 权限 → 把状态 PUT 回去 → 成功后删掉这个 checkpoint(revert/route.ts:152)。删除失败不影响请求成功,注释明说"回滚已经成功了"。

11.4 刷新页面接上直播

刷新后有个尴尬窗口:用户消息已经落库,assistant 消息还没落库(流还在跑)。buildEffectiveChatTranscript(apps/sim/lib/copilot/chat/effective-transcript.ts:540)解决它——从 Redis outbox 里读事件快照,现场重建一条虚拟的 assistant 消息:

export function getLiveAssistantMessageId(streamId: string): string {
return `live-assistant:${streamId}`
}

effective-transcript.ts:37。前缀 live-assistant: 让 UI 一眼能认出这是尚未持久化的行。触发条件很严:最后一条消息必须是 role=user 且 id 等于当前 activeStreamId(:457-464),否则原样返回。

11.5 API 面

路由都在 apps/sim/app/api/mothership/ 下:

路由干什么
POST /api/mothership/chat发消息,返回 SSE(chat/route.ts:24)
GET /api/mothership/chat/stream带 cursor 重连已有的流
POST /api/mothership/chat/stop / abort停止
GET/POST /api/mothership/chats会话列表、/[chatId]/fork 分叉
POST /api/mothership/executeSim 侧的非流式执行入口(execute/route.ts:28 声明 maxDuration = 3600),供 Mothership 块等内部调用方走 headless 链路;与 Go 侧的同名路径不是一回事
GET /api/mothership/events工作区级任务状态 SSE(哪个会话在跑)

最后那一行的同名碰撞值得记一笔:execute/route.ts:230 里这条 Sim 路由自己又把 goRoute 设成 '/api/mothership/execute' 发给 Go——同一个字符串,一个是本仓库的 Next.js 路由,一个是 Go 服务上的路径


12. Inbox:发邮件给 agent 让它干活

最后一条入口证明了"主循环在别处"这个架构的价值——换个触发源,复用整条链

流程(apps/sim/lib/mothership/inbox/):

用户发邮件 ──▶ AgentMail webhook ──▶ mothership_inbox_task 落库


executeInboxTask(taskId)

┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
原子认领任务 识别发件人身份 下载附件
(status: received 匹配工作区成员, 转成 LLM
→ processing) 否则回落到 owner 能读的内容


runHeadlessCopilotLifecycle(...)
goRoute: '/api/mothership/execute' ← Go 侧路径
interactive: false


回信 + 消息落库 + 标记完成

几个实现细节:

  • 原子认领:UPDATE ... WHERE status = 'received' RETURNING id,拿不到行就说明别的实例已经接手,直接返回(apps/sim/lib/mothership/inbox/executor.ts:89-101,认领与执行者解析并发进行)。
  • 失败关闭:发件人黑名单、发件人账号封禁、工作区计费账号封禁,任一命中就终止;查不出来也终止,并且这几条路径一律不回信——"绝不给一个被停用的账号发邮件"(executor.ts:104-135)。
  • headless 复用:runHeadlessCopilotLifecycle(apps/sim/lib/copilot/request/lifecycle/headless.ts:17)只是给 runCopilotLifecycle 套一层 trace 和 transport: 'headless' 标记,检查点循环、工具执行、SSE 解析全部同一套代码。interactive: false 让工具全部自动执行、不等确认(apps/sim/lib/copilot/request/handlers/tool.ts:753)。
  • 手动落库:交互模式下消息由前端 store 写回,headless 没有前端,所以 persistChatMessages 直接写 DB(executor.ts:351-353 的注释点明了这个不对称)。

生命周期管理在 apps/sim/lib/mothership/inbox/lifecycle.ts:enableInbox(:18)建 AgentMail 收件箱 → 建 scoped webhook → 存 webhook secret → 置 workspace.inboxEnabled;disableInbox(:86)反着来。


13. 巧妙之处(可以直接抄走的)

  1. 契约当代码生成物,CI 卡漂移。 跨仓库、跨语言的协议放在一方的 JSON 里,另一方生成 + --check 比对。比"两边各写一份文档同步"可靠一个数量级。见 scripts/sync-tool-catalog.ts:219-225

  2. 路由查表,不写 if。 工具归谁执行完全由生成的目录数据决定(apps/sim/lib/copilot/tool-executor/router.ts:9-27),加一个工具不需要碰路由代码。

  3. 工具结果里区分"失败"和"良性延迟"。 DEFERRED_SKIPPED_ITEM_TYPES(apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.ts:76)+ 明确写给模型的 "This is NOT a failure" 措辞,直接根治了模型死循环重发。给模型的反馈是接口设计的一部分。

  4. 前向引用挂起而非丢弃。 pendingConnections 存在块数据上,跨调用自愈(edit-workflow/builders.ts:496 + edit-workflow/engine.ts:303)。这等于承认"模型会乱序生成"并为它设计。

  5. 懒加载分三档。 glob 零解析、read 解析一个、grep 解析作用域内(apps/sim/lib/copilot/vfs/workspace-vfs.ts:1054:706:713)。按操作的真实需要付费,而不是一次性物化全部。

  6. 单线程并发的隔离清单。 makeResumeLegContext / mergeResumeLegOutputs 明确列出"哪四类字段必须按腿隔离、其余按引用共享",并配契约测试(apps/sim/lib/copilot/request/lifecycle/run.ts:496-563)。

  7. 只对可行动的错误标 ERROR。 isActionableErrorStatus(apps/sim/lib/copilot/request/go/fetch.ts:88)让 401/404 不污染错误看板,Go 那边有镜像规则。

  8. 为一类 bug 造一个专属指标。 copilot.sse.terminal_event_missing 精确对应"响应凭空消失",并显式排除检查点暂停的假阳性(apps/sim/lib/copilot/request/go/stream.ts:686)。

  9. 面向人的文档直接喂给模型。 yamlDocumentation 把 docs 站的 MDX 原样读进工具返回值(apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts:374)。

  10. 看门狗宁可造一个假失败。 超时的工具返回 error 而不是继续等,因为"让 Go 拿到失败"好过"整条链和聊天锁一起卡死"(apps/sim/lib/copilot/constants.ts:16-21)。


14. 边界与局限

  • 本仓库读不出 agent 的决策逻辑。 prompt 组装、模型选择、子 agent 分工、上下文压缩策略全在 Go 侧。本仓库只能看到压缩发生过(run.compaction_start / compaction_done 事件),看不到它怎么压。

  • route: 'go' 的 7 个工具在本仓库没有实现。 search_onlinescrape_pageuser_memory 等只是目录条目,Sim 从不执行它们。

  • partitionToolBatch / routeToolCall / isGoExecuted 目前没有生产调用方。 全仓库搜索只在 apps/sim/lib/copilot/tool-executor/router.ts 自身命中(routeToolCall 仅被同文件的 partitionToolBatch 调用);tool-executor/index.ts:3-4 导出 getToolEntryisSimExecuted 与新增的 toolRequiresApproval。热路径上的判定是 §5.1 那两处 isSimExecuted,批量分桶那套是预留的 API 面。

  • Redis 是硬依赖。 没有 Redis,流的持久化直接抛错(apps/sim/lib/copilot/request/session/buffer.ts:54),刷新重连和跨实例恢复都不可用。

  • edit_workflow 的实现里 any 很多。 edit-workflow/engine.tsoperations.ts 大量 (modifiedState as any),类型安全在这条最关键的路径上是缺席的——校验靠运行时的 validateWorkflowState 和 lint 兜。

  • 检查点恢复依赖 Go 的行为契约。 比如"Go 总会给 subagent 事件盖上 parentToolCallId"——Sim 侧只能防御性地丢弃畸形事件(apps/sim/lib/copilot/request/handlers/index.ts:45-51),没法自己修。

  • 生成器格式化有例外。 tool-schemas-v1.ts 被排除在 biome 格式化之外(scripts/generate-mship-contracts.ts:37),意味着这个文件的风格和其他生成物不一致。


15. 横向对比(同 shelf)

Sim 在 ai-agent-reference 属于 workflow-builder(货架总览见 AI Agent Reference,该类归在分支 E 长成什么产品)。本章这一支——"AI 替人搭工作流"——在货架里有三个取舍鲜明的对照组。

兄弟子库它怎么做与 Sim 的差别
activepieces · AI 搭流MCP 让 AI 发出和 GUI 完全相同的 FlowOperationRequest,由同一个纯函数 reducer 应用同样是"受限操作集"路线,但 activepieces 的 AI 与人共用一份 reducer、且大脑不外置;Sim 的 edit_workflow 是给模型专设的一套操作,外加拓扑重排、前向引用自愈和四道校验(§8),因为它要容忍模型乱序、也要给模型可读的失败分类
dify把图调度器整个抽成外部包 graphon,本仓库只守边界同样是"核心搬到本仓库之外",但 Dify 抽走的是执行引擎、契约靠包版本约束;Sim 抽走的是 agent 大脑、契约靠 JSON 生成 + CI 逐字节 diff(§4)。两者都得回答"边界另一侧变了怎么办",答案一个是语义化版本、一个是生成器
langflow · 对外出口把搭好的流暴露成 MCP 服务器,让外部 AI 调用这条流方向相反:langflow 主打让 AI 流,Sim 本章主打让 AI 流。这两件事在 Sim 里恰好是同一张工具目录里的两个路由——run_workflow(client)和 edit_workflow(sim)

一句话取舍: 把 agent 大脑放到仓库外,换来的是"换个触发源就复用整条链"(§12 的邮件入口、Mothership 块都是这么来的),代价是本仓库读不出任何决策逻辑(§14 第一条),以及必须自建一套契约生成 + 漂移检查来替代编译期类型检查。


16. 与本组其它章的关系

想知道什么去哪章
全局地图与阅读顺序Sim — 架构与原理
edit_workflow 修改的那个数据模型到底是什么01 从画布到 DAG
改完的图被谁、按什么规则跑起来02 调度引擎
insert_into_subflow / extract_from_subflow 背后的循环与并行语义03 子流程编排与变量解析
工作流里的 agent 和本章的 copilot agent 有什么不同(对比着读)04 Agent 块、模型 Provider 与工具层
工作流执行的暂停/恢复(与本章 checkpoint 是同一思路的两个实例)05 暂停、恢复与触发

17. 代码地图

主题文件路径(相对克隆根)关键符号
后端地址 / 超时常量apps/sim/lib/copilot/constants.tsSIM_AGENT_API_URLTOOL_WATCHDOG_DEFAULT_MSTOOL_WATCHDOG_LONG_RUNNING_MSORCHESTRATION_TIMEOUT_MS
工具目录(生成物)apps/sim/lib/copilot/generated/tool-catalog-v1.tsToolCatalogEntryTOOL_CATALOG
工具参数 schema(生成物)apps/sim/lib/copilot/generated/tool-schemas-v1.tsTOOL_RUNTIME_SCHEMAS
流协议类型(生成物)apps/sim/lib/copilot/generated/mothership-stream-v1.tsMothershipStreamV1EventTypeMothershipStreamV1RunKind
流协议 JSON Schema(生成物)apps/sim/lib/copilot/generated/mothership-stream-v1-schema.tsMOTHERSHIP_STREAM_V1_SCHEMA
契约生成总入口scripts/generate-mship-contracts.tsGENERATORSFORMAT_EXCLUDE
工具目录生成器scripts/sync-tool-catalog.tsDEFAULT_CATALOG_PATHRUNTIME_SCHEMA_OUTPUT_PATHgenerateInterface
流协议生成器scripts/sync-mothership-stream-contract.tsDEFAULT_CONTRACT_PATHgenerateRuntimeConstants
工具路由apps/sim/lib/copilot/tool-executor/router.tsisSimExecutedgetToolEntryisClientExecutedisKnownToolrouteToolCallpartitionToolBatch
工具执行apps/sim/lib/copilot/tool-executor/executor.tsexecuteTool
tool-executor 对外导出面apps/sim/lib/copilot/tool-executor/index.tsexecuteToolensureHandlersRegisteredgetToolEntryisSimExecuted
handler 装配apps/sim/lib/copilot/tool-executor/register-handlers.tsensureHandlersRegisteredbuildHandlerMapbuildServerToolHandlers
工具看门狗apps/sim/lib/copilot/request/tools/executor.tsLONG_RUNNING_TOOL_IDStoolWatchdogTimeoutMsToolExecutionTimeoutErrorexecuteToolAndReport
出站 HTTP + OTelapps/sim/lib/copilot/request/go/fetch.tsfetchGoisActionableErrorStatus
W3C trace 透传apps/sim/lib/copilot/request/go/propagation.tstraceHeaderscontextFromRequestHeaders
SSE 解析apps/sim/lib/copilot/request/go/parser.tsprocessSSEStreamFatalSseEventError
SSE 读循环apps/sim/lib/copilot/request/go/stream.tsrunStreamLoopstampSseReadLoopSpanCopilotBackendError
事件处理器表apps/sim/lib/copilot/request/handlers/index.tssseHandlerssubAgentHandlershandleSubagentRouting
检查点事件处理apps/sim/lib/copilot/request/handlers/run.tshandleRunEvent
工具事件处理apps/sim/lib/copilot/request/handlers/tool.tshandleToolEventdispatchToolExecution
检查点循环apps/sim/lib/copilot/request/lifecycle/run.tsrunCopilotLifecyclerunCheckpointLoopisPerSubagentContinuationmakeResumeLegContextmergeResumeLegOutputs
SSE 流构造apps/sim/lib/copilot/request/lifecycle/start.tscreateSSEStreamrequestChatTitle
无头执行apps/sim/lib/copilot/request/lifecycle/headless.tsrunHeadlessCopilotLifecycle
流写出 + 双写apps/sim/lib/copilot/request/session/writer.tsStreamWriter
Redis outboxapps/sim/lib/copilot/request/session/buffer.tsappendEventsreadEventswithRedisRetryhasAbortMarker
重连补洞apps/sim/lib/copilot/request/session/recovery.tscheckForReplayGap
中止与流锁apps/sim/lib/copilot/request/session/abort.tsacquirePendingChatStreamabortActiveStream
SSE 编码apps/sim/lib/copilot/request/session/sse.tsencodeSSEEnvelope
Go 地址解析apps/sim/lib/copilot/server/agent-url.tsgetMothershipBaseURLgetMothershipSourceEnvHeaders
改图入口apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.tseditWorkflowServerToolpreValidateCredentialInputsapplyTargetedLayout
操作类型定义apps/sim/lib/copilot/tools/server/workflow/edit-workflow/types.tsEditWorkflowOperationSkippedItemTypeDEFERRED_SKIPPED_ITEM_TYPES
操作应用引擎apps/sim/lib/copilot/tools/server/workflow/edit-workflow/engine.tsapplyOperationsToWorkflowStateorderOperationstopologicalSortInsertsresolvePendingConnectionsremoveInvalidScopeEdges
五个操作处理器apps/sim/lib/copilot/tools/server/workflow/edit-workflow/operations.tshandleAddOperationhandleEditOperationhandleInsertIntoSubflowOperation
块/边构造apps/sim/lib/copilot/tools/server/workflow/edit-workflow/builders.tscreateBlockFromParamscreateValidatedEdge
输入与引用校验apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.tsvalidateInputsForBlockcollectUnresolvedReferences
图 lintapps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.tslintEditedWorkflowStateformatWorkflowLintMessage
块元数据反哺apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.tsgetBlocksMetadataServerToolsplitParametersByOperation
server tool 注册表apps/sim/lib/copilot/tools/server/router.tsgetRegisteredServerToolNames
工作区 VFSapps/sim/lib/copilot/vfs/workspace-vfs.tsWorkspaceVFSmaterializeresolveLazyPathgetOrMaterializeVFS
VFS 序列化apps/sim/lib/copilot/vfs/serializers.tsserializeWorkflowMetaserializeKBMetaserializeCredentials
VFS 虚拟布局apps/sim/lib/copilot/vfs/workspace-vfs.tsWorkspaceVFS(workflow-aliases 别名层已删,:561-594 为布局总览)
VFS 工具 handlerapps/sim/lib/copilot/tools/handlers/vfs.tsexecuteVfsReadexecuteVfsGlobexecuteVfsGrep
统一聊天入口apps/sim/lib/copilot/chat/post.tshandleUnifiedChatPostresolveBranch
上下文处理apps/sim/lib/copilot/chat/process-contents.tsprocessContextsServer
直播转录apps/sim/lib/copilot/chat/effective-transcript.tsbuildEffectiveChatTranscriptgetLiveAssistantMessageId
API 路由apps/sim/app/api/mothership/{chat,chats,execute,events}/POSTGET
画布检查点apps/sim/app/api/copilot/checkpoints/route.tscheckpoints/revert/route.tsPOST
工作流里的 Mothership 块apps/sim/executor/handlers/mothership/mothership-handler.tsMothershipBlockHandler
数据表packages/db/schema.tscopilotChatscopilotMessagescopilotRunscopilotRunCheckpointscopilotAsyncToolCallsworkflowCheckpoints
邮件入口apps/sim/lib/mothership/inbox/executor.tsexecuteInboxTaskpersistChatMessages
收件箱生命周期apps/sim/lib/mothership/inbox/lifecycle.tsenableInboxdisableInbox
AgentMail 客户端apps/sim/lib/mothership/inbox/agentmail-client.tscreateInboxcreateWebhookreplyToMessage