源码 ~/code/pi,HEAD be26e32,pi-coding-agent 0.85.1。MCP 侧用 pi-mcp-adapter 2.33.0(~/.pi/agent/npm/node_modules/pi-mcp-adapter/)。模型 token-plan/qwen3.8-flash,--thinking off。观测扩展把每次 provider 请求的摘要追加成 jsonl,跑法与产物在 ~/wordpress/agent/experiments/mcp-skills/(./run.sh p1)。
一个请求里三块内容各管各的,先把它们分开:
| 内容 | 落在请求的哪里 | 什么时候变 |
|---|---|---|
| 工具定义 | 顶层 tools |
active 工具集合变化 |
| Skill 目录 | system prompt 尾部的 <available_skills> |
资源重载或 active 集合变化触发重建 |
| Skill 正文 | messages |
模型 read 之后,或 /skill: 展开成 user 消息 |
| MCP 工具的 schema | tools(directTools)或 messages(代理模式下 search 的返回文本) |
取决于配置与调用 |
| MCP 工具的返回 | messages |
每次调用 |
MCP:一个扩展把服务器工具搬进 Pi 的工具表
pi 核心没有 MCP,README.md:499 的 Philosophy 一节写着 No MCP.,理由是写 CLI 加 README 就够(docs/skills.md 那条路)。要用 MCP 就装扩展,扩展拿到的是 pi.registerTool(),跟内置工具走同一张注册表。
注册
pi.registerTool() 把定义塞进扩展自己的 extension.tools,紧接着调 runtime.refreshTools()(src/core/extensions/loader.ts:287-294)。刷新最终落到 AgentSession._refreshToolRegistry():内置工具与扩展工具一起包成 AgentTool 进 _toolRegistry,再算出 active 名字集合调 setActiveToolsByName()(src/core/agent-session.ts:2747-2786)。这个方法做两件事:写 agent.state.tools(980 行,决定请求里的 tools)和重建 system prompt(983 行,决定 Available tools 与 Guidelines 里出现哪些行)。
注册表与 active 集合是两回事。pi.getAllTools() 读注册表,pi.getActiveTools() 读当前集合;内部的最终判定是工具名是否出现在 agent.state.tools。只有 active 工具会进入下一次模型请求,注册但 inactive 的定义仍可由 pi.setActiveTools() 启用。实测一轮(runs/p2-surface.jsonl,没有传 --tools):
registered 12: read bash powershell edit write grep find ls mcpScript probe-stub_stub_echo probe-stub_stub_time mcp
active 8: read bash edit write mcpScript probe-stub_stub_echo probe-stub_stub_time mcp
Pi 的标准内置 active 集合是 read、bash、edit、write(src/core/sdk.ts:256-262)。powershell 是 Windows 下的 shell 入口,grep、find、ls 是可选的只读工具;它们都建进注册表,但标准集合没有选中。扩展工具在这轮里被加入 active,所以两个直连 MCP 工具、mcpScript 与 mcp 都进了请求。
扩展加载期注册的三种形态,区别是把 MCP 能力包装成 Pi 工具时采用哪一级粒度。它们不是三类 MCP 协议方法,也不要求全局三选一:
| 形态 | 注册代码 | tools 里的样子 |
解决的问题 |
|---|---|---|---|
| 单代理 | index.ts:1378 registerProxyTool() |
全部服务器共用一个 mcp |
常驻一个 schema,按需搜索与调用 |
| 命名空间代理 | namespace-tools.ts:197 |
每个代理服务器一个 mcp__<server>,收 {tool, args} |
按服务器分组、激活和路由,又不展开服务器内的全部工具 |
| 直接工具 | index.ts:327 registerDirectTool() |
每个 MCP 工具一个 Pi 工具,parameters 来自 inputSchema |
让模型直接看到具体名字与参数 schema |
三种形态不是三项互斥配置。directTools 控制的是服务器内部工具是否采用“直接工具”形态;全局 mcp 默认仍可同时存在,proxy-only 服务器有有效缓存时还会生成命名空间代理:
directTools |
默认注册组合 | 具体工具的 active 状态 |
|---|---|---|
false 或省略 |
单代理 mcp 加命名空间代理 mcp__<server> |
不注册为直接工具 |
true |
单代理 mcp 加直接工具 |
全部立即 active |
string[] |
单代理 mcp 加指定的直接工具 |
指定工具立即 active |
"search" |
单代理 mcp 加全部直接工具,不生成该服务器的命名空间代理 |
初始 inactive,搜索命中后 active |
这里的“加”表示这些注册形态可以共存;设置 disableProxyTool 后,全局 mcp 在满足条件时可以隐藏。"search" 不是第四种注册形态,而是直接工具的延迟激活策略。
粒度越细,请求里的 schema 越多;粒度越粗,调用前越依赖搜索。代理模式下服务器工具的 schema 不进 tools,靠 mcp({search}) 的返回文本进 messages。直接模式反过来:schema 从缓存读出来,注册成独立的 Pi 工具。
以 probe-stub 的两个工具为例,三种调用都列全:
MCP 服务器里的原名:
stub_echo { value: string }
stub_time {}
单代理:
mcp({ tool: "probe-stub_stub_echo", args: { value: "hello" } })
mcp({ tool: "probe-stub_stub_time", args: {} })
命名空间代理:
mcp__probe_stub({ tool: "stub_echo", args: { value: "hello" } })
mcp__probe_stub({ tool: "stub_time", args: {} })
直接工具:
probe-stub_stub_echo({ value: "hello" })
probe-stub_stub_time({})
前两种都是代理调用,tool 字符串负责选择具体工具,args 只在目标选定后作为参数传入。即使 stub_time 也接收 {value: string},适配器仍按 stub_echo 或 stub_time 分派,不会根据参数形状猜工具。单代理从带服务器前缀的全局名字解析出服务器与原名;命名空间代理已经由 mcp__probe_stub 固定服务器,所以 tool 使用服务器内原名。
两个服务器同时存在时,单代理和命名空间代理的差别落在外层路由。假设有 github: get_issue 与 playwright: browser_click,单代理只需一个 active 的 mcp:
mcp({ search: "get issue", server: "github" })
mcp({
tool: "github_get_issue",
args: { owner: "microsoft", repo: "vscode", issue_number: 123 }
})
mcp({
tool: "playwright_browser_click",
args: { element: "Submit button", ref: "e42" }
})
search 不传 server 时查全部已知 MCP 工具元数据,传 server: "github" 时只查 GitHub。元数据可以来自磁盘缓存,也可以来自连接后的刷新。命名空间代理不提供另一套搜索,搜索仍走全局 mcp;调用改为服务器级入口:
mcp__github({
tool: "get_issue",
args: { owner: "microsoft", repo: "vscode", issue_number: 123 }
})
mcp__playwright({
tool: "browser_click",
args: { element: "Submit button", ref: "e42" }
})
有有效缓存且两个服务器都是 proxy-only 时,请求可能同时带这三项:
mcp
mcp__github
mcp__playwright
这里注册的是一个全局代理和两个服务器级代理,没有触发 directTools: true,也没有触发 directTools: "search"。get_issue 与 browser_click 仍只是代理参数中的目标名,不是顶层 Pi 工具。实际使用中三项可以共存:mcp 管搜索、状态与连接,mcp__<server> 管固定服务器的调用。
两个服务器、两条用户消息的完整请求序列
同一组服务器分别使用下面两份配置。配置一是“单代理加命名空间代理”,搜索只返回 schema;配置二是“单代理加直接工具”,具体工具采用 "search" 延迟激活。
配置一,单代理加命名空间代理:
{
"mcpServers": {
"a": { "command": "server-a", "lifecycle": "lazy", "directTools": false },
"b": { "command": "server-b", "lifecycle": "lazy", "directTools": false }
}
}
配置二,单代理加直接工具:
{
"mcpServers": {
"a": { "command": "server-a", "lifecycle": "lazy", "directTools": "search" },
"b": { "command": "server-b", "lifecycle": "lazy", "directTools": "search" }
}
}
两份请求序列都假设 a、b 已有有效的元数据缓存。默认文件是 ~/.pi/agent/mcp-cache.json;设置 PI_CODING_AGENT_DIR 后,位置变为 $PI_CODING_AGENT_DIR/mcp-cache.json。每个服务器的条目保存 configHash、tools、resources、prompts、instructions 与 cachedAt。
缓存来自成功连接后的协议结果。适配器完成 initialize,取得 tools/list、resources、prompts 与 instructions 后写盘;显式 mcp({connect:"a"})、lazy 模式下第一次真实调用、启动连接、重连、列表变化通知和退出前刷新都会走这条更新路径。第一次运行且整个缓存文件不存在时,适配器先创建空文件,再连接所有已启用服务器填充初始元数据,这是 lazy 不在启动时连接的一个初始化例外。
后续会话可以只读缓存完成 search 和 describe。如果缓存文件虽然存在,但新加入的 a 没有条目,搜索得不到 a_tool1,要先执行 mcp({connect:"a"})。缓存还要通过两项校验:configHash 必须匹配当前服务器配置,时间不能超过默认七天或服务器声明的更短 TTL(metadata-cache.ts:36-128、init.ts:277-340,566-634)。
两个 lazy 服务器的缓存里各有两个工具:
服务器 a:
tool1 { value: string } Call server a tool1
tool2 { id: number } Call server a tool2
服务器 b:
tool1 { query: string } Call server b tool1
tool2 { count: number } Call server b tool2
用户先发 U1,模型完成调用并回答;随后用户再发 U2:
U1 = 调用服务器 a 的 tool1,参数 {"value":"first"},返回结果后告诉我。
U2 = 现在调用服务器 b 的 tool2,参数 {"count":2},返回结果后告诉我。
system prompt 里的 Available tools 是 active 工具的文字目录。每一行来自工具注册时提供的 promptSnippet,用于告诉模型当前有哪些入口;顶层 tools 才放 name、description、parameters 等完整调用 schema。active 集合变化时,Pi 会同时重建这段目录和顶层 tools。
下面把 Available tools 的实际内容列在每套配置的请求表之前。表内只保留 model、顶层 tools 与从第一条 user 消息开始累积的 messages;固定的 Pi system prompt 其余部分不重复。model 六次都是 qwen3.8-flash。
配置一:单代理加命名空间代理
缓存有效时,三个 active Pi 工具从第一轮起保持不变。六次请求的 system prompt 都包含同一段:
Available tools:
- mcp: MCP gateway — install by URL, status, search, describe, auth, and single MCP tool calls
- mcp__a: MCP namespace proxy for a
- mcp__b: MCP namespace proxy for b
对应的顶层工具名始终是:
["mcp", "mcp__a", "mcp__b"]
两次搜索和两次调用产生的消息对象如下:
S_A = assistant tool_call:
mcp({"search":"tool1","server":"a"})
R_SA = tool result for S_A:
Found 1 tool matching "tool1": a_tool1, Shape: { value: string }
C_A = assistant tool_call:
mcp__a({"tool":"tool1","args":{"value":"first"}})
R_A = tool result for C_A:
A_TOOL1:first
F_A = assistant:
A_TOOL1:first
S_B = assistant tool_call:
mcp({"search":"tool2","server":"b"})
R_SB = tool result for S_B:
Found 1 tool matching "tool2": b_tool2, Shape: { count: number }
C_B = assistant tool_call:
mcp__b({"tool":"tool2","args":{"count":2}})
R_B = tool result for C_B:
B_TOOL2:2
F_B = assistant:
B_TOOL2:2
每次 provider 请求的字段值:
| seq | model |
tools |
messages,不含 system |
本次模型返回 |
|---|---|---|---|---|
| 1 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1] |
S_A |
| 2 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1,S_A,R_SA] |
C_A |
| 3 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1,S_A,R_SA,C_A,R_A] |
F_A |
| 4 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1,S_A,R_SA,C_A,R_A,F_A,U2] |
S_B |
| 5 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1,S_A,R_SA,C_A,R_A,F_A,U2,S_B,R_SB] |
C_B |
| 6 | qwen3.8-flash |
[mcp,mcp__a,mcp__b] |
[U1,S_A,R_SA,C_A,R_A,F_A,U2,S_B,R_SB,C_B,R_B] |
F_B |
S_A 执行时只查缓存,不连接服务器,也不注册 a_tool1。执行 C_A 时适配器才连接服务器 a,并向它发送 tools/call{name:"tool1",arguments:{value:"first"}}。服务器 b 到 C_B 执行时才连接。六次请求的顶层 tools 始终只有三个代理工具,四个服务器内部工具都没有成为 Pi 工具。
配置二:单代理加直接工具
四个具体工具在加载期已经注册,但初始都是 inactive。两个服务器被视为直接注册,不再生成 mcp__a 与 mcp__b。搜索命中会改变 active 集合,因此 Available tools 分三段变化。
第 1 次请求:
Available tools:
- mcp: MCP gateway — install by URL, status, search, describe, auth, and single MCP tool calls
第 2 至第 4 次请求:
Available tools:
- mcp: MCP gateway — install by URL, status, search, describe, auth, and single MCP tool calls
- a_tool1: Call server a tool1
第 5 至第 6 次请求:
Available tools:
- mcp: MCP gateway — install by URL, status, search, describe, auth, and single MCP tool calls
- a_tool1: Call server a tool1
- b_tool2: Call server b tool2
S_A 的搜索结果执行期间,a_tool1 被追加到 active 集合,后续直接调用它:
D_A = assistant tool_call:
a_tool1({"value":"first"})
D_B = assistant tool_call:
b_tool2({"count":2})
对应的六次请求:
| seq | model |
tools |
messages,不含 system |
本次模型返回 |
|---|---|---|---|---|
| 1 | qwen3.8-flash |
[mcp] |
[U1] |
S_A |
| 2 | qwen3.8-flash |
[mcp,a_tool1] |
[U1,S_A,R_SA] |
D_A |
| 3 | qwen3.8-flash |
[mcp,a_tool1] |
[U1,S_A,R_SA,D_A,R_A] |
F_A |
| 4 | qwen3.8-flash |
[mcp,a_tool1] |
[U1,S_A,R_SA,D_A,R_A,F_A,U2] |
S_B |
| 5 | qwen3.8-flash |
[mcp,a_tool1,b_tool2] |
[U1,S_A,R_SA,D_A,R_A,F_A,U2,S_B,R_SB] |
D_B |
| 6 | qwen3.8-flash |
[mcp,a_tool1,b_tool2] |
[U1,S_A,R_SA,D_A,R_A,F_A,U2,S_B,R_SB,D_B,R_B] |
F_B |
搜索命中时发生的是“激活已注册工具”,不是重新注册。a_tool2 与 b_tool1 没被命中,六次请求里始终没有它们。服务器连接仍推迟到具体直连工具执行:a 在 D_A 时连接,b 在 D_B 时连接。
两套配置都有六次模型请求。每条用户消息各触发搜索、具体工具调用和最终回答三次请求;区别只在搜索后的第二次请求:命名空间代理的 tools 不变,模型调用 mcp__a 或 mcp__b;搜索激活模式会扩张 active 集合,模型直接调用 a_tool1 或 b_tool2。
一个可控的 MCP 服务器
工具型 MCP 服务器至少要有传输层、initialize 握手、服务器名称与版本、能力声明、tools/list 返回的工具描述和 JSON Schema,以及处理 tools/call 的执行函数。使用官方 SDK 时,注册工具并连接 stdio 或 Streamable HTTP transport 即可,SDK 负责 JSON-RPC 和握手。
工具不是服务器能提供的唯一内容。resources/list 与 resources/read 可以暴露 URI 资源,prompts/list 与 prompts/get 可以暴露命名提示模板;这些能力都可选。初始化结果还可带 instructions,说明服务器的使用约束。它们不等于自动追加到模型的 system prompt:pi-mcp-adapter 通过 mcp({instructions: "server"}) 读取 instructions,缓存里的 prompts 注册成 /mcp__<server>__<prompt> 命令,调用后才进入消息。
server/stub-mcp-server.mjs 是手写的最小 stdio 工具服务器,只处理 initialize、tools/list、tools/call 与 ping,其余方法回 -32601,收到的每条请求按行写日志:
const TOOLS = [
{
name: "stub_echo",
description: "Echo the given value back. Deterministic stub tool.",
inputSchema: {
type: "object",
properties: { value: { type: "string", description: "Value to echo" } },
required: ["value"],
},
},
{ name: "stub_time", description: "Return a fixed stub timestamp.", inputSchema: { type: "object", properties: {} } },
];
// initialize / tools/list / tools/call / ping
if (method === "tools/call") {
reply(id, { content: [{ type: "text", text: `STUB_ECHO:${params?.arguments?.value ?? ""}` }], isError: false });
}
fixtures/mcp-proxy.json 只有一条服务器定义,lazy(默认)意味着启动时不连:
{
"mcpServers": {
"probe-stub": {
"command": "node",
"args": ["/home/yangsen/wordpress/agent/experiments/mcp-skills/server/stub-mcp-server.mjs"],
"lifecycle": "lazy"
}
}
}
“服务器连接”与“Pi 工具激活”是两条状态线。lazy 服务器在第一次真实工具调用或显式 mcp({connect: "probe-stub"}) 时启动;有缓存时,search 和 describe 不需要连接。Pi 工具是否激活只看 agent.state.tools:
| 配置 | 连接前可能已经 active 的 Pi 工具 | 首次调用后的变化 |
|---|---|---|
| 默认代理 | mcp;缓存有效时还有 mcp__probe_stub |
服务器连接,顶层 tools 不增加具体 MCP 工具 |
directTools: true |
缓存中的具体 MCP 工具已注册并 active | 调用时才连接服务器,active 集合可以不变 |
directTools: "search" |
具体工具已注册但 inactive | mcp({search}) 命中的具体工具加入 active,下一次请求带其 schema |
因此,服务器已经连接不代表它的每个工具都会进入顶层 tools;反过来,缓存中的直连工具也可以在服务器尚未连接时已经 active。eager 和 keep-alive 会在启动时连接,lazy-keep-alive 在第一次调用时连接并保持,但它们控制的是连接生命周期,不直接决定 Pi 的 active 集合。
PI_MCP_CONFIG_MODE=exclusive 加 --mcp-config(扩展自己注册的 flag,index.ts:663,解析在 utils.ts:80)让这一份配置独占,不动真实配置。跑法:
pi -p --no-session -nc --thinking off --model token-plan/qwen3.8-flash \
--no-extensions -e ~/.pi/agent/npm/node_modules/pi-mcp-adapter/index.ts \
-e ./capture-ext/capture-payload.ts \
--no-skills --tools read,mcp --mcp-config fixtures/mcp-proxy.json \
'PROBE-MCP. Step 1: call the mcp tool with {"search":"probe stub echo"}. Step 2: call the mcp tool with {"tool":"stub_echo","args":{"value":"PROBE-77"}}. Step 3: reply DONE.'
代理模式的实测
4 次请求、3 次 mcp 调用(runs/p1-json-payload.jsonl、runs/p1-events.jsonl):
seq=1 tools=2 bytes=3877 roles=system>user schema=0 result=0
seq=2 tools=2 bytes=3877 roles=system>user>assistant>tool schema=1 result=0
seq=3 tools=2 bytes=3877 roles=...>assistant>tool>assistant>tool schema=1 result=0
seq=4 tools=2 bytes=3877 roles=...>assistant>tool>assistant>tool>assistant>tool schema=1 result=1
tools 从第 1 次到第 4 次没动过,一直是 ["read","mcp"]、3877 字节。MCP 工具的参数定义是第 2 次请求开始出现在 messages 里的,内容是 mcp({search}) 的返回文本,逐字:
Found 2 tools matching "probe stub echo":
probe-stub_stub_echo
Echo the given value back. Deterministic stub tool.
Shape:
{ value: string; }
probe-stub_stub_time
Return a fixed stub timestamp.
Shape:
{}
Shape 那行是 ts-shape.ts 把 JSON Schema 渲成 TypeScript 形状(proxy-modes.ts:661-663)。工具名带服务器前缀,默认 toolPrefix: "server"。中间有一次模型直接用了裸名 stub_echo,扩展没兜住,回的是提示而非异常:
call {"tool": "stub_echo", "args": "{\"value\":\"PROBE-77\"}"}
result Tool "stub_echo" not found. Use mcp({ search: "..." }) to search. Did you mean: probe-stub_stub_echo
改成带前缀的名字之后,服务器侧日志(runs/stub-wire.log):
{"method":"initialize","params":{"protocolVersion":"2025-11-25", ... }}
{"method":"notifications/initialized"}
{"method":"tools/list","id":1}
{"method":"tools/call","params":{"name":"stub_echo","arguments":{"value":"PROBE-77"}},"id":2}
tools/list 与 tools/call 分别是 server-manager.ts:1451 和 proxy-modes.ts:1470 发出的。模型看到的始终是同一个 mcp 工具的调用与返回,MCP 协议那一层在 execute() 内部。
还有一轮不加 --tools 白名单的冒烟跑(runs/p0-smoke-payload.jsonl),模型先调 mcp({}) 看到状态、再 mcp({connect}),缓存填上之后多出一个命名空间代理工具:
seq=1 tools=6 bytes=7264 systemBytes=2843 [read,bash,edit,write,mcpScript,mcp]
seq=2 tools=6 bytes=7264 systemBytes=2843 同上
seq=3 tools=6 bytes=7264 systemBytes=2843 同上
seq=4 tools=7 bytes=7984 systemBytes=2897 [read,bash,edit,write,mcpScript,mcp,mcp__probe_stub]
加载之后调 registerTool() 不需要 /reload,新工具直接进下一轮请求,同时把 system prompt 重建了一次(多的那 54 字节是 promptSnippet 的一行)。
directTools 的对照
fixtures/mcp-direct.json 加一行 "directTools": true,其余不变。第一次请求:
tools=8 bytes=7705 systemBytes=2974
["read","bash","edit","write","mcpScript","probe-stub_stub_echo","probe-stub_stub_time","mcp"]
代理形态的同一轮(同一份命令,配置换成 mcp-proxy.json):
tools=7 bytes=7984 systemBytes=2897
["read","bash","edit","write","mcpScript","mcp","mcp__probe_stub"]
这两个服务器的工具都只有两个,schema 一共 453 字节,反而比 mcp__probe_stub 那个代理工具的 args 描述便宜;system 字节差 77,是两个 promptSnippet 换掉一个的代价。规模上去之后方向会反过来:~/.pi/agent/mcp-cache.json 里 playwright 的 24 个工具定义,按同样方式序列化的长度是 17870 字节,描述文本 1621 字节。适配器 README 给的数是每个直接工具约 150-300 token(README.md:714),代理工具自己约 200 token(README.md:17)。
直接模式的工具定义来自磁盘缓存,不需要连服务器。registerDirectTool() 的 parameters 就是缓存里的 inputSchema(index.ts:333),缓存条目长这样:
{
"name": "stub_echo",
"description": "Echo the given value back. Deterministic stub tool.",
"inputSchema": {
"type": "object",
"properties": { "value": { "type": "string", "description": "Value to echo" } },
"required": ["value"]
}
}
configHash 只覆盖影响工具输出的字段——command、args、env、cwd、url、headers、includeTools、excludeTools 等(metadata-cache.ts:81-112),directTools、lifecycle、idleTimeout 不在里面。哈希不匹配、或者缓存超过 7 天(CACHE_MAX_AGE_MS,metadata-cache.ts:36),代理模式下 mcp__probe_stub 就不注册(namespace-tools.ts:41-44)。
三种注册形态的取舍
| 模式 | 注册时机 | schema 在哪 | 服务器连接 |
|---|---|---|---|
| 代理 | 加载期,一个 mcp |
messages,search 返回文本 |
首次调用时 |
| 命名空间代理 | 缓存有效时,每服务器一个 | messages |
首次调用时 |
directTools: true |
加载期从缓存注册 | tools,每请求都带 |
首次调用时 |
directTools: "search" |
注册但置为 inactive | messages,mcp({search}) 命中后 setActiveTools() 追加(index.ts:361-377) |
首次调用时 |
Skills:不进工具表,进文本
一个 skill 就是一个目录加一个 SKILL.md。loadSkillFromFile() 解析 frontmatter,只留六个字段(src/core/skills.ts:74-81、334-342):
export interface Skill {
name: string;
description: string;
filePath: string;
baseDir: string;
sourceInfo: SourceInfo;
disableModelInvocation: boolean;
}
正文在这一步没有被读进内存结构,filePath 是个路径。名字取 frontmatter 的 name,缺省回落父目录名;description 为空的 skill 直接不加载(skills.ts:328-331)。
formatSkillsForPrompt() 把这三项拼成 XML 尾巴挂到 system prompt 上(skills.ts:355-398,调用点 system-prompt.ts:67、162)。用上面那个 fixture 跑出来的块,逐字:
The following skills provide specialized instructions for specific tasks.
Use the read tool to load a skill's file when the task matches its description.
When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.
<available_skills>
<skill>
<name>probe-marker</name>
<description>Deterministic marker skill for the payload probe. Use only when the prompt contains PROBE-SKILL.</description>
<location>/home/yangsen/wordpress/agent/experiments/mcp-skills/skill-fixture/probe-marker/SKILL.md</location>
</skill>
</available_skills>
那三行使用说明是硬编码的:告诉模型用 read 去取正文。read 不在 active 集合里时改用 bash,两个都没有就整块不发(system-prompt.ts:45、160-163)。
模型自己读
pi -p --no-session -nc --thinking off --model token-plan/qwen3.8-flash \
--no-extensions --no-skills --skill "$PWD/skill-fixture/probe-marker/SKILL.md" --tools read \
'PROBE-SKILL. Step 1: call the read tool on the file listed as the location of the probe-marker skill. Step 2: follow what that file says.'
结果(runs/p3-payload.jsonl):
seq=1 tools=1 bytes=701 systemBytes=2432 index=1 body_in_messages=0
seq=2 tools=1 bytes=701 systemBytes=2432 index=1 body_in_messages=1
tools 两次请求都是 ["read"]。所谓”加载一个 skill”,是第二条请求的 messages 里多了一条 read 的工具结果,正文 92 字节(整个文件 231 字节)。system prompt 一个字节没变。
正文一旦进去就作为那次 read 的工具结果留在消息历史里,后续请求会继续携带,直到被压缩或裁剪。这不表示 Skill 进入了某个永久 active 状态,也不会让 Pi 每轮重新读取文件;后续模型看到的是历史中已有的正文。
/skill:name 展开
同一条命令,消息换成 /skill:probe-marker PROBE-SKILL:
seq=1 tools=1 bytes=701 systemBytes=2432 index=1 body_in_messages=1
一次请求结束。_expandSkillCommand() 在读输入时就把文件读出来、剥掉 frontmatter、包成标签直接进 user 消息(agent-session.ts:1366-1391):
<skill name="probe-marker" location="/home/yangsen/wordpress/agent/experiments/mcp-skills/skill-fixture/probe-marker/SKILL.md">
References are relative to /home/yangsen/wordpress/agent/experiments/mcp-skills/skill-fixture/probe-marker.
# Probe Marker Skill
MARKER-BODY-7731
Reply with exactly DONE. Do not call any other tool.
</skill>
PROBE-SKILL
这条路径不经过模型决策,disable-model-invocation: true 的 skill 只能这样用——那个开关只过滤 prompt 里的目录(skills.ts:356),不影响命令展开。
与 MCP 代理的对照
| MCP 代理模式 | Skills | |
|---|---|---|
| 常驻开销 | 一个工具的 schema,约 200 token | <available_skills> 里 name/description/location |
| 详情进上下文的载体 | mcp({search}) 的工具结果 |
read 的工具结果 |
| 详情来源 | 服务器 tools/list 的缓存 |
磁盘上的 SKILL.md |
| 拿到能力的方式 | 模型再调一次代理工具,扩展转发 | 模型按文本里的步骤调已有工具 |
| 主动把详情放进上下文 | mcp({search})、mcp({describe}),或配置 directTools |
/skill:name |
最后都到 streamFunction
模型侧的出口只有一个。Agent.runPromptMessages() 把 this.streamFunction 作为最后一个参数交给 runAgentLoop(packages/agent/src/agent.ts:414-421),循环里每轮过一次 streamAssistantResponse(agent-loop.ts:212),该函数组好 llmContext 再调它(agent-loop.ts:296-311):
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
// ...
const response = await streamFunction(config.model, llmContext, { ...config, apiKey: resolvedApiKey, signal });
streamFunction 来自 createAgentSession() 传给 Agent 的 streamFn,实现是 modelRuntime.streamSimple()(src/core/sdk.ts:314-341)。往下一层按 model.api 选适配器,工具定义在适配器里转成厂商格式,openai-completions 那一份只取 name、description、parameters(packages/ai/src/api/openai-completions.ts:1470-1503)。label、execute、renderCall 这些字段到不了网络,tool.execute 是 Pi 在本地按名字查表调用的(agent-loop.ts:686)。
所以 MCP 与 Skills 的差别只体现在”往 tools 和 messages 里塞什么”,出口是同一条:
| 场景 | provider 请求次数 | tools 变化 |
|---|---|---|
| 代理模式下 search + call + 收尾 | 4 | 恒定 2 项 / 3877 字节 |
| 直接工具注册后的首轮 | 1 | 8 项 / 7705 字节 |
模型自己 read 一个 skill |
2 | 恒定 1 项 / 701 字节 |
/skill:name 展开 |
1 | 恒定 1 项 / 701 字节 |
不走 streamFunction 的是工具内部那些外呼:client.listTools()、client.callTool()、pi.exec()。MCP 服务器也没能让模型替它干活,除了 sampling:那条路走 modelRegistry.complete()(sampling-handler.ts:68),一次嵌套请求,不经过 runLoop,也没有工具。
控制这块成本的开关是 active 工具集合:setActiveToolsByName() 同时改 tools 和重建 system prompt(agent-session.ts:980-984),一次改动会同时打断工具前缀和 prompt 里更靠前的部分。pi-mcp-adapter 的 directTools: "search" 与 Pi 的延迟加载走的就是这条缝:只在已有集合上做加法,把新定义放在工具结果的位置,Anthropic 与 gpt-5.4+ 这类模型能保住前缀(docs/extensions.md:2365-2392)。
循环本身的控制流在 pi 的 Agent Loop,两种协议下请求体的字段差异在 pi 的两种请求格式。