oh-my-pi 中 manage_skill 工具的实现解析:隔离式受管技能(Managed Skill)的创建、更新与删除
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
manage_skill是 oh-my-pi(一个内嵌 IDE 能力的编码智能体)中用于直接维护“受管技能”(managed skill)的内置工具:它把可复用的操作流程(如某类问题的调试步骤、项目专属工作流)固化为独立目录下的SKILL.md文件,供后续会话像普通技能一样被发现和注入。读完本文,你将掌握它的启用条件、三个 action 的完整行为契约、名字/描述/正文的校验规则,以及源码层面防符号链接逃逸、防硬链接篡改和并发串行化的安全设计。
工具定位:与用户技能(authored skill)彻底隔离
oh-my-pi 的技能体系区分两类来源:
- 用户编写技能(authored skills):存放在
~/.omp/agent/skills等项目或用户技能目录中,由人编写和维护; - 受管技能(managed skills):由
manage_skill工具(以及自动学习相关的learn工具)自动生成的技能文件,统一存放在隔离目录~/.omp/agent/managed-skills下。
核心隔离原则在辅助模块 managed-skills.ts 的头部注释中写得很明确:该目录下所有写入都被限制在getManagedSkillsDir()之内——“自动管理永远不能触碰用户编写的技能”。对应地,manage_skill的模型提示词(manage-skill.md)也反复强调“User-authored skills separate; tool NEVER edits them”。
从源码结构看,受管技能在发现(discovery)层被注册为独立 provider:builtin.ts 中以MANAGED_SKILLS_PROVIDER_ID = "omp-managed"注册了一个优先级最低(MANAGED_SKILLS_PRIORITY = 5)的 provider,无条件扫描managed-skills目录(空目录扫描是 no-op)。这意味着:发现的门是常开的,只有写入(manage_skill)和自动学习提醒(auto-learn 的其他子功能)受autolearn.enabled门控。
启用条件与注册可见性
工具元数据定义在 manage-skill.ts:
| 属性 | 值 | 含义 |
|---|---|---|
name | manage_skill | 工具名 |
approval | "write" | 写类工具,走审批语义 |
strict | true | 严格结构化输出模式 |
loadMode | "essential" | 常驻顶层,不挂载到xd://子树下 |
注册通过工厂函数ManageSkillTool.createIf(session)完成,并注册进内置工具工厂表 tools/index.ts:
static createIf(session: ToolSession): ManageSkillTool | null { if (!session.settings.get("autolearn.enabled")) return null; return new ManageSkillTool(session.refreshSkills); }两个要点:
autolearn.enabled是唯一可用性门槛,默认false(见 settings-schema.ts 中的配置定义:"autolearn.enabled": { type: "boolean", default: false, ... },UI 标签为 “Auto-Learn (experimental)”,位于 memory 标签页)。该门控只读取配置,与memory.backend无关——技能侧是独立子系统。- 构造函数捕获了会话的可选
refreshSkills回调(实现见 agent-session.ts,底层转发到 session-tools.ts),用于在技能变更后刷新活跃技能快照。
可见性规则:启用的顶层会话在普通显式工具列表中会自动包含该工具;子代理(subagent)不会自动发现或接收它,只有在其 requested-tools/frontmatter 列表中显式包含manage_skill时才能使用。执行是单次的(single-shot),不发出进度更新。
输入参数契约
工具入参 schema 同样定义在 manage-skill.ts:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
action | "create" \| "update" \| "delete" | 是 | 对受管技能的变更操作 |
name | string | 是 | kebab-case 的受管技能名(小写字母、数字、连字符) |
description | string | create/update 必填 | 单行描述,驱动技能发现(discovery) |
body | string | create/update 必填 | SKILL.md的 Markdown 正文,不得包含 frontmatter |
跨字段约束不是用“判别联合”实现的,而是 schema 的narrow子句:
(p, ctx) => p.action === "delete" || (p.description !== undefined && p.body !== undefined) || ctx.mustBe('used with both "description" and "body" for "create" and "update"'),源码注释解释了选择原因:保持单一根对象结构(而非 discriminated union),因为 strict 结构化输出模式和 Anthropic 工具 schema 生成器都要求 wire schema 是单个根对象。因此delete只需name,而create/update必须同时携带description与body,否则在验证阶段(而非执行阶段)即被拒绝。
执行流程
execute方法(manage-skill.ts)的完整流程:
delete 分支:调用
deleteManagedSkill(name)后,若refreshSkills回调存在则刷新活跃技能,返回Deleted managed skill "<name>".及details = { action: "delete", name }。防御性收窄:
create/update缺少description或body时抛出"<action>" requires both "description" and "body".。由于 schema 的narrow已先行拦截,该分支对合法输入不可达,它的作用只是向writeManagedSkill的类型契约证明字符串必然存在。同名遮蔽检查(仅 create):若已有活跃的用户编写技能占据了该名字,直接返回
isError: true的错误结果,不写任何文件。判断依据是 skills.ts 的isNameClaimedByAuthoredSkill:export function isNameClaimedByAuthoredSkill(name: string): boolean { return getActiveSkills().some( skill => skill.name === name && skill._source?.provider !== MANAGED_SKILLS_PROVIDER_ID, ); }之所以“预先拒绝”而不是写完再说,是因为受管技能在发现时优先级最低,同名用户技能永远胜出——写一个被遮蔽的文件只会报告虚假的 "Created"。错误结果携带
details = { action: "create", name, shadowed: true },提示文本建议选择其他名字。create/update 写入:委托给
writeManagedSkill(...)(见下一节),成功后再次调用refreshSkills?.(),使交互式会话能立即发现变更。结果:
content[0].text分别为Created/Updated managed skill "<name>" (managed-skills/<name>/SKILL.md).,details = { action, name }。
名字、描述与正文的规范化规则
写入前的所有规范化都在辅助模块 managed-skills.ts 中完成:
名字(sanitizeSkillName,L34-L42):先trim().toLowerCase(),再必须匹配/^[a-z0-9][a-z0-9-]{0,63}$/——1 到 64 字符,小写字母/数字开头,仅允许小写字母、数字和连字符。任何不符合的名字抛出Invalid skill name "<raw>". Use lowercase letters, digits, and hyphens (1-64 chars, starting with a letter or digit).。严格白名单同时天然阻断了..、路径分隔符和大小写变体,使非法名字永远无法逃逸出managed-skills根目录。该名字模式在发现时还会被isValidManagedSkillName(L50-L52)复核——手工放进目录的SKILL.md若 frontmatter 名字不符合规范,不允许未转义地渲染进系统提示词。
描述(sanitizeManagedDescription,L62-L69):
return raw .replace(/[\p{Cc}\p{Cf}]/gu, " ") // 控制字符与格式字符 → 空格 .replace(/[<>`]/g, "") // 尖括号与反引号 → 删除 .replace(/~{2,}/g, "~") // 连续波浪线折叠为单个 ~ .replace(/\s+/g, " ") // 空白折叠为单行 .trim();源码注释把这里明确称为信任边界:受管描述由历史任务内容生成且跨会话持久化,必须剥离能“跳出”系统提示词<skills>列表的内容——控制/格式字符、<system-directive>/</skills>之类的尖括号、Markdown 围栏分隔符(反引号、~~~)。该净化在写入与读取两侧都应用,已存在的文件也保持安全。净化后为空则抛Managed skill "<name>" needs a non-empty description.(否则发现扫描因requireDescription: true会静默丢弃该技能,工具就会为“从未出现的技能”报告成功)。
正文与大小上限(L155-L173):body去除首尾空白后必须非空(否则抛... needs a non-empty body.)。最终文件 = 生成的 frontmatter + 正文,其UTF-8 字节长度上限为MAX_MANAGED_SKILL_BYTES = 64_000(注释强调:限的是最终文件字节数,而不是 body 的 UTF-16 长度),超限抛Managed skill is <bytes> bytes; the limit is 64000. Trim the body or description.。
frontmatter 生成(toSkillFrontmatter,L75-L82):正文里不允许自带 YAML frontmatter;writeManagedSkill用仓库的 YAML helper 生成只含规范化name和净化后description的最小 frontmatter 块,并通过parseFrontmatter往返可解析。
文件系统安全设计
writeManagedSkill(L152-L230)和deleteManagedSkill(L233-L255)是本文最值得细读的部分——它针对每一层路径组件都设了防线:
- 根目录检查(
assertManagedRootSafe,L117-L125):对managed-skills根做lstat,若根本身是符号链接则拒绝操作。注释解释了动机:对子路径lstat会跟随中间组件,符号链接的根会让合法名字写入/删除到隔离目录之外(例如落到用户技能上)。 - 技能目录检查:对
<root>/<name>做lstat(不跟随最终组件),目录若是符号链接则抛Managed skill "<name>" resolves through a symlink; refusing to write outside the managed directory.。 - create 的原子独占创建:先
mkdir(dir, { recursive: true }),再用fs.writeFile(file, content, { flag: "wx" })——即O_CREAT|O_EXCL,文件已存在则失败(关闭 check-then-write 竞态),同时天然拒绝符号链接的SKILL.md。EEXIST被翻译为Managed skill "<name>" already exists. Use action "update" to change it. - update 的多重验证:文件必须已存在(否则
Managed skill "<name>" does not exist. Use action "create" to add it.)、不是符号链接、是普通文件(isFile())、且nlink <= 1——nlink > 1意味着该 inode 可能通过硬链接与用户文件共享,抛... has <n> hard links; refusing to overwrite a file that may be user-authored elsewhere. - 先打开后截断:update 用
O_WRONLY | O_NOFOLLOW打开文件句柄(符号链接触发ELOOP时转为明确的拒绝错误),在已检查的句柄上重做一遍 stat 验证,然后truncate(0)+ 写入。这样 lstat 之后路径被换成符号链接或新硬链接目标时,写入仍指向已验证的 inode。 - delete 的防跟随删除:递归
fs.rm前同样lstat检查目录符号链接;ENOENT翻译为Managed skill "<name>" does not exist.
并发语义:按名字串行,跨名字并行
serializeSkillMutation(L99-L109)用一张进程内Map<string, Promise>实现按名字的 Promise 链:
- 同名变更按提交顺序串行——两个工具都是非独占的,同一轮次的并行工具批次可能对同一技能同时发起两个变更(例如一个 update 观察到 delete 进行中),串行链保证按序执行;
- 不同名字可以并行;
- 跨进程竞态不在保障范围内(源码注释明确 out of scope)。
输出契约与错误汇总
正常输出:
| action | content[0].text | details |
|---|---|---|
delete | Deleted managed skill "<name>". | { action: "delete", name } |
create | Created managed skill "<name>" (managed-skills/<name>/SKILL.md). | { action: "create", name } |
update | Updated managed skill "<name>" (managed-skills/<name>/SKILL.md). | { action: "update", name } |
| create 遇到同名用户技能 | Cannot create managed skill ... an authored skill of that name already exists ... | { action: "create", name, shadowed: true },且isError: true,无文件写入 |
异常路径(均抛错或被 schema 拒绝):非法名字、create/update 缺字段、净化后描述为空、正文为空、超过 64000 字节、create 目标已存在、update/delete 目标不存在、不安全根目录/符号链接目录/符号链接文件/非普通文件/多硬链接文件。
发现集成与遮蔽规则的边界
三条与发现层相关、容易被忽略的规则:
- update 不绕过用户技能优先级:如果某用户技能与受管技能同名,
update会成功改写文件,但该受管技能在发现中依旧处于遮蔽状态(managed provider 优先级最低,见 builtin.ts 的注释:managed 是最低优先级 provider,其他任何来源的同名用户技能都会赢下 capability 级去重)。只有create有前置遮蔽检查。 - 描述是发现的入场券:
loadManagedSkills以requireDescription: true扫描,无描述(或净化后为空)的受管技能不会出现在技能列表中——这正是writeManagedSkill要前置拒绝空描述的原因。 - 刷新即时性依赖回调:变更成功后的
refreshSkills()让交互式会话(TUI、RPC 等模式均有对应调用点,如 interactive-mode.ts、rpc-mode.ts)立即看到新技能;无回调的运行环境(如纯子代理调用)则从下一个新会话开始生效。
适用前提与关联文件
- 适用前提:oh-my-pi 当前仓库版本;启用
manage_skill需将autolearn.enabled设为true(默认关闭,零足迹),它与内存后端(mnemopi/sharpshooter 等)相互独立;autolearn.autoContinue(默认false,控制停止时自动跑一个私密捕获轮次)与autolearn.minToolCalls(默认 5,配置文件专属旋钮)是同组下的其他开关,但不影响本工具的注册。 - 核心实现:packages/coding-agent/src/tools/manage-skill.ts、packages/coding-agent/src/autolearn/managed-skills.ts
- 模型提示词:packages/coding-agent/src/prompts/tools/manage-skill.md
- 技能发现与同名判定:packages/coding-agent/src/extensibility/skills.ts、packages/coding-agent/src/discovery/builtin.ts
- 工具注册表与工厂:packages/coding-agent/src/tools/index.ts
- 配置定义:packages/coding-agent/src/config/settings-schema.ts
- 测试覆盖:test/autolearn-managed-skills.test.ts、test/autolearn-tools-gating.test.ts、test/autolearn-discovery.test.ts
- 相关工具:
learn工具(tools/learn.ts)复用同一套writeManagedSkill/sanitizeSkillName原语与同样的同名遮蔽检查,属于自动学习特性在任务结束时的“顺带固化”路径,与manage_skill的显式直接变更互为补充。
【免费下载链接】oh-my-pi⌥ Coding agent with the IDE wired in项目地址: https://gitcode.com/GitHub_Trending/oh/oh-my-pi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考