06 扩展系统学习记录
本节目标
本节是进阶章节。第一遍只想理解 agent 核心主线的人,可以先跳到 07 或回头复习 03/04/05;本节目标是理解 Pi 如何把外部能力装进 agent:skills 如何按需进入上下文,extensions 如何注册工具、命令和生命周期 hook,以及这些能力最后如何接到 AgentSession 和 agent loop。
重点问题:
- Skill 和 Extension 的本质区别是什么。
- 为什么 skill 不是 tool。
/skill:name如何从短命令变成完整 prompt block。- extension loader 如何发现并执行扩展模块。
registerTool()、registerCommand()、on()分别写到哪里。ExtensionRunner如何 bind 到AgentSession。- extension tool 如何进入
agent.state.tools。 - extension hook 如何接进工具调用、上下文转换和 provider 请求。
resources_discover为什么只返回路径,而不是直接返回资源对象。
我们怎么学的
- 先看
packages/coding-agent/src/core/skills.ts,理解 skill 的发现、校验和 system prompt 目录注入。 - 看
AgentSession._expandSkillCommand(),理解/skill:name如何显式展开。 - 看
packages/coding-agent/src/core/extensions/loader.ts,理解 extension 的发现、加载和piAPI。 - 看
packages/coding-agent/src/core/extensions/runner.ts,理解 runtime bind、ctx、command、hook 和 stale 检查。 - 回到
AgentSession._refreshToolRegistry(),串起 extension tool 进入 agent tool registry 的路径。 - 对照
packages/agent/src/agent-loop.ts,看tool_call、tool_result等 hook 实际插入的位置。 - 最后看
resources_discover和ResourceLoader.extendResources(),理解扩展包如何贡献 skills、prompts、themes。
总体图
ResourceLoader
-> 加载 skills / prompts / extensions
Extension loader
-> 执行 extension factory
-> 收集 registerTool / registerCommand / on(...)
ExtensionRunner
-> bind 到 AgentSession
-> 提供运行时 ctx
-> 解析 extension commands
-> emit lifecycle hooks
-> 暴露 registered tools
AgentSession
-> _refreshToolRegistry()
-> setActiveToolsByName()
-> _rebuildSystemPrompt()
-> prompt() 里处理 command / input hook / skill-template expansion
Agent loop
-> transformContext
-> before_provider_request
-> beforeToolCall
-> afterToolCall
一句话:
Skill 是“教模型怎么做”;
Extension 是“给 Pi 增加本地能力、工具和生命周期插槽”。
Mermaid 源码:
flowchart TD
R[ResourceLoader] --> L[Extension loader]
L --> D[register declarations]
D --> Runner[ExtensionRunner]
Runner --> S[AgentSession]
S --> T[tool registry]
S --> P[prompt/input handling]
S --> H[hooks]
T --> A[agent.state.tools]
H --> Loop[agent loop]
五种能力的最短定义
skill
= 教模型怎么做
command
= 用户直接触发的本地控制入口
tool
= 模型可以调用的本地动作
hook
= 插入生命周期的拦截点
resource
= 扩展包贡献的静态资源路径
这些能力都能来自扩展系统,但边界不同:
- skill/template 改 prompt。
- command 先于 prompt 展开,直接执行本地 handler。
- tool 进入 agent loop,由模型 tool call 触发。
- hook 插入现有生命周期,可以观察、改写或 block。
- resource 回到
ResourceLoader,由统一资源管线读取和校验。
Skill:最轻量的能力注入
Skill 是一份带 frontmatter 的 Markdown 文件。Pi 只需要从它里面提取:
name
description
filePath
baseDir
sourceInfo
disableModelInvocation
对应类型:
interface Skill {
name: string;
description: string;
filePath: string;
baseDir: string;
sourceInfo: SourceInfo;
disableModelInvocation: boolean;
}
Skill 的发现位置主要有:
全局:~/.pi/agent/skills
项目:.pi/skills
显式路径:settings / CLI 传入的 skillPaths
loadSkillsFromDirInternal() 的发现规则:
如果目录里有 SKILL.md
-> 这个目录就是 skill root,不继续递归
否则
-> 加载 root 下直接的 .md 文件
-> 递归子目录寻找 SKILL.md
loadSkillFromFile() 做的事:
readFileSync(filePath)
-> parseFrontmatter()
-> validateName()
-> validateDescription()
-> 生成 Skill 对象
Pi 会校验:
name: 小写字母 / 数字 / hyphen,不能太长,不能首尾 hyphen,不能连续 hyphen
description: 必须存在,不能太长
缺 description 的 skill 不会加载;其他校验问题通常变成 diagnostics。
formatSkillsForPrompt()
formatSkillsForPrompt() 不会把所有 SKILL.md 正文塞进 system prompt。
它只放:
name
description
location
形态大概是:
<available_skills>
<skill>
<name>docs-sync</name>
<description>Keep documentation in sync...</description>
<location>/.../docs-sync/SKILL.md</location>
</skill>
</available_skills>
我们总结的原因是:
渐进式披露
= 先暴露目录,命中任务时再 read 具体 SKILL.md
省上下文
= skill 可能很多,正文可能很长,全部注入会污染 system prompt
这也是为什么 skill 是“按需能力”,不是每次都展开的全量规则。
disable-model-invocation 也和这里有关:
disable-model-invocation: true
会让这个 skill 不出现在 formatSkillsForPrompt() 的可见列表里,只能通过 /skill:name 显式调用。
为什么 skill 不是 tool
Tool 是模型可调用的执行接口:
name
description
parameters
prepareArguments
executionMode
execute
Skill 没有 execute(),也没有参数 schema。它不能被模型 tool call。
所以:
tool
= 模型可以调用的动作
skill
= 模型可以读取的指导文档
skill 影响的是“模型怎么想、怎么做”,不是直接执行本地动作。
/skill:name 显式展开
/skill:name 出现在 slash command 列表里,但它不是 extension command。
它是 prompt 预处理。
流程:
/skill:docs-sync 更新 README
-> AgentSession.prompt()
-> _expandSkillCommand()
-> readFileSync(skill.filePath)
-> stripFrontmatter()
-> 包成 <skill ...>...</skill>
-> 拼上用户 args
-> 作为普通 user message 进入 agent.prompt()
模型最终看到的不是:
/skill:docs-sync 更新 README
而是:
<skill name="docs-sync" location="/.../SKILL.md">
References are relative to /.../docs-sync.
这里是 SKILL.md 正文
</skill>
更新 README
prompt() 里的顺序
AgentSession.prompt() 的关键顺序:
raw input
-> extension command 有机会接管
-> input hook 可拦截/改写
-> skill/template 展开
-> agent.prompt()
为什么 extension command 要先处理:
extension command 是本地控制入口,必须先拿到原始 slash 命令;
skill/template 只是 prompt 文本展开,应该在确认没人接管后再做。
extension command 可能根本不需要模型,例如:
打开 UI
切 session
设置状态
newSession / fork / reload
而 /skill:name 和 prompt template 的目标是生成更完整的用户消息,最终仍然交给模型。
这也解释了为什么 steer() / followUp() 里会禁止 extension command:运行中的插队消息只能排进 agent loop,不能直接执行本地 command 控制流。
Extension loader:发现并执行扩展模块
extension 的发现入口在 packages/coding-agent/src/core/extensions/loader.ts。
主线:
discoverAndLoadExtensions()
-> loadExtensions()
-> loadExtension()
-> loadExtensionModule()
-> factory(api)
发现位置:
项目扩展:.pi/extensions/
全局扩展:~/.pi/agent/extensions/
显式配置:configuredPaths
目录支持:
extensions/foo.ts
extensions/foo.js
extensions/foo/index.ts
extensions/foo/index.js
extensions/foo/package.json 里的 pi.extensions
Pi 用 jiti 加载 TS/JS:
jiti.import(path, { default: true })
extension 默认导出必须是 factory function:
export default async function(pi) {
pi.registerTool(...)
pi.registerCommand(...)
pi.on("message_end", ...)
}
加载时:
const extension = createExtension(...)
const api = createExtensionAPI(extension, runtime, cwd, eventBus)
await factory(api)
ExtensionAPI:注册 API 和动作 API
createExtensionAPI() 里有两类 API。
第一类是注册 API,加载时安全:
pi.on()
pi.registerTool()
pi.registerCommand()
pi.registerShortcut()
pi.registerFlag()
pi.registerMessageRenderer()
这些只是写入当前 Extension 对象:
extension.handlers
extension.tools
extension.commands
extension.shortcuts
extension.flags
extension.messageRenderers
第二类是动作 API,依赖运行时:
pi.sendMessage()
pi.sendUserMessage()
pi.appendEntry()
pi.setSessionName()
pi.setActiveTools()
pi.setModel()
pi.exec()
load 阶段不能直接触发 agent 行为。
我们总结为:
load 阶段只允许注册能力,不能触发 agent 行为。
registerTool()
= 登记 metadata
sendMessage()
= 运行时动作,必须等 runner bind 完并进入事件/命令上下文后才能调用
所以 createExtensionRuntime() 先给动作 API 放 throwing stubs:
Extension runtime not initialized.
等 AgentSession 调 runner.bindCore(...) 后,stub 才会被替换成真实动作。
特殊情况是 registerProvider():
load 阶段先进 pendingProviderRegistrations
bindCore() 后 flush 到 modelRegistry
因为 provider 注册也是声明性质,但需要 registry 准备好。
ExtensionRunner:运行时胶水
Loader 负责收集扩展声明,Runner 负责把声明变成运行时资源。
ExtensionRunner 主要做四件事:
bindCore()
-> 把 runtime stub 换成真实 AgentSession 动作
getAllRegisteredTools() / getRegisteredCommands()
-> 汇总 extension 注册的资源
createContext() / createCommandContext()
-> 给事件、工具、命令提供运行时 ctx
emitXxx()
-> 按事件类型派发 extension hooks
AgentSession._bindExtensionCore() 会把真实动作接进去:
sendMessage -> AgentSession.sendCustomMessage()
sendUserMessage -> AgentSession.sendUserMessage()
appendEntry -> sessionManager.appendCustomEntry()
setActiveTools -> setActiveToolsByName()
refreshTools -> _refreshToolRegistry()
setModel -> setModel()
compact -> compact()
为什么 ctx 用 getter + stale 检查
createContext() 返回的 ctx 不是静态快照。
它使用 getter:
ctx.model
ctx.signal
ctx.sessionManager
ctx.ui
每次访问时都从 runner 当前状态取值,并先 assertActive()。
我们总结的原因:
extension ctx 不是“静态配置”,而是运行时入口。
getter
= 每次使用取最新 runtime 状态
stale 检查
= reload / fork / switch 后,旧 ctx 继续用会直接报错
如果创建普通对象快照,会有两个风险:
值不新:
fork / switchSession / reload 后,ctx 还指向旧 session、旧 signal、旧 UI
拦不住旧 ctx:
旧 ctx 继续 appendEntry / setActiveTools / sendMessage,可能写到错误 session 或污染新运行
所以 Pi 做了两层保护:
getter
= 保持读取最新状态
assertActive / invalidate
= 防止旧 ctx 误操作
extension tool 如何进入 agent.state.tools
extension tool 的完整链路:
extension factory
-> pi.registerTool(toolDefinition)
-> extension.tools.set(name, { definition, sourceInfo })
-> runner.getAllRegisteredTools()
-> AgentSession._refreshToolRegistry()
-> wrapRegisteredTools()
-> _toolRegistry
-> setActiveToolsByName()
-> agent.state.tools
extension 注册的仍然是 ToolDefinition,不是 AgentTool。
wrapper.ts 做适配:
wrapToolDefinition(registeredTool.definition, () => runner.createContext())
所以 extension tool 执行时拿到的是运行时 ctx,不是 load 阶段的 api。
_refreshToolRegistry() 会统一处理:
builtin tools
extension registered tools
SDK custom tools
allowed/excluded tool filter
sourceInfo
promptSnippet
promptGuidelines
ToolDefinition -> AgentTool wrap
active tool names
system prompt rebuild
工具启用/禁用不只是改:
agent.state.tools
还要重建:
agent.state.systemPrompt
因为 tool 的 promptSnippet 和 promptGuidelines 也会影响模型行为。
我们对 refreshTools() 的理解:
工具不是单个数组状态。
Pi 维护:
- definition registry
- runtime wrapped registry
- active tools
- system prompt snippets/guidelines
- source metadata
registerTool() 只登记定义;
_refreshToolRegistry() 统一重算工具注册表、active tools、metadata 和 system prompt。
直接 push 到 agent.state.tools 会绕过一致性逻辑。
extension hook 如何接进 agent loop
hook 接入有两条主线。
工具调用 hook
AgentSession._installAgentToolHooks():
this.agent.beforeToolCall = runner.emitToolCall(...)
this.agent.afterToolCall = runner.emitToolResult(...)
agent loop 中的顺序:
prepareToolCallArguments()
validateToolArguments()
beforeToolCall()
tool.execute()
afterToolCall()
createToolResultMessage()
回填 context
所以:
tool_call hook
= 看到校验过的 args,可以 block
tool_result hook
= 看到执行后的结果,可以改 content/details/isError/terminate
为什么 tool_call hook 放在参数校验后:
hook 通常要做权限、策略、审计、拦截;
这些判断需要可信的结构化参数。
如果模型刚开始流式吐 tool call 时就触发,参数可能还没完整。
如果 tool call 完成但还没校验就触发,hook 需要自己处理类型错误、缺字段和非法结构,职责会混乱。
模型请求 hook
SDK 创建 Agent 时会接入:
transformContext -> runner.emitContext(messages)
onPayload -> runner.emitBeforeProviderRequest(payload)
agent loop 调模型前:
transformContext(messages)
-> convertToLlm(messages)
-> build Context
-> streamFn(model, llmContext, options)
-> provider onPayload
边界:
context hook
= 改 AgentMessage[],发生在转 LLM message 前
before_provider_request hook
= 改 provider payload,发生在 provider 适配之后、真正请求前
context 更偏 Pi 内部语义,可以改 user/assistant/toolResult 消息。
before_provider_request 更偏具体 provider API payload,已经是 OpenAI/Anthropic/Google 各自的请求形态。
哪些 hook 能改东西
常见 hook 可以分成几类:
观察类
= 只看事件,不改变主流程
改写类
= 返回新内容,改变消息、上下文或 payload
拦截类
= block / cancel 某个动作
我们重点看过:
message_end
= 可以返回同 role message,改最终持久化/上下文消息
tool_call
= 可以 block 工具调用
tool_result
= 可以改 content/details/isError/terminate
context
= 可以改发给模型的 AgentMessage[]
before_provider_request
= 可以改 provider payload
before_agent_start
= 可以追加 message 或改 systemPrompt
session_before_*
= 可以 cancel
resources_discover:扩展资源索引
resources_discover 不是注册工具或命令。
它让 extension 在启动或 reload 后返回额外资源路径:
{
skillPaths?: string[];
promptPaths?: string[];
themePaths?: string[];
}
触发流程:
session_start 后 / reload 后
-> runner.emitResourcesDiscover(cwd, reason)
-> 收集 skillPaths / promptPaths / themePaths
-> resourceLoader.extendResources(...)
-> updateSkillsFromPaths / updatePromptsFromPaths / updateThemesFromPaths
-> _rebuildSystemPrompt()
它适合一个 extension 包里附带:
skills/
prompts/
themes/
extension 负责告诉 Pi:
我这里还有这些资源,请一起加载。
为什么返回路径,而不是直接返回 Skill 或 Prompt 对象:
resources_discover 只负责“告诉 Pi 去哪里找资源”;
资源怎么读、怎么校验、怎么去重、怎么进 system prompt,由 ResourceLoader 统一处理。
如果让 extension 直接返回对象,就会绕过:
SKILL.md frontmatter 校验
name / description 规则
路径解析和 baseDir
sourceInfo 标注
冲突诊断
ignore 规则
prompt/template 解析
system prompt 重建
所以 resources_discover 是“扩展资源索引”,不是“资源对象注入”。
06 能力对照表
| 能力 | 本质 | 入口 | 最后进入哪里 |
| --- | --- | --- | --- |
| skill | 指导文档 | SKILL.md / /skill:name | system prompt 目录,或 user message 的 <skill> block |
| prompt template | prompt 文本模板 | /template-name | user message |
| extension command | 本地控制命令 | pi.registerCommand() | 立即执行 command handler |
| extension tool | 模型可调用工具 | pi.registerTool() | _toolRegistry -> agent.state.tools |
| extension hook | 生命周期拦截/观察 | pi.on(event, handler) | agent/session/provider 流程中的事件点 |
| resource / resources_discover | 扩展资源索引 | pi.on("resources_discover") | ResourceLoader 继续加载 skills/prompts/themes |
核心区别:
skill/template
= 改 prompt 文本
command
= 本地立即执行,不一定进模型
tool
= 给模型调用,进入 agent loop
hook
= 插入现有生命周期,可能观察,也可能改写/block
resources_discover
= 让扩展包贡献更多静态资源
常见误解
- skill 不是 tool。skill 没有参数 schema 和
execute(),它只能影响模型如何理解任务。 - command 不是 prompt template。command 是本地控制入口,会先于 skill/template 展开被处理。
registerTool()不等于直接改agent.state.tools。中间还要经过_refreshToolRegistry()、wrap、过滤、active tools、system prompt rebuild。- hook 不是只能观察。部分 hook 可以改写 message/context/provider payload,也可以 block 工具或 cancel session 行为。
resources_discover不是资源对象注入。它只返回路径,资源读取、校验、去重和 sourceInfo 仍由ResourceLoader负责。
自测题
- 为什么 skill 不是 tool?
- extension command 为什么先于 prompt 展开?
- extension tool 如何进入
agent.state.tools? - hook 能改哪些东西,哪些只是观察?
- 为什么资源发现必须回到
ResourceLoader?
本节完成度
本节已经完成:
- 理解 skill 的渐进式披露和省上下文设计。
- 理解
/skill:name是 prompt 展开,不是代码执行。 - 理解 extension loader 如何发现和执行 TypeScript/JavaScript 扩展。
- 理解 registration API 和 runtime action API 的区别。
- 理解
ExtensionRunner的 bind、ctx、stale 检查和 event emit。 - 理解 extension tool 如何进入
_toolRegistry和agent.state.tools。 - 理解 tool hooks、context hook、provider payload hook 的边界。
- 理解
resources_discover如何把扩展包资源接回 ResourceLoader。
下一节建议进入 SDK/RPC:
06 看 Pi 如何扩展自己的能力;
07 看 Pi 如何把同一套 agent 能力暴露给其他进程或应用。
Source: 06-extension-system-notes.md