Skills 不是工具:从目录发现到渐进注入
沿固定版本的 SkillsService、root loader、metadata renderer 与 explicit injection path,拆开模型可见的 Skills 目录和按 mention 读取的 SKILL.md 正文,说明它们各自的 owner、缓存、禁用与 Tool 边界。
第 19 章把 network target 的控制链收到了 reviewer:execution attribution、NetworkApprovalService、hook,再到 Guardian 或用户。Skills 进入模型时走的是另一条链。它先回答“有哪些可用的说明”,再回答“这一轮是否被明确要求读取哪一份说明”。如果把这两个问题压成一张列表,后面每一步都会看起来像自动执行。
两个 context artifact,不是一份列表
固定版本里至少有两个名称相近、owner 完全不同的 context artifact;它们是两个独立产物,不是把同一份列表换一个包装:
| artifact | owner | 何时生成 | 谁消费 | 里面有什么 |
|---|---|---|---|---|
| metadata catalog | SkillsService、build_available_skills、AvailableSkillsInstructions | thread 启动时从 HostSkillsSnapshot 生成 developer fragment | 模型的 developer context | 名称、描述、source locator 或 alias、可选的使用说明;不含每个 SKILL.md 的全文 |
| explicit body injection | collect_explicit_skill_mentions、build_skill_injections、SkillInstructions | 每次 turn 在拿到用户输入后,只有选出 mention 才生成 | 模型的 user context | 被选中 SKILL.md 的完整文本、名称与路径;读失败变成 warning |
第一个产物是目录视图,第二个产物才是正文。SkillMetadata 保存声明正文在哪里、属于哪个 scope、是否允许 implicit invocation,以及可选的界面、依赖和 policy metadata;它不把正文预先塞进模型。HostSkillsSnapshot 还保留了“这个路径应该由哪个 filesystem 读取”的映射,所以发现和读取可以在不同时间发生。
flowchart LR
accTitle: Skills 从发现到两种 context artifact
accDescr: SkillsService 从多个 roots 形成带缓存的 SkillMetadata snapshot;build_available_skills 将允许出现在 implicit catalog 的 metadata 交给 developer context,而当前 turn 的显式 mention 才触发读取 SKILL.md 并交给 user context;这两条路径都不调用 ToolRegistry
ROOTS["configured skill roots"] --> LOAD["root loader + SKILL.md discovery"]
LOAD --> SNAP["SkillMetadata / HostSkillsSnapshot"]
SNAP --> CATALOG["AvailableSkillsInstructions\ndeveloper context"]
INPUT["UserInput: Skill, $name, or linked path"] --> SELECT["collect_explicit_skill_mentions"]
SELECT --> READ["build_skill_injections\nread SKILL.md"]
READ --> BODY["SkillInstructions\nuser context"]
SNAP -. metadata only .-> SELECT
CATALOG -. no registration .-> BOUNDARY["ToolRegistry / ToolRouter\nseparate tool plane"]
BODY -. no registration .-> BOUNDARY
roots 先确定发现边界
SkillsService 并不从当前目录随便递归。loader::skill_roots 先把配置层、插件 roots、额外 roots 和仓库沿途的 .agents/skills 合并,再按 path 去重。固定 tag 里的普通来源包括:项目 .agents/skills、为兼容保留的 $CODEX_HOME/skills、用户的 $HOME/.agents/skills、$CODEX_HOME/skills/.system 缓存,以及 system/admin scope 的 skills 目录。plugin root 会带上 plugin id、namespace 和 plugin root,之后才交给同一个 loader。
真正扫描 root 时还有两个硬边界:从 root 起最多六层,单个 root 最多访问 2000 个目录;只有文件名为 SKILL.md 的普通文件进入 parse 阶段。用户、repo 和 admin scope 可以跟随目录 symlink,system scope 则忽略 symlink。可选的 agents/openai.yaml 只补充 interface、dependencies 和 policy metadata;它读不到或解析失败时,loader 仍然保留 SKILL.md,不会把整项发现抹掉。
plugin snapshot 还有一层容易混淆的 cache。root_loader 只在 plugin id、namespace 和 plugin root 都齐全时用这些字段组成 PluginSkillRoot key;命中后复用整份 SkillRootSnapshot,否则才重新 load_skill_root。这不是“模型已经拥有正文”的证明,而是发现阶段的预解析快照。
目录视图只描述能力,不注册 Tool
目录 artifact 的 owner 是 AvailableSkillsInstructions。它把 AvailableSkills 已经渲染好的 skill_lines 放进一个 developer fragment;有 alias root 时还先输出 roots 表,再输出 ### Available skills。因此模型看到的是名称、描述和 locator,以及一段“如何使用 skills”的治理说明,而不是脚本进程、资源句柄或可调用 executor。
SkillMetadata 与 available skills instructions 只给模型一张目录视图;它们不会把脚本、资源或 Skill 正文注册或执行为 ToolRegistry / ToolRouter 中的 Tool。
固定版本 rust-v0.144.6 的边界也很窄:被发现的 Skill script 不会自动变成 Tool;它仍要经过后续 ToolSpec、ToolRouter 和执行器各自的注册路径。
build_available_skills 还会先过滤 disabled skill 和 allow_implicit_invocation = false 的项,再按 metadata budget 截断描述或省略条目;这解释了为什么“被发现”不等于“出现在模型目录”。这里的 implicit 只影响目录可见性和隐式使用的候选集合,不会把正文自动读入当前 user message。
SkillMetadata 里确实有 dependencies.tools,但它仍是声明字段:类型只保存 dependency type、value、transport、command、url 等字符串。固定版本没有一条从 SkillMetadata 或 SkillToolDependency 直接调用 ToolRegistry::from_tools 的路径。后者属于另一套 ToolRouter 构建:先收集 PlannedTools,再生成 model-visible specs 和 registry。两者都可能在同一个 turn 出现,但 owner 与生命周期不同。
所以固定版本的结论要写窄:发现了一个 Skill 目录、其中的脚本或资源,最多说明 loader 能定位 SKILL.md 及其关联文件;它不等于 ToolRegistry/ToolRouter 注册,更不等于已经执行。第 21 章再处理 MCP 与 Tool 的实际暴露和路由。不要把“模型看到了能力声明”倒推成“机器已经拥有能力”。
显式 mention 才打开正文读取
正文路径发生在 turn 内,而不是目录渲染阶段。session::turn 先把当前 UserInput 交给 collect_explicit_skill_mentions:结构化的 UserInput::Skill 按路径解析,文本里的 $skill-name 和 linked resource path 再按名称或精确 path 匹配。得到 mentioned_skills 后,才调用 build_skill_injections;它为每个选中项找到发现该项的 filesystem,读取 SKILL.md 全文,并构造 SkillInjection。
SkillInjection 随后被转换成 SkillInstructions。这个 fragment 的 role 是 user,边界标记是 <skill>...</skill>,body 里才放 name、path 和刚刚读到的 contents。也就是说,目录里的描述不会被误当成正文;正文也不会因为目录里存在某个 name 就自动追加。
turn 的调用顺序也把这个边界写得很直白:先收集 mention,再处理可能缺失的 MCP dependency,再读取 injection,再转成 SkillInstructions;如果某个 extension 已经按同一 host path 注入过,当前 turn 还会用 InjectedHostSkillPrompts 过滤一次。普通 ToolRouter 的构建在另一条函数里进行,二者没有共享一个“把 Skill 变成 Tool”的步骤。
这里有一个不能省略的默认开启分支:Feature::SkillMcpDependencyInstall。固定版本把它标为 Stable 且 default_enabled: true。不过 feature 开启还不是充分条件:调用必须来自 first-party originator,当前 turn 必须选出显式 mention,并且对应 Skill 声明了尚未安装的 MCP dependency。缺失依赖会先按 session 去掉已经询问过的项;剩余项可能触发用户询问,当前 approval/permission 组合允许自动批准时也可能直接继续安装。
安装路径会写全局 MCP 配置,必要时执行 OAuth 登录;若带 scopes 的第一次登录失败且满足 retry 条件,还会无 scopes 再试一次。最后它调用 refresh_mcp_servers_now 刷新 session MCP runtime。它不是 Skill 正文的自动执行,但已经是一个真实的配置和认证副作用。
时序边界可以写得更确定。run_turn 在 dependency preflight 之前已经捕获 first_step_context,并把同一个对象传给第一次 sampling;StepContext 又持有这一步的 McpRuntimeSnapshot 和一次性初始化的 tool list。因此 refresh 不会改写第一次 sampling 已捕获的 MCP 视图,也不会把新 ToolSpec retroactively 塞进正在构造的 router。
只要当前 turn 进入后续 sampling,循环就会重新 capture StepContext。最常见的原因是工具 follow-up 或 steer;auto-compaction 后续运行、stop hook continuation、invalid-image 清洗重试也会经过同一条重捕获路径。届时新的 runtime 才可能进入下一张工具表。若第一轮直接结束,新依赖要到后续 turn 才有机会暴露。
去重、禁用、roots 与 cache 都有 owner
这条路径至少有四种“看起来都像去重”的状态,不能合并成一个模糊的 cache:
| 边界 | owner | 固定版本的行为 |
|---|---|---|
| mention 选择 | collect_explicit_skill_mentions | seen_names / seen_paths 阻止同一项重复加入;linked path 优先,plain name 只有唯一 Skill 且不撞 connector 才接受;disabled path 直接跳过 |
| host extension 注入 | InjectedHostSkillPrompts | 保存规范化和原始 path,避免 legacy path 与 extension 已注入的同一 SKILL.md 再发一次 |
| root snapshot | SkillsService 与 PluginSkillSnapshots | plugin preload 按 plugin identity cache;service 另外按 cwd 或有效 roots/config cache HostSkillsSnapshot |
| 正文读取 | build_skill_injections | 只对本次 selected skills 读文件;本函数没有把正文写入 metadata catalog cache |
SkillsService 负责 host discovery、immutable snapshot、extra roots 和 cache invalidation。snapshot_for_config 的 key 包含有效 roots、scope、plugin identity 与 skill config rules,避免同一个 cwd 的 session override 串线;snapshot_for_cwd 在有 filesystem 时才走 cwd cache。设置 extra roots 会清 cache,clear_cache 同时清 cwd/config 两张表。
这里的“disabled”也有两个层次:禁用路径会从 implicit catalog 候选和 explicit mention 选择中消失,但已经由 extension 提供的 host prompt 还需要 path 去重;这不是一次全局删除。相反,roots 变化只应该让下次 snapshot 重新发现,不能把上一次已读正文偷偷写回新的 catalog。把几个 owner 分开,才能解释为什么“关掉一个 Skill”不会顺手改变 ToolRouter 里已有的 MCP 或 shell tool。
指定实验:确认真实的 Skill injection test
实验目标不是证明所有 Skills 行为,而是确认真实 integration target 确实把一个 repo Skill 的正文放进 user input。先在同一个 shell 运行第四部源码工作台的完整 archive/lock 校准脚本;它会导出大写的 ARCHIVE_DIR、ARCHIVE_CODEX_RS 和 run_checked_test。不要只复制下面几行到另一个 shell,否则校准后的副本和 helper 都不存在。下面的 fail-closed 断言确保工作目录和 Cargo target 都在 disposable archive 内,固定 checkout 只负责确认源码身份。
repo="${CODEX_SOURCE_DIR:-/tmp/codex-handbook-final-rust-v0.144.6}"
: "${ARCHIVE_DIR:?run the Part 4 shared preparation first in this shell}"
: "${ARCHIVE_CODEX_RS:?the Part 4 preparation must export ARCHIVE_CODEX_RS}"
export CARGO_TARGET_DIR="$ARCHIVE_DIR/target"
test -d "$ARCHIVE_CODEX_RS"
test "$CARGO_TARGET_DIR" = "$ARCHIVE_DIR/target"
repo_status="$(git -C "$repo" status --porcelain)"
test -z "$repo_status"
cd "$ARCHIVE_CODEX_RS"
run_checked_test skill 1 suite::skills::user_turn_includes_skill_instructions \
just --set rust_min_stack 16777216 test --locked -p codex-core --test all \
suite::skills::user_turn_includes_skill_instructions --no-capture
校准时,默认栈的命令已经找到真实 target,但随后以 stack overflow / SIGABRT 结束。这是 harness failure,不是 zero-match,所以不能把它写成通过。共享命令只增加线程栈,不改变源码或测试语义;下面是省略耗时与 skipped 数量的 nextest 归一化摘录:
PASS ... codex-core::all suite::skills::user_turn_includes_skill_instructions
Summary ... 1 test run: 1 passed
run_checked_test 还会检查完整测试名、pass count,并拒绝 skip 文本。这份结果证明的是一条可复现的 body-injection harness:测试先写入 .agents/skills/demo/SKILL.md,提交 $demo 与结构化 UserInput::Skill,再断言请求的 user texts 同时含 <skill>、canonical path 和 skill body。若某次运行没有命中目标测试,shared gate 会失败;zero-match 不构成成功证据。
第 19 章的 network approval 不等于 Skills injection
第 19 章的 network approval 决定“一个已经发起的 network target 能不能继续”,owner 是 proxy policy、NetworkApprovalService 和 reviewer 链。本章的 Skills injection 把目录或说明正文放进 prompt,owner 是 SkillsService、turn 的 mention collector 和 context fragment;正文里的命令与脚本不会因为注入而自动执行。显式 Skill 声明的 MCP dependency 是单独的例外分支:它受 first-party、feature 与 approval/permission gate 控制,最多安装配置并刷新 runtime,仍不能绕过后续 MCP 工具自己的网络、审批和执行治理。
交界处只有一句需要带到后面:本章即使走完 dependency gate,也只推进到 MCP 配置、认证和 session runtime refresh;资源发现后的 ToolSpec 暴露、ToolRouter 路由与实际调用留到 第 21 章:外部 MCP Server 的工具怎样进入 Codex。阅读顺序转入第 21 章;第 19 章:网络访问为什么另有一条决策链只作为对照,提醒网络审批与 Skills injection 是两条相邻但不互相覆盖的控制链。