π Pi Agent Study

06 扩展系统学习记录

本节目标

本节是进阶章节。第一遍只想理解 agent 核心主线的人,可以先跳到 07 或回头复习 03/04/05;本节目标是理解 Pi 如何把外部能力装进 agent:skills 如何按需进入上下文,extensions 如何注册工具、命令和生命周期 hook,以及这些能力最后如何接到 AgentSession 和 agent loop。

重点问题:

我们怎么学的

总体图

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:最轻量的能力注入

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.

AgentSessionrunner.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 的 promptSnippetpromptGuidelines 也会影响模型行为。

我们对 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:

我这里还有这些资源,请一起加载。

为什么返回路径,而不是直接返回 SkillPrompt 对象:

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
= 让扩展包贡献更多静态资源

常见误解

自测题

本节完成度

本节已经完成:

下一节建议进入 SDK/RPC:

06 看 Pi 如何扩展自己的能力;
07 看 Pi 如何把同一套 agent 能力暴露给其他进程或应用。

Source: 06-extension-system-notes.md