数据截至 (上游 commit 60706feb348c)
让 agent 指挥 agent:工具目录、MCP、定 时与循环
30 秒导读: 前面几章讲的是「人怎么通过 Paseo 控制 agent」。这一章讲的是 Paseo 的另一半野心: 把这套控制面反过来交给 agent 自己用。守护进程把「建工作区、开 agent、发提示、看状态、开终端、下定时任务」 打包成 39 个工具,通过两条投递路径塞进正在跑的 agent 里。于是一个 agent 可以像人一样点开另一个 agent。
1. 这是什么(零基础也能懂)
一句话定义: Paseo 守护进程把「自己能做的每一件事」注册成一份工具清单,让运行中的 agent 直接调用。
它解决什么问题。 你在终端里让 Claude 干活,它做到一半发现:这个任务应该拆成三份并行做;或者 「我改完了,得有人复查」;或者「这个 PR 要每五分钟看一眼 CI」。
传统做法是你回来点三次。Paseo 的做法是:agent 自己调 create_agent 开三个子 agent、调
create_heartbeat 挂一个定时唤醒、调 send_agent_prompt 去催那个复查的 agent。
谁在用。 三类调用者共用同一份工具清单:
| 调用者 | 怎么接进来 | 典型动作 |
|---|---|---|
| 运行中的 agent | 工具被注入进它的会话 | 开子 agent、发提示、挂心跳 |
| 外部 MCP 客户端 | 连守护进程的 /mcp/agents HTTP 端点 | 脚本化批量管理 agent |
paseo CLI / 手机 App | 走 WebSocket 协议(见 01) | 人手动操作 |
用起来什么样。 用户对 agent 说一句 "committee this",agent 读仓库里的技能说明,然后自己发出工具调用:
// 示意,非源码 —— agent 发出的一次工具调用
{
"name": "create_agent",
"arguments": {
"title": "Root-cause: flaky auth test",
"provider": "codex/gpt-5.4", // 必须是 provider/model 形式
"initialPrompt": "分析 auth 测试为何间歇失败……(自包含简报)",
"notifyOnFinish": true // agent 之间调用时默认就是 true
}
}
notifyOnFinish: true 是这套东西能闭环的关键:子 agent 干完活,守护进程会主动给父 agent 发一条系统提示,
父 agent 因此被唤醒继续往下走——不需要它轮询。
一句话直觉。 把守护进程想成一台机器的「操作系统」,agent 是进程。这一章讲的就是 Paseo 给进程开的
系统调用表:fork(create_agent)、kill、signal(send_agent_prompt)、cron(create_schedule)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 中间那个盒子是唯一的真相——一份工具目录。左边是它的两个投递出口,右边是它背后真正干活的服务。
┌──────────────── 两条投递路径 ────────────────┐
│ │
① 原生注入(provider 直接吃) ② MCP 适配(标准协议)
AgentLaunchContext.paseoTools HTTP /mcp/agents
│ │
└──────────────┬───────────────────────────────┘
▼
┌────────────────────────────┐
│ PaseoToolCatalog │ ← 唯一真相
│ createPaseoToolCatalog() │
│ 39 个工具 · zod 校验 │
└─────────────┬──────────────┘
│ 每个 handler 调下面某个服务
┌──────────┬──────────┼──────────┬───────────┐
▼ ▼ ▼ ▼ ▼
AgentManager Workspace Terminal ScheduleService Voice
生命周期 工作区 终端 定时/心跳 speak
部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
PaseoToolCatalog | 工具的注册表 + 分发器 | packages/server/src/server/agent/tools/types.ts:27 |
createPaseoToolCatalog | 造目录:注册全部工具、绑好依赖 | packages/server/src/server/agent/tools/paseo-tools.ts:541 |
| 原生注入路 | 把目录塞进 provider 的「宿主工具」通道 | packages/server/src/server/agent/providers/omp/host-tools.ts:33 |
| MCP 适配路 | 把目录薄封装成一个 MCP server | packages/server/src/server/agent/mcp-server.ts:31 |
ScheduleService | cron 定时 agent 与心跳 | packages/server/src/server/schedule/service.ts:244 |
(注:旧版的两个编排部件——
LoopService(worker/verifier 迭代循环)与FileBackedChatService(agent 聊天室)——已在本 commit 移除,见 §5.2/§5.4 的说明。)
主线走一遍(高层):
- 守护进程启动时把目录工厂交给 AgentManager(
bootstrap.ts:1377,setPaseoToolCatalogFactory)。 - 某个 agent 要启动 → AgentManager 现场为它造一份目录,目录里记着「调用者是谁」。
- 目录按 provider 能力走 ① 或 ② 投给 agent 进程。
- agent 调用
create_agent→ 目录的 handler 调 AgentManager → 新 agent 起来。 - 新 agent 结束 → 完成通知作为一条提示回灌给父 agent → 父 agent 继续。
3. 工具目录:一份清单,两个消费者
3.1 类型骨架先看懂
目录只有五个类型,读完就知道全貌(agent/tools/types.ts):
| 类型 | 是什么 | 行 |
|---|---|---|
PaseoToolDefinition | 一个工具:名字 + 描述 + zod 入参 + handler | types.ts:21 |
PaseoToolCatalog | 一张只读 Map + getTool + executeTool | types.ts:27 |
PaseoToolExecutionContext | 调用期上下文:signal 取消、sendUpdate 流式中间结果 | types.ts:3 |
PaseoToolRuntimeContext | 造目录时的身份:callerAgentId、enableVoiceTools、voiceOnly | types.ts:37 |
PaseoToolCatalogFactory | (runtimeContext) => Catalog —— 目录是按调用者现造的 | types.ts:43 |
最后一条是整个设计的支点。目录不是全局单例,而是每个调用者一份。因为「谁在调用」会改变工具的 schema 和默认值(见 3.4)。
3.2 注册与分发:三十行搞定
createPaseoToolCatalog 内部只有一个 Map 和一个闭包 registerTool(paseo-tools.ts:573-587),
所有工具都往这个 Map 里塞。分发同样朴素(paseo-tools.ts:593-603):
const tool = tools.get(name);
if (!tool) {
throw new Error(`Paseo tool not found: ${name}`);
}
return tool.handler(await parseToolInput(tool, input), context);
重点看 parseToolInput(paseo-tools.ts:558-570):它在 handler 之前做 zod 校验,并且能吃两种 schema 形态——
完整的 z.ZodType,或者一堆字段组成的 ZodRawShape(后者会被包成 z.object(...).passthrough())。
passthrough() 是刻意的:模型经常多塞字段,直接 strict 会把整次调用打回,不如放行未知字段。
3.3 39 个工具,分成六族
| 工具族 | 工具 | 干什么 |
|---|---|---|
| 工作区 | create_workspace list_workspaces archive_workspace rename_workspace | 开/关并行开发底座(见 05) |
| agent 生命周期 | create_agent send_agent_prompt get_agent_status list_agents cancel_agent archive_agent kill_agent update_agent set_agent_mode get_agent_activity | 指挥另一个 agent(见 03) |
| 终端 | list_terminals create_terminal capture_terminal send_terminal_keys kill_terminal | 开一个真终端并读回屏幕 |
| 工作区脚本 | list_workspace_scripts start_workspace_script stop_workspace_script | 启停 paseo.json 里配置的服务 |
| 定时 | create_schedule create_heartbeat delete_heartbeat list_schedules inspect_schedule pause_schedule resume_schedule delete_schedule update_schedule schedule_logs run_schedule_once | 让未来的自己/别人被唤醒 |
| 其它 | list_providers list_models inspect_provider list_pending_permissions respond_to_permission speak | 发现能力、代批权限、说话 |
另有一族浏览器工具(browser_click、browser_snapshot 等 12 个)只在开关打开时挂载,
由 registerBrowserTools 注入同一个 registerTool(paseo-tools.ts:1202-1209)。
speak 是特例:它只在语音开关打开时注册,并且在 voiceOnly 模式下目录到此为止直接返回
(paseo-tools.ts:1198-1200)——语音 agent 只有一个工具,不给它开 agent 的权力。语音本身的实现
在 server/session/voice/voice-session.ts:1031(registerVoiceBridgeForAgent)把 handler 挂上来,
本章不展开。
3.4 调用者身份会改写 schema
同一个工具名,在「agent 调用」和「外部客户端调用」两种场景下,入参 schema 和默认值不一样。
以 send_agent_prompt 为例(paseo-tools.ts:1139-1141):
| 参数 | agent 调用时默认 | 外部调用时默认 | 为什么 |
|---|---|---|---|
background | true | false | agent 不该阻塞在等待里,它有别的活干 |
notifyOnFinish | true | false | agent 需要被回调唤醒;脚本不需要 |
create_agent 更进一步:agent 调用时没有 background 参数,而外部调用时没有「默认继承调用者工作区」
这层行为(paseo-tools.ts:1011-1039)。判据就一个字段——callerAgentId 是否存在。
cwd 也被同一个身份约束住:resolveScopedCwd(paseo-tools.ts:649-669)在 agent 场景下强制从
父 agent 的 cwd 派生,并尊重 lockedCwd / allowCustomCwd;没有调用者身份时则必须显式传 cwd。
3.5 结果要让模型「看得见」
工具返回 { content, structuredContent } 两份数据。问题是:很多 handler 只填 structuredContent
(结构化 JSON),content(模型可见文本)是空的——模型于是什么都读不到。
addModelVisibleStructuredContent(agent/tools/paseo-tool-serialization.ts:50)补这一刀:
当 structuredContent 有值而 content 为空时,把 JSON 渲染成文本塞进 content。
渲染时还做了一个小优化(paseo-tool-serialization.ts:18,formatStructuredContentForModel):
遇到数组字段就先摘一行 agents_count=3 / agents_ids=a,b,c 的摘要放在完整 JSON 前面。
模型读第一行就能抓到 id,不必啃完整个 JSON。
4. 两条投递路径:同一份目录,两种运法
4.1 为什么要两条
provider 是异构的(见 02)。有的 CLI 支持标准 MCP,有的有自己的 「宿主工具」私有通道。Paseo 不想为此写两份工具,于是把目录和投递拆开。
PaseoToolCatalog(一份)
│
┌───────────────────┴────────────────────┐
│ │
supportsNativePaseoTools = true 其它 provider
│ │
┌────────▼─────────┐ ┌─────────▼────────┐
│ 塞进 launchContext │ │ 注入一条 mcpServers │
│ .paseoTools │ │ 配置指向本机 HTTP │
└────────┬─────────┘ └─────────┬────────┘
│ │
provider 进程内直接持有目录 agent 进程作为 MCP 客户端回连
│ │
setOmpHostTools() 下发定义 GET/POST /mcp/agents
│ │
host_tool_call 事件回来 标准 MCP tools/call
│ │
└──────────► catalog.executeTool() ◄─────┘
4.2 两路对比
| 维度 | ① 原生注入 | ② MCP 适配 |
|---|---|---|
| 触发条件 | provider capabilities.supportsNativePaseoTools === true | 其余全部 |
| 目录怎么到达 | AgentLaunchContext.paseoTools(内存对象) | HTTP URL + Bearer token 写进会话配置 |
| 入参 schema 怎么变 | serializePaseoToolInputParameters 手动转 JSON Schema | 直接把 zod 交给 MCP SDK 的 registerTool |
| 进程边界 | 同进程,零序列化开销 | 跨进程,走本机 HTTP |
| 支持取消 | 支持(host_tool_cancel → AbortController) | 支持(MCP 请求的 signal) |
| 支持流式中间结果 | 支持(sendUpdate → host_tool_update) | 不支持——mcp-server.ts:47 只传 signal |
| 目录状 态 | 会话期常驻 | 每次 HTTP 请求现造一份,响应关闭即销毁 |
最后一行是个值得记住的取舍。MCP 路走的是无状态模式(sessionIdGenerator: undefined),
bootstrap.ts:1389-1394 的注释给了理由:agent 控制面只做「列工具」和「调工具」,没有跨请求状态可留;
留了反而会被那些不干净退出的 agent 永久占住内存。
4.3 MCP 路:一层薄封装 + 一个被特批的路由
createAgentMcpServer(agent/mcp-server.ts:31-52)总共 20 行:造目录、for 循环把每个工具
server.registerTool 一遍、把结果映射成 CallToolResult。它本身不含任何业务逻辑。
麻烦的是这条路的鉴权。守护进程可以设密码,但密码在 App 里设置时只存哈希、明文拿不到—— 注入进 agent 配置的那条 MCP 连接因此没法带密码。解法是一枚每次启动随机生成的能力令牌:
bootstrap.ts:639生成agentMcpAuthToken = randomUUID()。runtime-mcp-config.ts:51-53把它写成注入配置的Authorization: Bearer …。/mcp/agents被从全局密码中间件里摘出去(auth.ts:145),改由isAgentMcpRequestAuthorized(auth.ts:163)单独把关:先常量时间比对能力令牌,不中再退回密码校验。
令牌只写进本机 agent 配置、从不发给远程客户端,所以拿不到就没法离机重放。
调用者身份则挂在 query 上:?callerAgentId=<id>(runtime-mcp-config.ts:50),
路由侧解析后传给目录工厂(bootstrap.ts:1458-1466)。
4.4 原生路:把目录翻译成 provider 的宿主工具
只有 omp 这一个 provider 目前声明 supportsNativePaseoTools: true
(agent/providers/omp/agent.ts:462-467)。AgentManager 的判据是三个条件同时成立
(agent/agent-manager.ts:4781-4787):
if (
this.paseoToolsEnabled &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
) {
context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId });
}
provider 拿到后调 setOmpHostTools(omp/host-tools.ts:48),把目录序列化成宿主工具定义下发;
每个工具都标 loadMode: "essential",意思是不做懒加载、模型一上来就能看见全部
(omp/host-tools.ts:33-46)。
回调侧是一个 OmpHostToolRouter(omp/host-tools.ts:144),按 runtime session 用 WeakMap 缓存。
它比 MCP 路多做三件事:
- 取消:每次调用配一个
AbortController,收到host_tool_cancel就 abort(host-tools.ts:168-175)。 - 迟到结果丢弃:已取消的调用即使 handler later 返回了,也不回传(
host-tools.ts:205-212)。 - 流式中间结果:handler 通过
sendUpdate推host_tool_update帧(host-tools.ts:226-233)。
4.5 互斥:注入原生就得摘掉内置 MCP
两条路同时开会让同一批工具在模型眼里出现两次。所以原生注入成功时,内置的那条 MCP 配置要被拆掉
(agent-manager.ts:4791-4796):
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
stripInternalPaseoMcpServer(runtime-mcp-config.ts:6)删得很克制:只删名字叫 paseo
并且 URL 路径正好是 /mcp/agents 的那一条(isInternalPaseoMcpServer,runtime-mcp-config.ts:60)。
用户自己配的、恰好也叫 paseo 的外部 MCP server 不会被误伤。
另一个细节:注入是运行时行为,不写回持久化配置。withRuntimePaseoMcpServer
(runtime-mcp-config.ts:29)先 strip 再加,产出的是一份临时 launch config。
mcp-parity.e2e.test.ts:394-404 正是断言这一点——launch config 里有 mcpServers.paseo,
而落盘快照和内存 agent 里都没有。
4.6 一致性靠什么保证
第一层是结构: 两条路调的是同一个 catalog.executeTool,同一份 zod schema,同一个
addModelVisibleStructuredContent。语 义分叉的空间被压到只剩「传不传 sendUpdate」。
第二层是端到端测试: agent/mcp-parity.e2e.test.ts(943 行)起一个真守护进程,
用真 MCP HTTP 客户端连两个端点——匿名的 /mcp/agents 和带 ?callerAgentId= 的——
再跨五个套件把工具挨个跑一遍:
| 套件 | 覆盖 |
|---|---|
| A: Core Fixes | 父子标签、MCP 注入 URL、provider/model 语法、feature 透传 |
| B: Terminal Tools | 建终端、送键、抓屏 |
| C: Schedule Tools | 建/查/暂停/恢复/删定时 |
| D: Provider Tools | 列 provider 与模型 |
| E: Worktree Tools | worktree 工作区的建与归档 |
测试里的 fake provider 被显式设成 supportsNativePaseoTools: false
(对照 agent/agent-mcp.e2e.test.ts:108),把执行强制压到 MCP 那条路上。
原生那条路则由 omp/host-tools.test.ts 单独覆盖(标 essential、路由、取消丢弃三个用例)。
5. 有了工具之后:四种协作机制
工具只是「能按按钮」。真正让多 agent 跑起来的是下面四套机制。