Skip to content

refactor(ports): CapabilityProvider → Capability,钉清 extension 与 capability 的分工 - #53

Merged
fujiwarazz merged 7 commits into
feat/extension-listingfrom
refactor/capability-naming
Aug 31, 2026
Merged

refactor(ports): CapabilityProvider → Capability,钉清 extension 与 capability 的分工#53
fujiwarazz merged 7 commits into
feat/extension-listingfrom
refactor/capability-naming

Conversation

@fujiwarazz

Copy link
Copy Markdown
Owner

refactor(ports): CapabilityProvider → Capability,钉清 extension 与 capability 的分工

为什么改名

前三个 PR 把词汇全线换成了 "extension"(manifest 的 extensions 段、EXTENSION.md、
节、LoadedExtension),唯独类型还叫 CapabilityProvider——这是那几轮
留下的不一致。而且 getHookHandlers 删掉之后,"Provider" 这半截不再指代任何东西。

顺手钉清一个含义,否则 Capability 与 extensions 段仍然对不上

extension 是部署单元(manifest 里那一行、你装的那个包),
capability 是它带进来的运行时对象。和 Port(接口)与 adapter(包)是同一种关系。

所以 EXTENSION.md 属于包、Capability.description 属于对象,
LoadedExtension { capability, description } 读起来才成立。
这也解释了为什么 builtin 六件套与 agentspec 三件套实现了 Capability 却不是 extension:
它们没有 manifest 条目、没有包,只是同一个运行时形状。

改动

  • CapabilityProvider → Capability(101 处)
  • CAPABILITY_PROVIDER_API_VERSION → CAPABILITY_API_VERSION(37 处)
  • builtinCapabilityProvider → builtinCapability(4 处)
  • Kernel.activateProvider → activateCapability(7 处)
  • LoadedExtension.provider → .capability
  • kernel/src/builtin/provider.ts → builtin/capability.ts

包名(cap-lsp / cap-cron / cap-mcp / capability-fs,另 36 个文件)不在本 PR
capability-fs 在下一步会被重写成 AgentSkill 生产者,那时一次改到位,
现在改等于改两次名,且包名改动要动 package.json / 所有 manifest 与 import,
和类型改名混在一个 PR 里 diff 会爆。

补一个真实缺口(不是改名带来的)

extension-listing 原有的 EXTENSION.md 覆盖用例,fixture 是对象字面量——
所以就算有人把 loader 改回 { ...capability, description }(丢原型那个 bug),
它照样全绿。而 cap-lsp / cap-cron 都是 class,方法在原型上。

新增 fixtures/extclass/:class 形态 + EXTENSION.md 覆盖,断言它的 extclass__ping
真的注册进了 ToolRegistry。两处变异均命中(写回实例展开 / 忽略 EXTENSION.md 覆盖)。

纯 token 替换留下的三处语义问题,已逐个修

改名脚本全用精确 token 全词替换(不写正则跨界匹配——前四次 codemod 就栽在这上面),
但替换本身正确不代表周围的话还成立:

  • docs/[IP]loop-decoupling.md 里那段「三个 capability 撞车」是历史记录,
    被替换后变成用新名字描述旧状态。恢复原名 + 加一条后续修订说明。
  • docs/code-organization.md 「为什么不叫 capabilities/」的论据原本是
    「已经有 CapabilityProvider 和 cap-* 两个」,改名后要重写才通顺。
  • builtin/capability.ts 的注释还在说「与用户自写 provider 走相同路径」。

typecheck 退出码 0;918 passed(+1)。
live 全绿:readonly 35/38,七个专项各 3/3,派生 agent 4/4。
eval set 36 → 38:新增对象身份 / 原型丢失类(src/registry/decorate.ts:
{ ...plugin, label } 对 class 实例丢原型方法、复制后用 indexOf 去重引用不再相等)。
TS 拦不住——展开后的类型仍然满足接口,只有运行到那个方法才炸。正是本轮避开的坑。

zhangzihao added 2 commits August 31, 2026 01:15
…ility 的分工

## 为什么改名

前三个 PR 把词汇全线换成了 "extension"(manifest 的 extensions 段、EXTENSION.md、
<extensions> 节、LoadedExtension),唯独类型还叫 CapabilityProvider——这是那几轮
留下的不一致。而且 getHookHandlers 删掉之后,"Provider" 这半截不再指代任何东西。

## 顺手钉清一个含义,否则 Capability 与 extensions 段仍然对不上

**extension 是部署单元**(manifest 里那一行、你装的那个包),
**capability 是它带进来的运行时对象**。和 Port(接口)与 adapter(包)是同一种关系。

所以 EXTENSION.md 属于包、Capability.description 属于对象,
LoadedExtension { capability, description } 读起来才成立。
这也解释了为什么 builtin 六件套与 agentspec 三件套实现了 Capability 却不是 extension:
它们没有 manifest 条目、没有包,只是同一个运行时形状。

## 改动

- CapabilityProvider → Capability(101 处)
- CAPABILITY_PROVIDER_API_VERSION → CAPABILITY_API_VERSION(37 处)
- builtinCapabilityProvider → builtinCapability(4 处)
- Kernel.activateProvider → activateCapability(7 处)
- LoadedExtension.provider → .capability
- kernel/src/builtin/provider.ts → builtin/capability.ts

包名(cap-lsp / cap-cron / cap-mcp / capability-fs,另 36 个文件)**不在本 PR**:
capability-fs 在下一步会被重写成 AgentSkill 生产者,那时一次改到位,
现在改等于改两次名,且包名改动要动 package.json / 所有 manifest 与 import,
和类型改名混在一个 PR 里 diff 会爆。

## 补一个真实缺口(不是改名带来的)

extension-listing 原有的 EXTENSION.md 覆盖用例,fixture 是**对象字面量**——
所以就算有人把 loader 改回 `{ ...capability, description }`(丢原型那个 bug),
它照样全绿。而 cap-lsp / cap-cron 都是 class,方法在原型上。

新增 fixtures/extclass/:class 形态 + EXTENSION.md 覆盖,断言它的 extclass__ping
真的注册进了 ToolRegistry。两处变异均命中(写回实例展开 / 忽略 EXTENSION.md 覆盖)。

## 纯 token 替换留下的三处语义问题,已逐个修

改名脚本全用精确 token 全词替换(不写正则跨界匹配——前四次 codemod 就栽在这上面),
但替换本身正确不代表**周围的话**还成立:

- docs/[IP]loop-decoupling.md 里那段「三个 capability 撞车」是历史记录,
  被替换后变成用新名字描述旧状态。恢复原名 + 加一条后续修订说明。
- docs/code-organization.md 「为什么不叫 capabilities/」的论据原本是
  「已经有 CapabilityProvider 和 cap-* 两个」,改名后要重写才通顺。
- builtin/capability.ts 的注释还在说「与用户自写 provider 走相同路径」。

typecheck 退出码 0;918 passed(+1)。
live 全绿:readonly 35/38,七个专项各 3/3,派生 agent 4/4。
eval set 36 → 38:新增对象身份 / 原型丢失类(src/registry/decorate.ts:
`{ ...plugin, label }` 对 class 实例丢原型方法、复制后用 indexOf 去重引用不再相等)。
TS 拦不住——展开后的类型仍然满足接口,只有运行到那个方法才炸。正是本轮避开的坑。
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
zhangzihao and others added 5 commits August 31, 2026 16:25
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。
两个装配根都靠最后一行 `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): 补「插件挂载恒定排在内置之后」护栏(欠了四轮)
feat(ports,kernel): AgentSkill 契约 + 三来源汇合,capability-fs 重写为 cap-skills
@fujiwarazz
fujiwarazz merged commit f4ba171 into feat/extension-listing Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant