技能:Markdown 指引
职责
技能 (skill) 在 OpenClaw 里不是可调用的函数,而是一段 Markdown 指引——具体就是一个 SKILL.md 文件。系统在构建系统提示时,把所有可用技能的 name/description/location 列成一段 <available_skills> XML 塞进 prompt;模型看到任务匹配某技能的 description 时,自己用 read 工具把那个 SKILL.md 读出来,按里面写的步骤行事。
这套机制看起来很"轻",但它和工具系统有本质区别:工具是系统注册的函数,模型只能调名字;技能是模型自己决定读不读的文档。技能的内容可以是任意 Markdown——操作步骤、检查清单、术语表、代码模板——模型读完之后该用工具就用工具、该写代码就写代码,技能本身不参与运行时调用。
技能系统负责的事情因此也很纯粹:发现 (discovery) 找到所有 SKILL.md 文件、加载 (loading) 解析 frontmatter 出 Skill 对象、注入 (injection) 把摘要塞进系统提示、生命周期 (lifecycle) 管理技能的安装/归档/上传。模型自己负责剩下的"读不读、怎么用"。
设计动机
为什么不让技能也成为工具?因为工具的调用是确定性的——参数 schema、入参校验、executor 逻辑都得固定,每次调同名工具行为一致。但很多任务指引天然是叙述性的:"遇到 bug 先看日志、再复现、再定位"这种流程没法塞进 JSON schema。让模型用自然语言理解指引,再灵活组合工具调用,比硬塞成函数更合身。
另一个动机是懒加载 (lazy loading)。技能正文可能很长(几十 KB 的操作手册),但系统提示里只需要塞摘要。模型只在任务确实匹配时才 read 全文,大部分对话里技能正文根本不进上下文窗口。formatSkillsForPrompt 在 skill-contract.ts formatSkillsForPrompt:L34-L58 里只输出 name/description/location/version 四个字段,正是这个原因。
<version> 字段是关键设计:它是 SKILL.md 内容的稳定标记。系统提示里明确告诉模型:"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it."(src/skills/loading/skill-contract.ts:L41-L43)——技能作者改了内容,模型下一轮就该重新读,而不是凭旧版本记忆乱来。
source 字段保留来源标识(openclaw-bundled / workspace / plugin 等),disableModelInvocation frontmatter 字段允许技能作者声明"模型不能自动调用,只能用户显式触发",这是为一些需要明确许可的高风险操作留的口子。
关键文件
skill-contract.ts Skill:L5-L15—Skill类型定义,包含 name/description/filePath/baseDir/promptVersion/sourceInfo。skill-contract.ts formatSkillsForPrompt:L34-L58— 把 Skill 数组格式化成<available_skills>XML 塞进 prompt。local-loader.ts loadSingleSkillDirectory:L38-L89— 单个技能目录的加载:读 SKILL.md、解析 frontmatter、组装 Skill。local-loader.ts loadSkillsFromDirSafe:L107-L151— 批量加载入口,失败转 diagnostics 不抛错。local-loader.ts readSkillFileSync:L16-L36— 通过openRootFileSync在技能 root 边界内读文件,防 symlink 逃逸。system-prompt.ts 技能注入:L70-L74— 系统提示构建时把技能列表用formatSkillsForPrompt拼进去。system-prompt.ts hasRead 守卫:L169-L172— 只有 read 工具可用时才注入技能段,否则模型读了也读不动。tool-dispatch.ts resolveSkillDispatchTools:L59-L249— 技能触发工具调用时按多层策略过滤工具集。install.ts installSkill:L458— 技能安装入口,从 archive/source/upload 等渠道拉技能。openclaw-provider-index.ts:L13-L69— 内置 provider 索引,可类比技能预声明模式。
数据流
技能加载从文件系统开始(local-loader.ts 加载单个目录:L38-L89):
function loadSingleSkillDirectory(params: {
skillDir: string;
source: string;
rootRealPath: string;
maxBytes?: number;
}): LoadedLocalSkill | null {
const skillFilePath = path.join(params.skillDir, "SKILL.md");
const raw = readSkillFileSync({
rootRealPath: params.rootRealPath,
filePath: skillFilePath,
maxBytes: params.maxBytes,
});
if (!raw) {
return null;
}
let frontmatter: Record<string, string>;
try {
frontmatter = parseFrontmatter(raw);
} catch {
return null;
}
const fallbackName = path.basename(params.skillDir).trim();
const name = frontmatter.name?.trim() || fallbackName;
const description = frontmatter.description?.trim();
if (!name || !description) {
return null;
}
const invocation = resolveSkillInvocationPolicy(frontmatter);
// ...
return {
skill: {
name,
description,
filePath,
baseDir,
promptVersion: computeSkillPromptVersion(raw),
source: params.source,
sourceInfo: createSyntheticSourceInfo(filePath, { /* ... */ }),
disableModelInvocation: invocation.disableModelInvocation,
},
frontmatter,
};
}注意三道闸:读文件用 openRootFileSync(src/skills/loading/local-loader.ts:L16-L36),它会把路径解析到 root 真实路径上,防止 symlink 逃出技能目录;frontmatter 解析失败静默丢弃这个技能,不让一个坏技能拖死整个加载流程;name 和 description 都必须存在,缺一个就跳过。computeSkillPromptVersion 对内容算一个稳定标记,后面塞进 <version> 字段。
loadSkillsFromDirSafe(src/skills/loading/local-loader.ts:L107-L151) 是上层包装,先尝试把传入的 dir 当成单技能目录加载,失败再当作父目录枚举子目录批量加载。这种"先看自己是不是技能,再看是不是技能集合"的双层逻辑,让 skills/ 根目录(单技能)和 skills/coding-agent/(子技能)两种布局都能工作。
加载完的 Skill 数组最终在 system-prompt.ts 注入:L70-L74 被拼进 prompt:
// Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}这里有个关键守卫:只有 read 工具在激活列表里时才注入技能段。否则模型看到 <available_skills> 也读不动 SKILL.md,反而会陷入"知道有却调不到"的尴尬。formatSkillsForPrompt(src/skills/loading/skill-contract.ts:L34-L58) 产出的 XML 大致是:
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use the absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of skills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
if (skill.promptVersion) {
lines.push(` <version>${escapeXml(skill.promptVersion)}</version>`);
}
lines.push(" </skill>");
}
lines.push("</available_skills>");注意第三行明确告诉模型:"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it."——版本变了就重读,别凭记忆。
技能被用户显式触发(走 chat command)或模型自动决定读时,会进入 tool-dispatch.ts resolveSkillDispatchTools:L59-L249。这个函数不是"执行技能",而是"按技能触发的上下文过滤工具集"——比如 coding-agent 技能触发时,主循环要给子 agent 配哪些工具,得走一遍 profile/global/group/sender/sandbox/subagent/inherited 多层策略:
const tools = createOpenClawTools({
agentSessionKey: params.sessionKey,
// ... 大量上下文
pluginToolAllowlist: collectExplicitAllowlist(explicitPolicyList),
pluginToolDenylist: explicitDenylist,
// ...
});
const policyFiltered = applyToolPolicyPipeline({
tools,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: logVerbose,
steps: [
...buildDefaultToolPolicyPipelineSteps({ /* profile/provider/global/agent/group/sender */ }),
{ policy: sandboxPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
{ policy: inheritedToolPolicy, label: "inherited tools" },
],
declaredToolAllowlist: buildDeclaredToolAllowlistContext({ /* ... */ }),
});注意注释里写了"Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 stays closed across allow/deny, group, sandbox, and subagent policy layers"——这是个安全 seam,技能触发的工具调用必须和普通工具调用走同一套策略管线,不能因为"技能触发的"就绕过 allowlist。
边界与失败
- name/description 缺失即丢弃:
loadSingleSkillDirectory里if (!name || !description) return null;(src/skills/loading/local-loader.ts:L64-L66)。技能没有 description 模型就不知道该什么时候用它,这种技能等于没用,加载阶段直接丢。 - frontmatter 解析失败丢弃:
try { parseFrontmatter(raw) } catch { return null; }——一个坏 frontmatter 不拖死整个加载流程,但这个技能就不可见。loadSkillsFromDirSafe把所有失败转成"该技能不出现"而不是抛错,保证整体可用。 - symlink 逃逸防御:
readSkillFileSync通过openRootFileSync在技能 root 真实路径边界内打开文件(src/skills/loading/local-loader.ts:L16-L36)。这防止恶意技能目录里放一个指向/etc/passwd的 symlink,然后被read工具读出去。 - read 工具未激活时不注入:
hasRead && skills.length > 0是注入的硬条件(src/agents/sessions/system-prompt.ts:L71-L74)。如果用户的工具 allowlist 把read砍掉了,系统就不告诉模型有技能——避免"看得见吃不到"。 - disableModelInvocation:
resolveSkillInvocationPolicy(frontmatter)解析这个字段。某些高风险技能(比如直接执行任意 shell 的技能)作者可以声明"模型不能自动调,必须用户显式 chat command",系统在注入段就不会把它放进<available_skills>。 - promptVersion 漂移检测:
computeSkillPromptVersion对 SKILL.md 内容算稳定标记,版本变了模型下一轮该重读。这是为热重载场景设计的:开发者改了技能内容,新一轮对话模型不会凭旧版本记忆乱来。 - 技能不是工具的执行体:
resolveSkillDispatchTools看起来像"执行技能",其实它只做工具集过滤,真正"按技能干活"的还是模型 + 工具。技能系统从不直接调用工具,这是和 工具系统 最大的边界。
小结
技能是注入系统提示的 Markdown 指引,不是可调用函数。加载阶段用边界读防 symlink 逃逸、用 frontmatter 解析失败丢弃容错、用 promptVersion 检测内容漂移;注入阶段只在 read 工具可用时塞 <available_skills> 摘要,正文由模型按需 read。技能触发的工具调用走 resolveSkillDispatchTools 多层策略管线,和普通工具调用共用同一套 allow/deny 规则——这条 seam 关着 GHSA-mhm4-93fw-4qr2。和 工具 的区别:工具是 Map 里的函数,技能是 Markdown 指引;和 插件 的区别:插件是按目录约定发现的扩展包,技能是插件可以携带的一种内容形式(插件 skills/SKILL.md 字段)。