feat(ports,kernel): AgentSkill 契约 + 三来源汇合,capability-fs 重写为 cap-skills - #54
Merged
Merged
Conversation
added 2 commits
August 31, 2026 16:08
skill 是一个**目录**:SKILL.md 是说明书,旁边可以躺 scripts/*.{mjs,py}。模型读说明书、
用 Bash 跑脚本,脚本本身永不进上下文——这是它与 <extensions> 清单的分工:清单常驻一句话,
skill 按需展开一整页。
## 三来源汇合,渲染只有一个出口
| 来源 | 谁扫 | 读法 |
|---|---|---|
| 用户级 ~/.helios/skills/*/SKILL.md | kernel(skills/scan.ts) | node fs 直读(目录在 workDir 外,过 WorkDirGuard 必被拒;与 hooks.json、全局 AGENTS.md 同一条先例) |
| 项目级 <workDir>/.helios/skills/*/SKILL.md | @helios/cap-skills 的 getSkills() | FileSystemPort |
| extension 自带 | 各 capability 的 getSkills() | 生产者自己说了算 |
AgentSkill 因此带一个 load() 闭包而不是一个路径:**谁能读什么由生产者决定**。
渲染成工具只有 kernel/src/skills/tools.ts 的 toSkillTools 一个出口——曾经各来源各拼各的,
base directory 有的告诉有的不告诉,模型行为随来源而飘。
## 修掉三个硬伤
原 capability-fs 有三处让 skill 事实上不可用:
1. 工具 description 是模板拼的(`加载 skill「x」的完整指引内容`),等于没说,
模型无从判断该调哪个 → 改用 frontmatter 里作者写的那句,缺失即跳过并 warn
(不用目录名兜一个:凑出来的描述等于挂了个永远不会被正确选中的工具)
2. 加载时不告诉 base directory,SKILL.md 里 `运行 scripts/analyze.py` 无处可解
→ 输出前置一行 `Base directory for this skill: <abs>`
3. 扫的是 .helios/policies/*/SKILL.md —— 这是上一轮全局改名(3a8b90d)误伤的
用户可见路径,与 kernel/src/policies/(内置挂载策略)毫无关系 → 改回 .helios/skills/
## 其它
- 同名冲突**后来者不覆盖 + warn**,不再静默覆盖(valos nameMap.set 的教训)
- skill 工具名豁免命名空间前缀(名字是作者起的)。推论:base prompt 讲 skill 那条
不能靠命名规律——原来写的是「名为 caps__* 的工具」,现在改成只靠 description 描述
- SKILL.md 与 EXTENSION.md 共用 ports 的 parseFrontmatter,消掉两份必然漂移的正则
- 合成的 skills capability description 为空,故不进 <extensions> 清单:
它不是装出来的包,是 kernel 内部的汇合点
- capability-fs → cap-skills(包名 @helios/cap-skills),它不再自己渲染工具
## 验证
- 939 passed(新增 test/agent-skills.test.ts 18 例 + p1-impls 的 cap-skills 4 例)
- 变异验证 13/13 命中(不告诉 base dir / 模板描述 / 同名覆盖 / 目录名兜底 /
递归两层 / 不扫用户级 / getSkills 抛错不兜 / 加前缀 / 进清单 / 退回靠前缀讲 skill /
扫回 policies / baseDir 用相对路径 / readExtensionDoc 忽略 frontmatter)
- 真机验证 test/live/skills.verify.live.test.ts:用**真实 helios.config.json** 起 Kernel,
模型第一步就主动调了 release-notes(没自己发明步骤),拿到 base directory,
据此跑通了 scripts/collect.mjs。真机变异同样命中。
⚠️ 第一版断言查的是全部 tool_result,被模型顺手跑的 Glob 满足了,变异逃逸过一次;
改成只看 skill 工具自己的返回值(tool_use.id ↔ tool_result.toolUseId 配对)才抓住。
附带发现:base directory 不是能力强的模型的硬需求(它会 Glob 找回来),省的是轮次。
- eval set 38 → 41,新增第九类「按需资源加载」(missing-base-dir /
silent-source-override / fabricated-description),配套新场景 loader-context-review
命中 3/41;readonly-review 39/41
CLI 真机(本轮该跑没跑,补跑后逮到)暴露:只前置一行 `Base directory for this skill: <abs>` **不足以**让模型把正文里的相对路径挂到那个目录上。 它把 `scripts/collect.mjs` 当成 cwd 相对(Bash 的 cwd 是 workDir),第一个 Bash 直接失败, 然后靠 Glob 满仓找路。**3/3 次稳定复现**,而单测与 live 测试全绿。 模型需要的不是一个事实,是一条**解析规则**,并且要点明 shell 命令得先 cd。 A/B(同一提示词,各 3 次,deepseek-v4-pro-0813-ali): | | BASE(只报路径) | FIX(+解析规则) | |---|---|---| | 首个 Bash | 3/3 失败 | 3/3 成功 | | turn 数 | 9 / 5 / 5 | 3 / 3 / 3 | | 输入 token | 39.0k / 20.4k / 20.3k | 11.5k ×3 | | 成本 | $.0024 / $.0013 / $.0013 | $.0008 / $.0007 / $.0007 | ## 为什么原来的测试全绿 两层护栏各差一点,合起来正好放过这个缺陷: - 单测只断言输出里**含有那个路径**——含,绿。 - live 只断言脚本**最终跑通了**——模型靠 Glob 兜回来了,绿。 两条都补上:单测断言解析规则那两句在(`resolves against that directory` / `not your current working directory` / `cd <baseDir>`);live 新增 `failedToolNames(out)` 应为空,直接编码「**一次就解对**,而不只是最终做成」。 变异:M14「退回只报一个路径」、M15「说了基准但不提 cd」单测均命中; M14 真机也命中(`expected [ 'Bash' ] to deeply equal []`)。 940 passed,typecheck / vitest / live 退出码均为 0。
Owner
Author
补跑 CLI 真机,逮到一个两层测试都放过的缺陷(
|
| BASE(只报路径) | FIX(+解析规则) | |
|---|---|---|
| 首个 Bash | 3/3 失败 | 3/3 成功 |
| turn 数 | 9 / 5 / 5 | 3 / 3 / 3 |
| 输入 token | 39.0k / 20.4k / 20.3k | 11.5k ×3 |
| 成本 |
|
|
为什么原来两层护栏都是绿的
各差一点,合起来正好放过:
- 单测只断言输出里含有那个路径 —— 含,绿。
- live 只断言脚本最终跑通了 —— 模型靠 Glob 兜回来了,绿。
两条都补上:单测断言解析规则那两句在;live 新增 failedToolNames(out) 应为空,直接编码「一次就解对,而不只是最终做成」。
变异:M14「退回只报一个路径」、M15「说了基准但不提 cd」单测均命中;M14 真机也命中(expected [ 'Bash' ] to deeply equal [])。
撤回原 PR 里的一个说法
原文写了「base directory 对能力强的模型不是硬需求(它会 Glob 找回来),省的是轮次」。这个观察本身没错,但结论下反了:不是「所以没那么重要」,而是「这一行的措辞没写对,代价就是每次都白烧 2~6 个 turn」。措辞修好后 turn 数从 5~9 稳定降到 3。
940 passed,typecheck / vitest / live 退出码均为 0。
两个装配根都靠最后一行 `for (const bundle of opts.pluginMounts ?? []) register(bundle)` 保证这条,有实现、有注释、**零测试**。`pluginMounts` 此前只在 run-assembly.test.ts 里 被用来验作用域校验(注册到错误 scope 抛错),没有任何断言管顺序。 ## 坏了会零症状,但后果分两档 | 挂载点语义 | 顺序错了会怎样 | |---|---| | `concat`(context:prepare / session:start) | 拼接顺序**模型可见** → 「装了哪个插件」决定提示词长什么样 | | `firstWin`(context:compact / tool:before / turn:end) | 插件排前面**整个顶掉内置** → 压缩该发生时不发生,上下文涨到 413 | `test/unit/policies/pluginMountOrder.test.ts`(8 例):结构层(describe() 的相对位置) + 行为层(concat 真拼出来的顺序、firstWin 下插件根本没被调用)。 四处变异全命中:run 级挪到最前 / 插到内置中间 / 会话级挪到 compaction 前 / splitPluginMounts 反转。 ## 写这类护栏踩的三个坑(都写进注释了) 1.⚠️ **顺序断言最容易写成空断言。** 插件与内置**没挂在同一个挂载点上**时, 「插件排在最后」恒成立,顺序怎么改都绿。第一版会话级让插件挂 session:start、 内置只挂 context:compact,两者无交集 → **变异 M3 实测逃逸**。改用 context:compact 做对照才有内容。run 级同理:不传 consumeNotifications 时 context:prepare 上 一个内置都没有(那道 `length > 1` 防空断言当场抓住了第一版)。 2.⚠️ **`dispatch` 把 handler 异常 warn 后跳过**,所以「内置赢了」与「内置炸了、插件顶上」 在返回值上分不出来。第一版 payload 写错,内置在 `p.path.length` 抛 TypeError 被吞, 于是赢的是插件——而 `expect(out).toBeDefined()` 照样通过。断言 firstWin 必须同时收 warn。 3.⚠️ **fixture 上 `as unknown as MountBundle` 就是自己关掉类型检查。** 第一版加了, 于是把 `run` 写成 `handler` 时 TS 一声不响,跑起来 mount.run 是 undefined → TypeError → 被静默吞掉 → 表现成「返回值是 undefined」,排查一轮才定位。去掉 cast 后 TS 立刻又抓出 `memFs().fs`(实际叫 `port`)。 ## 刻意不做:`enforce: pre/post` 现在没有任何插件需要排到内置之前。为假想需求新增 API 表面的代价是永久的。 文档写清:将来若真要,抄 Vite 两档桶,不要抄 VSCode 数字优先级(必然退化成 `order: 999999` 军备竞赛)。 ## 验证 - typecheck / vitest 退出码均 0,**948 passed** - 变异 4/4 命中 - eval set 41 → 44,新增第十类「注册顺序即语义」(extension-before-builtin / firstwin-hijacked / handler-error-swallowed,落在新文件 `src/hooks/registry.ts`)。 这一类的判据不在被审代码上,在**消费端怎么用这个数组**:同一个顺序错误对 join 型 消费者是「外部看到的文本变了」,对 firstWin 型是「内置根本不执行」,差一个数量级。 新场景 `registration-order-review` 命中 3/44。 - CLI 真机:3 turn、零失败调用,与上一轮 FIX 基线一致。 (本轮无 src 改动,按规则可省;仍跑了一遍。) 顺带观察:`apps/cli/test/configDiscovery.e2e.test.ts` 在全量并发下撞 5s 超时, 单跑 975ms 通过。它起 pnpm 子进程,5s 预算对机器负载太敏感——非本轮引入,记一笔。
补跑 live **全套**(此前只跑了新增的那一个场景)时逮到: `derived-agent.live.test.ts` 把 `deepseek-v4-flash-0731-ali` 放在候选首位, 而 `code-review.eval.live` 与 `skills.verify.live` 都是 pro 优先——**全仓只有这一个文件 跑在最不稳的模型上**。 它已经三次以不同形态假红:一次「路径越界」、两次模型把 DSML 工具调用标记当纯文本吐出来 (`<|DSML|invoke name="file_read">` 直接进了 output)。此前两次我都当成随机噪声重跑过去了, 这次才去看候选列表,发现是稳定的配置差异。 判据:本文件断言的是**代码属性**(数据沿 workflow DAG 流动、不产生悬空 tool_use), 不是模型能力。让它跑在最不可靠的模型上,等于让它因为与被测内容无关的原因变红—— 一个只在 30% 的时候变红的护栏,实际效果是训练人忽略它。 flash 的耗时方差本身也大到不像在省时间:同一用例实测 23.6s vs 142.8s。 换 pro 后整个文件 4/4 通过,workflow 那例 66s。 ## 验证(三层全绿,退出码均为 0) - typecheck 0 - 单测 948 passed - live **全套** 19 passed / 4 files(此前是 18 passed + 1 failed) ## 顺带记一笔 我这轮往 `evalProject.ts` 的 FILES 里加了 `src/hooks/registry.ts`,那是**所有 eval 场景 共享的被审工程**,等于动了每个场景的输入。当时只跑了新场景(10 skipped)就汇报"跑了回归", 是不完整的——共享 fixture 的改动必须跑全套。
test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
问题
skill 这套机制在仓库里是名存实亡的。原
capability-fs有三处让它事实上不可用:加载 skill「x」的完整指引内容scripts/analyze.py」时相对路径无处可解,带脚本的 skill 直接不可用.helios/policies/*/SKILL.md3a8b90d)误伤的用户可见路径,与kernel/src/policies/(内置挂载策略)毫无关系而且只有项目级一个来源,用户自己的 skill 无处安放。
做法
skill 是一个目录:
SKILL.md是说明书,旁边可以躺scripts/*.{mjs,py}与资源。模型读说明书,再用 Bash 去跑脚本——脚本本身永不进上下文,这是它省 token 的原理,也是它与<extensions>清单的分工:清单常驻一句话,skill 按需展开一整页。三来源汇合,渲染只有一个出口
~/.helios/skills/*/SKILL.mdskills/scan.ts)WorkDirGuard必被拒。与hooks.json、全局AGENTS.md同一条先例<workDir>/.helios/skills/*/SKILL.md@helios/cap-skills的getSkills()FileSystemPort(在 workDir 内,理应受守卫约束)getSkills()AgentSkill因此带一个load()闭包而不是一个路径:谁能读什么由生产者决定。渲染成工具只有
kernel/src/skills/tools.ts的toSkillTools一个出口。这不是洁癖:曾经各来源各拼各的,base directory 有的告诉有的不告诉,模型行为随来源而飘。一并钉住的几条
description必填非空,缺失即跳过并 warn,不用目录名兜一个 —— 凑出来的描述等于挂了个永远不会被正确选中的工具,比不挂更糟。Base directory for this skill: <abs>。nameMap.set静默覆盖的教训)。skills__foo),名字是作者在 frontmatter 里起的。推论:base prompt 讲 skill 那条不能靠命名规律——原文是「名为caps__*的工具」,现在只靠 description 描述。SKILL.md与EXTENSION.md共用ports的parseFrontmatter,消掉两份必然漂移的正则(曾经真的各有一对)。skillscapabilitydescription为空,故不进<extensions>清单:它不是装出来的包,是 kernel 内部的汇合点。capability-fs→@helios/cap-skills,它不再自己渲染工具。验证
939 passed(新增
test/agent-skills.test.ts18 例,p1-impls的 cap-skills 段重写为 4 例)。变异验证 13/13 命中:不告诉 base dir / 模板描述 / 同名覆盖 / 目录名兜底 / 递归两层 / 不扫用户级 /
getSkills抛错不兜 / 加前缀 / 进清单 / 退回靠前缀讲 skill / 扫回policies/ baseDir 用相对路径 /readExtensionDoc忽略 frontmatter。真机验证(新增
test/live/skills.verify.live.test.ts)。本轮改的大半是模型可见文本,单测只能断言「这段字长这样」,断言不了「模型看到之后会不会用」。上一轮 extension 清单就差点被骗过去——live eval 的 manifest 没有extensions段,35/36 全绿却完全没跑到新代码。所以这次用真实helios.config.json起 Kernel:模型第一步就主动调了
release-notes(没有自己发明步骤),拿到 base directory,据此跑通了scripts/collect.mjs。真机变异同样命中。eval set 38 → 41,新增第九类「按需资源加载」——加载器把一包资源压成一段文本交给消费方,却漏掉了解释这段文本所必需的上下文:
missing-base-dirloadGuide只返回正文,消费方解不开正文里的相对脚本路径silent-source-overridecollect用map.set汇合多来源,同名后扫到的静默覆盖先扫到的fabricated-description这一类的判据不在被审的那段代码上,在消费方:
loadGuide的返回值自身完全正常,只有追问「拿到这段字的人怎么解开scripts/run.mjs」才看得出漏了根路径。配套新场景loader-context-review命中 3/41;readonly-review39/41。剩下的
第 5 步:插件挂载顺序护栏(抄 Vite 的
enforce: pre/post两档桶,避开 VSCode 数字优先级那种order: 999999军备竞赛)。「插件挂载恒定排在内置之后」已经有实现有注释,但至今没有测试,欠了三轮。