从零构建 Coding Agent
English
本章目录

14. 扩展系统

当 Agent 开始被不同团队使用,你会不断收到定制需求:加一个内部搜索工具,拦截危险命令,改 system prompt,给状态栏显示 token 成本,保存任务完成后的报告。把这些需求都合并进核心代码,会让内核膨胀。扩展系统的目标是把可定制点产品化。

扩展不是脚本目录

扩展系统至少要提供三类能力:

  • 生命周期钩子:监听 session、turn、model request、tool call、compaction、shutdown。
  • 注册能力:注册工具、命令、快捷键、CLI 标志、状态展示、消息渲染器。
  • 运行上下文:访问 cwd、会话、事件、UI、配置和安全 API。

扩展应该通过受控 API 影响 Agent,而不是直接改内部对象。否则一个扩展就能破坏日志、事件和权限模型。一个常见形态是工厂函数:扩展导出一个函数,运行时传入一个 pi 对象,扩展在里面注册钩子和能力。

export default function myExtension(pi: ExtensionApi): void {
  pi.on("tool_call", async (event, ctx) => {
    if (isDangerous(event.call)) {
      return { block: "This command is blocked by policy." };
    }
  });
  pi.registerTool(internalSearchTool);
  pi.registerCommand("report", { handler: writeReport });
}

生命周期钩子

一组实用钩子可能包括:

project_trust
session_start
before_agent_start
before_model_request
after_model_response
tool_call
tool_result
session_before_compact
session_shutdown

有些钩子只是通知,有些钩子可以阻断或改写。必须明确每个钩子的语义。tool_call 可以返回 block 阻止工具执行、也可以改写参数;before_agent_start 可以在模型请求前修改消息;session_before_compact 可以提供自定义摘要;turn_start 类通知钩子不允许改写状态。

阻断类钩子默认 fail closed:扩展报错时宁可阻止危险操作,也不要继续执行。这一点在权限相关钩子上尤其重要——一个抛异常的安全扩展如果被当成“放行”,就等于没有安全扩展。相对地,纯观察类钩子(统计、日志)报错不应该拖垮整个 turn,捕获并记录即可。

注册工具

扩展注册工具时,需要提供和内置工具同等质量的定义:名称、描述、schema、执行函数、输出截断、错误语义、可选 UI 渲染器。自定义工具如果会写文件,必须参与同一文件写队列;否则它会和内置 edit/write 互相覆盖。声明了执行模式(并行安全还是必须串行)的工具,由内核执行器统一调度,扩展不需要自己加锁。

工具描述也要进入 system prompt 的“可用工具”部分。否则模型不知道何时使用扩展工具。描述应明确工具名,不要写“使用这个工具”,因为模型在平铺提示词里可能不知道“这个”指谁。

声明式扩展面:技能、命令与提示词模板

不是所有扩展点都需要写代码。成熟系统会把几类高频定制做成声明式格式,让不写 TypeScript 的用户也能扩展 Agent:

  • skills(技能):一个带 YAML frontmatter 的 Markdown 文件(约定名如 SKILL.md),frontmatter 至少声明 name 和 description。名称和描述要有长度上限(比如 name 不超过 64 字符、description 不超过 1024 字符),因为它们会进入 system prompt 的技能目录——无上限的描述会悄悄吃掉上下文预算。技能正文是模型按需加载的指令,不常驻上下文。
  • slash commands(斜杠命令):把一段常用提示词或一串固定操作绑定到一个 /name,用户输入命令时展开成对应的 user 消息或动作。
  • prompt templates(提示词模板):带占位符的可复用提示词,运行时用参数填充后作为用户输入注入。

这三者的共同点是:它们是数据,不是代码,因此更容易分发、审阅和限制。但它们仍受同一套边界约束——技能目录只在加载工具激活时才注入(第十一章),项目本地的声明式资源同样受项目信任门控(未信任的项目不会自动加载它的 SKILL.md)。声明式不等于无害:一份技能文件里的文字同样可能是 prompt 注入,运行时要把它当项目内容而非系统指令对待。

自定义消息类型

扩展常常需要往会话里放一些不是标准对话的东西:一次本地命令执行、一份生成的报告、一条通知。这依赖第六、八章埋下的两个能力——自定义消息 entry,加上 convertToLlm 投影钩子。扩展声明一个自定义消息类型,指定它是否进入模型上下文、如何渲染;投影时,内核把不该进上下文的类型过滤掉,把该转换的类型改写成标准消息。

这样扩展既能持久化自己的状态(写进会话日志、恢复后仍在),又不会污染发给模型的上下文。UI 通过配套的消息渲染器把这些自定义 entry 画成合适的样子。

扩展与安全

扩展本身通常是任意代码。安装扩展等于信任它能访问进程权限范围内的资源。这正是第 12 章项目信任的用武之地:未信任的项目不应该自动加载和执行项目本地扩展。产品必须把这个事实告诉用户,并区分项目扩展、用户全局扩展和内置扩展——三者的信任级别不同。

扩展 API 还要防止绕过权限门。比如扩展注册了一个 dangerous_shell 工具,它仍应经过 tool call 权限检查。否则用户禁用了内置 bash,却被扩展工具绕过。扩展能扩展能力,但不能扩展出权限模型的例外。

自托管检验

判断扩展 API 是否足够好的方法是:用它实现一部分产品自身能力。例如:

  • 状态栏通过扩展 API 注册。
  • 权限确认通过 tool_call 钩子实现。
  • 自定义压缩通过 session_before_compact 实现。
  • 危险命令拦截通过 tool_call 钩子实现。
  • 自动 checkpoint 通过 turn_startsession_shutdown 实现。

如果这些需求都必须改核心代码,说明扩展面还不够完整。成熟系统往往真的把不少内置功能做成了扩展,这既是对 API 的压力测试,也让第三方能替换或增强这些功能。

练习

实现最小扩展系统。

验收标准:

  • 扩展可以注册一个只读工具,工具描述进入 system prompt。
  • 扩展可以在 tool call 前拒绝 bash 命令(fail closed)。
  • 扩展可以在 system prompt 中追加一段工具指南。
  • 扩展可以注册一个自定义消息类型,通过投影钩子决定它是否进入模型上下文。
  • 扩展错误会形成明确事件,观察类钩子报错不拖垮 turn。
  • 未信任项目下,项目扩展不会自动运行。