Book 第十五章:Agent 与 Skill 如何改写运行时
第四部分:扩展与协作

第十五章:Agent 与 Skill 如何改写运行时

把 AgentTool 和 SkillTool 放在一起看,理解 Claude Code 怎样动态扩展执行主体和执行策略。

1. 为什么这两个要放在一起拆

前面几篇文档拆的,大多还是“当前这个 agent 自己怎么干活”。

到了 AgentToolSkillTool,味道就完全变了。

它们不再只是“多一个能力”,而是在改运行时本身:

  • AgentTool 会创建新的执行主体
  • SkillTool 会改写当前执行主体接下来的行为边界

所以这两个工具放在一起看,才能真正看清 Claude Code 的一个关键设计判断:

Claude Code 不是单个模型循环 + 一堆工具,而是一个可以动态扩展执行主体和执行策略的 agent runtime。

2. 先说结论

我对这两个工具的判断可以压成两句话:

  • AgentTool 的本质是:把“再开一个 agent 去做事”做成正式工具协议
  • SkillTool 的本质是:把“加载一段专门工作说明并改变当前上下文”做成正式工具协议

如果换成更偏架构的话说:

  • AgentTool 扩展的是 execution subject
  • SkillTool 扩展的是 execution context

这两个东西一组合,Claude Code 就不再只是“一个会调工具的大模型”,而是一个:

  • 可以自己分工
  • 可以自己装载专门能力
  • 可以按上下文切换工作模式
  • 可以把不同执行单元隔离开

的运行时系统。

3. 源码锚点

这次主要看的源码是:

  • src/tools/AgentTool/AgentTool.tsx
  • src/tools/AgentTool/prompt.ts
  • src/tools/AgentTool/UI.tsx
  • src/tools/SkillTool/SkillTool.ts
  • src/tools/SkillTool/prompt.ts
  • src/tools/SkillTool/UI.tsx

配套依赖还包括:

  • src/tools/AgentTool/runAgent.ts
  • src/tools/AgentTool/loadAgentsDir.ts
  • src/tools/AgentTool/forkSubagent.ts
  • src/tools/AgentTool/agentToolUtils.ts
  • src/utils/forkedAgent.ts
  • src/utils/processUserInput/processSlashCommand.js
  • src/commands.js

4. 总体结构图

flowchart TD
    A["当前主 agent"] --> B{"选择哪条扩展路径"}

    B -->|"AgentTool"| C["创建新的执行主体"]
    B -->|"SkillTool"| D["改造当前执行上下文"]

    C --> C1["选择 fork / specialized agent / teammate / remote"]
    C1 --> C2["校验 agent 类型、权限、MCP 依赖、隔离方式"]
    C2 --> C3["重建子 agent 的工具池与运行上下文"]
    C3 --> C4["同步完成 / 后台运行 / 远端运行"]
    C4 --> C5["把结果映射回父 agent transcript"]

    D --> D1["查找本地 skill / MCP skill / 远程 canonical skill"]
    D1 --> D2["校验 skill 名、类型、disable-model-invocation"]
    D2 --> D3["按 skill 名做权限决策"]
    D3 --> D4["展开 slash command 或加载 SKILL.md"]
    D4 --> D5["向当前会话注入 newMessages + contextModifier"]
    D5 --> D6["后续回合按新上下文继续执行"]

这张图里最重要的一点是:

这两个工具都不是“立即返回一个字符串答案”。

它们真正做的,是改 Claude Code 后面怎么运行。

5. AgentTool 不是辅助函数,而是真正的子 agent 启动器

src/tools/AgentTool/AgentTool.tsxcall() 往下看,AgentTool 做的事情远比“spawn 一个子任务”复杂得多。

它至少有四种执行分支:

  • 启动普通同步 sub-agent
  • 启动后台 async agent
  • 启动 teammate / team agent
  • 启动 worktree 或 remote 隔离 agent

而且它不是把一个 prompt 扔给别的模型就完了,它会重新做一遍子 agent 的运行时配置:

  • 选 agent definition
  • 算最终 model
  • 检查当前权限模式
  • 检查能不能 background
  • 检查能不能 team spawn
  • 检查 required MCP server 是否真的可用
  • 为子 agent 重建 tool pool
  • 生成新的 agent id / worktree / output file / progress tracker

也就是说,子 agent 不是一个“模型调用分支”,而是一个新的 runtime instance。

6. AgentTool 的设计重点

6.1 它先解决“该不该生这个 agent”

这层判断很重,主要包括:

  • team_name + name 是否真的是 teammate spawn
  • 当前是不是 teammate,teammate 能不能继续生 teammate
  • 当前是不是 in-process teammate,它能不能开 background
  • 指定的 subagent_type 是否存在
  • 这个 subagent_type 是否被权限规则 deny
  • 这个 agent 要求的 MCP server 是否都已连接且已认证

Claude Code 在这里特别谨慎的一点是:

它不会因为 MCP server “正在连接中” 就立刻失败。

如果发现需要的 MCP server 还在 pending,它会轮询等待一段时间,再决定能不能启动。这个处理很 runtime,不是简单 schema 校验。

6.2 fork 和 fresh subagent 是两条明确不同的路

AgentTool 里一个很关键的设计是:

  • 指定 subagent_type,表示启动一个 fresh specialized agent
  • 不指定 subagent_type,在 fork gate 打开时,表示 fork 自己

这两个分支的设计目标完全不同。

fresh agent 的假设是:

  • 它没有当前上下文
  • 需要你把背景讲清楚
  • 它更像“新拉进来的同事”

fork agent 的假设是:

  • 它直接继承当前上下文
  • 适合把中间噪音留在子上下文里
  • 重点是节省主 agent 的上下文预算

所以 src/tools/AgentTool/prompt.ts 专门花了很大篇幅讲:

  • 什么时候应该 fork
  • 什么时候不要 fork
  • fork prompt 应该写 directive,而不是背景复述
  • 不要在 fork 完成前“偷看结果”
  • 不要猜测 fork 的结论

这说明 Claude Code 很清楚:多 agent 系统最大的问题不是“能不能开”,而是“开了以后父 agent 会不会胡乱同步、胡乱猜结果”。

6.3 它把“结果未返回之前,父 agent 不知道答案”写成了正式规则

这是我觉得 AgentTool 最成熟的一点。

它不是默认父 agent 可以“差不多知道子 agent 在干嘛”,而是明确约束:

  • 子 agent 没回,就等于不知道
  • 用户中途追问,也只能报状态,不能猜结论
  • 如果是后台 agent,告诉用户“我启动了什么”就该停

也就是说,Claude Code 把“不要脑补异步任务结果”写进了工具协议和 prompt 规范里。

这对 C# 版很重要,因为很多人做多 agent 时最容易犯的错,就是把子任务还没返回时的推测,当成了真实信息。

6.4 它会重建子 agent 的工具池,而不是粗暴继承

从实现上看,子 agent 的工具不是父 agent 原样拷过去,而是会重新经过 assembleToolPool(...) 之类的装配逻辑。

这意味着子 agent 的工具可见性依赖于:

  • agent definition 自身的 allowlist / denylist
  • 当前权限上下文
  • MCP 可用性
  • fork / built-in / worker 场景

也就是说,Claude Code 的 agent 不是“共享一份万能工具箱”,而是每个执行主体都有自己的工具视图。

这点非常值得在 C# 版里保留。

7. AgentTool 的额外 runtime 处理

除了“能启动 agent”之外,Claude Code 还给它加了很多额外处理。

7.1 权限语义不是简单 allow/deny

AgentTool.isReadOnly() 返回的是 true,这看起来有点反直觉,因为子 agent 明明可能去改文件、跑 bash。

但 Claude Code 的意思是:

AgentTool 自己不直接做副作用,它只是委派。真正的副作用权限,交给子 agent 里的具体工具去判。

这是一种“包装器只声明自己行为,不替子工具背锅”的设计。

7.2 并发安全也被单独声明

AgentTool.isConcurrencySafe() 返回 true

意思不是“所有子 agent 都不会冲突”,而是:

  • 这个工具协议本身允许并发发起
  • 真正的冲突控制靠子任务隔离、worktree、提示词约束和后续调度

所以 Claude Code 在这里分清了两层:

  • 工具调用协议是否能并发
  • 具体任务内容是否会互相踩

7.3 自动模式下走 passthrough

AgentTool.checkPermissions() 在 auto mode 下会返回 passthrough,其他模式直接 allow

这说明 Claude Code 对“再开一个 agent”这件事是有单独敏感度的:

  • 普通模式默认更宽松
  • 自动模式下交给更上层的 classifier / 审批逻辑继续判断

换句话说,AgentTool 不是完全免审批的。

7.4 结果映射不是一种,而是四种

mapToolResultToToolResultBlockParam() 至少区分了几类结果:

  • teammate_spawned
  • remote_launched
  • async_launched
  • completed

completed 里又会根据情况追加:

  • agentId
  • worktree 信息
  • token / tool use / duration usage trailer

但如果是一次性 built-in agent,还会主动省掉 trailer,减少 token 浪费。

这个细节很说明问题:

Claude Code 不把 tool result 当“日志原样输出”,而是当 prompt 预算和后续行为的控制面。

8. AgentTool 的 UI 不是装饰,而是降噪层

src/tools/AgentTool/UI.tsx 里有个非常 runtime 的设计:它会主动折叠子 agent 的搜索、读取、REPL 类进度。

比如它会把连续的:

  • 搜索
  • 读文件
  • 子工具结果

压缩成更高层的摘要。

这是因为多 agent 一旦开始跑,transcript 很容易被子 agent 的工具噪音淹没。

所以 Claude Code 在 UI 层专门做了几件事:

  • 折叠连续 search/read/repl 消息
  • 只展示最近几条进度
  • 支持 condensed 风格
  • 避免 user tool_result 在终端里刷出大片空白

这说明它把“可读性”当成运行时设计的一部分,而不是前端美化问题。

9. SkillTool 本质上是 slash-command runtime

如果说 AgentTool 是“生一个新的执行主体”,那 SkillTool 做的就是:

把一条 skill 命令扩展成当前会话后续要遵循的一段工作协议。

src/tools/SkillTool/SkillTool.ts 看,它至少做了四层事情:

  • 发现 skill
  • 校验 skill
  • 审批 skill
  • 把 skill 展开进当前对话上下文

它的核心不是“执行脚本”,而是:

  • 找到 skill 对应的 prompt command
  • 把 skill 的内容转成新的消息
  • 必要时修改当前上下文里的允许工具、模型、effort

所以 skill 更像“热加载工作模式”,不是普通命令。

10. SkillTool 的设计重点

10.1 skill 是阻塞要求,不是可选建议

src/tools/SkillTool/prompt.ts 里写得很重:

当用户请求和某个 skill 匹配时,这是一个 BLOCKING REQUIREMENT

意思是:

  • 先调 SkillTool
  • 再继续回答
  • 不能嘴上提到 skill 却不真的调用

这件事很关键,因为 Claude Code 明确不希望模型“看见 skill 目录,但继续靠自己编”。

10.2 它拿到的不是只有本地 skill

getAllCommands(context) 会把 skill 来源合并起来:

  • 本地 command / skill
  • bundled skill
  • MCP skill

但它又明确只允许 MCP skills 进来,不让 plain MCP prompts 混进 SkillTool

这个边界很重要,因为它说明 Claude Code 不是“凡是 prompt 都叫 skill”,而是把可被 SkillTool 调用的能力单独分类。

10.3 validateInput() 做的是协议级校验

这部分至少做了这些事情:

  • 去掉前导 /,兼容 slash command 习惯
  • 校验 skill 名不能为空
  • 支持实验性的远程 canonical skill _canonical_<slug>
  • 校验 skill 是否存在
  • 拒绝 disable-model-invocation
  • 要求命令必须是 prompt-based skill

也就是说,SkillTool 不是一个“万能命令调度口”,它明确只接受某一类命令对象。

10.4 它的权限模型不是按工具,而是按 skill 名

这是 SkillTool 最有意思的地方。

它的权限规则匹配对象不是“整个 SkillTool”,而是:

  • 精确 skill 名
  • skill 名前缀,比如 review:*

并且顺序也很讲究:

  1. 先查 deny
  2. 再处理远程 canonical skill 的自动放行
  3. 再查 allow
  4. 再判断是不是只包含 safe properties
  5. 否则 ask

这意味着 Claude Code 把 skill 看成“可审计的能力名字空间”,不是一把大锤。

10.5 safe skill 会自动放行

skillHasOnlySafeProperties(...) 这套逻辑很关键。

Claude Code 的思路是:

  • 不是所有 skill 都要每次问
  • 但只有显式确认安全属性集合内的 skill,才能自动放行
  • 新增属性默认视为不安全,除非被加入 allowlist

这是一种很稳的默认策略。

11. SkillTool 真正返回的不是“结果”,而是“环境变了”

call() 的实现看,inline skill 的核心输出不是一段最终答案,而是两样东西:

  • newMessages
  • contextModifier

这里的设计非常重要。

11.1 newMessages

skill 展开后,会往当前会话里注入新的消息。

这些消息可能来自:

  • processPromptSlashCommand(...) 展开的 prompt
  • 本地 skill 的内容
  • 远程 skill 加载后的 SKILL.md 内容

也就是说,skill 的主要效果是把新的“操作说明”喂回当前回合上下文。

11.2 contextModifier

这部分更像运行时补丁,它会改后续上下文:

  • 把 skill 指定的 allowedTools 注入 alwaysAllowRules.command
  • 如果 skill 指定了 model,就覆写 mainLoopModel
  • 如果 skill 指定了 effort,就覆写 effortValue

这说明 SkillTool 干的不是“做完一个动作”,而是“把当前 agent 切换到一种新的执行姿态”。

所以它最后映射成 tool result 时,inline 分支只是:

  • Launching skill: xxx

真正重要的内容不在这句文案,而在前面那批 newMessages + contextModifier

12. forked skill:SkillTool 自己也会借 AgentTool 的路子

SkillTool 里一个很值得注意的点是:

如果 skill 的 command context 是 fork,它不会在当前上下文里 inline 展开,而是转去 executeForkedSkill(...)

这条路径会:

  • 先用 prepareForkedCommandContext(...) 准备 skill 上下文
  • 生成独立 agent id
  • 直接调用 runAgent(...)
  • 采集子 agent 的 tool use / tool result 进度
  • 最后抽取结果文本返回

也就是说,skill 并不总是“当前 agent 的提示词宏”。

有些 skill 会进一步变成“用一个隔离 agent 跑完这套 skill 协议”。

这再次说明 Claude Code 的 skill 系统并不是壳子语法,而是运行时一级公民。

13. 远程 canonical skill 说明 Claude Code 在做“技能分发”

SkillTool 里还有一条更平台化的路线:远程 canonical skill。

它的流程大致是:

  • 先通过 _canonical_<slug> 命中远程 skill 名
  • 要求这个 skill 已经在当前 session 里被 discover 过
  • loadRemoteSkill(...) 从远程拉内容并做本地缓存
  • 去 frontmatter
  • 注入 base directory
  • 替换 ${CLAUDE_SKILL_DIR} / ${CLAUDE_SESSION_ID}
  • addInvokedSkill(...) 注册到 compaction 保留状态
  • 最后把内容直接包成 user meta message 注入当前上下文

这说明 Claude Code 已经不满足于“本地技能目录”了,它其实在往“技能市场 / 技能分发网络”方向走。

换句话说,skill 不是本地文件夹技巧,而是平台能力包。

14. AgentToolSkillTool 的真正区别

可以把它们并排看:

维度AgentToolSkillTool
目标新建执行主体改当前执行上下文
核心产物子 agent runtime新消息 + 上下文修饰
典型场景分工、并行、隔离、后台运行装载专门工作协议
主要风险父子上下文错位、结果猜测、任务冲突skill 越权、重复加载、技能污染
Claude Code 的控制点agent 类型、MCP、隔离、结果映射、UI 降噪skill 名权限、safe property、prompt 展开、contextModifier

所以你后面做 C# 版时,千万不要把它们统一成一个 InvokeExtensionAsync()

它们长得都像“扩展能力”,但其实修改的是两层完全不同的运行时结构。

15. Claude Code 做了哪些额外处理

把前面零散细节收一下,大概有这些:

  • AgentTool 会过滤 denied agent,不是所有 agent definition 都对模型可见
  • AgentTool 会检查 required MCP server 是否真的有工具,不是“连上了就算可用”
  • AgentTool 对 fork child 做递归保护,避免无限 fork
  • AgentTool 会区分 sync / async / remote / teammate 四类结果映射
  • AgentTool 会在 UI 层折叠子 agent 的搜索和读文件噪音
  • SkillTool 会把 MCP skill 纳入统一命令视图,但排除 plain MCP prompt
  • SkillTool 会校验 disable-model-invocation
  • SkillTool 会按 skill 名和前缀做权限决策,不是只看工具名
  • SkillTool 会把安全属性 skill 自动放行
  • SkillTool 会把 skill 对运行时的修改显式编码到 contextModifier
  • SkillTool 会把远程 skill 内容做本地缓存和 compaction 保活

这些额外处理合起来说明一件事:

Claude Code 真正在构建的是“可治理的扩展运行时”,不是“模型能顺手用的几个高级命令”。

16. 转成 C# 时我建议怎么建模

如果你后面准备做 C# 版,我建议至少拆成下面几层接口:

public interface IAgentLauncher
{
    Task<AgentLaunchResult> LaunchAsync(AgentLaunchRequest request, CancellationToken ct);
}

public interface ISkillRuntime
{
    Task<SkillExecutionResult> ExecuteAsync(SkillExecutionRequest request, CancellationToken ct);
}

public interface IAgentRegistry
{
    Task<IReadOnlyList<AgentDefinition>> GetAvailableAgentsAsync(CancellationToken ct);
}

public interface ISkillRegistry
{
    Task<IReadOnlyList<SkillDefinition>> GetAvailableSkillsAsync(CancellationToken ct);
}

public interface IContextMutation
{
    RuntimeContext Apply(RuntimeContext context);
}

然后在语义上明确分开:

  • AgentLaunchResult 表示启动了谁、在哪里跑、是否后台、结果什么时候回
  • SkillExecutionResult 表示注入了哪些消息、修改了哪些上下文字段

我会特别建议你保留这几个概念,不要拍扁:

  • AgentDefinition
  • SkillDefinition
  • PermissionDecision
  • ContextModifier
  • BackgroundTaskHandle
  • IsolationMode
  • ToolPoolSnapshot

其中 ContextModifier 很关键。

因为如果没有这个概念,你最后很容易把 skill 退化成“返回一个 prompt 字符串”,那就丢了 Claude Code 最有意思的一层设计。

17. 我的总体评价

拆完这两个工具之后,我对 Claude Code 的判断更明确了:

它真正先进的地方,不是“会开子 agent”或者“会读 SKILL.md”,而是它把这两件事都纳入了统一 runtime 语义。

也就是:

  • 什么时候能扩展
  • 扩展成什么
  • 扩展后权限怎么算
  • 扩展后 UI 怎么展示
  • 扩展后 transcript 怎么回灌
  • 扩展后 compaction 怎么保活

这些问题它都认真处理了。

所以如果你后面要做 C# 版,我会建议把这两个子系统看成 Claude Code 从“工具型 agent”跨到“平台型 agent runtime”的分界线。

这是非常值得抄的设计思路。