Skip to content

refactor(kernel): loop 扩展面收成闭包 + 插件可自带挂载点(capability 三处撞名收敛) - #49

Open
fujiwarazz wants to merge 3 commits into
mainfrom
refactor/loop-closure-surface
Open

refactor(kernel): loop 扩展面收成闭包 + 插件可自带挂载点(capability 三处撞名收敛)#49
fujiwarazz wants to merge 3 commits into
mainfrom
refactor/loop-closure-surface

Conversation

@fujiwarazz

@fujiwarazz fujiwarazz commented Aug 30, 2026

Copy link
Copy Markdown
Owner

两个提交,一条线:先把 loop 的扩展面收干净,第三方才挂得上去。


提交 1:loop 的扩展面收成闭包

loop 之前拿的是 { mounts: MountRegistry, hooks: HookRunner },于是多知道两件不属于它的事:

  1. 有两套扩展机制,还得自己排先后
  2. 同一时机可能有多个订阅者——MountRegistry 是注册表,「合并策略」跟着进了 loop 的词汇表

turn:end 那段最明显:Stop hook 与挂载点谁先跑、急停凭什么压过续跑,全写在循环里。那些是组合规则不是循环逻辑。

改成 LoopExtensions——一个时机一个闭包,签名里没有任何注册表类型。createLoopExtensions(mounts, hooks) 是 harness 侧唯一决定先后的地方。

与 P2.7 撤掉的那次不是一回事:那次把 Stop hook 包装成 mount 能力,否决权因此受制于注册顺序;这次两套机制在 harness 里仍是两套、语义各自保留,只是对 loop 的呈现统一了。判据:合并之后「用户能不能拦住 agent」还取不取决于注册顺序?那次取决,这次不取决。


提交 2:插件可自带挂载点

a) 命名收敛

「capability」在仓库里指三样东西,讨论时永远要先问「你说的是哪个」:

保留 / 改名
✅ 保留 CapabilityProvider 插件契约,与 cap-* 包名一致
改名 kernel/src/capabilities/kernel/src/policies/ port→mount 适配器
改名 xxxCapabilityxxxPolicy(7 个)
改名 MountedCapabilityMountBundle

b) PluginModule.mounts(instance, ctx)

迁移前第三方只能供工具与 hook 订阅,挂载点是够不到的——要挂上去得改 kernel 的装配根,而第三方改不了核心代码。于是能力市场只能卖「替换既有 Port 的实现」,卖不了「新增一个挂载策略」。

现在一个「带自定义工具的领域插件」一个包就够,manifest 加一行,核心代码一行不动

export function create(ctx) { return new AnalyticsProvider(); }  // getTools() 给工具
export function mounts(instance, ctx) {                          // 自己声明挂在哪
  return [{ name: "analytics:context", mounts: [
    { at: "session:start",   run: async () => ({ additionalContext: "<analytics>…</analytics>" }) },
    { at: "context:prepare", run: async () => ({ append: [note] }) },
  ]}];
}

kernel 用 splitPluginMounts() 按作用域拆进会话级 / run 级两个注册表。插件挂载恒定排在内置之后——context:prepare 是 concat 语义、拼接顺序模型可见。

为什么放在 PluginModule 而不是 CapabilityProvider

因为想挂载的不只是插件。判据:Port 的方法签名有没有把调用时机钉死?

Port 钉死了吗
ModelRouterPort.route(payload) / CostMeterPort.onLLMCall / ToolResultCachePort.get ✅ 适配器机械,留装配根
MemoryPort.recall(query) ❌ 没说何时调、结果去哪

于是 memoryRecallPolicy 从 kernel 搬进了 @helios/memory-fssession:start + <memory> 标签那套是 MEMORY.md 这一派的策略,换成 mem0(每轮带 query 检索、结果作消息、turn 结束抽取)一条都用不上,还会在 session:start 白调一次 recall


护栏

覆盖 位置
合成规则本身(7 例) test/unit/loopExtensions.test.ts
loop 边界不许再破(3 例) test/unit/agentLoop/loopExtensionSurface.test.ts
插件挂载 + splitPluginMounts(7 例) test/plugin-mounts.test.ts

边界那条用「读源码查 import」的笨办法,因为它坏掉不会有任何行为症状:谁在 loop 里加一行 hooks.runXxx(),测试全绿、agent 照跑,只是边界又没了。

六处变异全部命中:去掉急停 early return / 忽略 hadToolUse / loop 重新 import HookRunner / 不注册 run 级插件挂载 / 不注册会话级 / 清空作用域清单。

行为等价的主要证据是断言一字未改expect(prefix).toContain("<memory>\nprobe:default\n</memory>") 原样通过,改的只有 fixture。顺带发现「召回为空时不注入空标签」那条变成了空转(不装插件自然没标签),已改成装一个「会声明挂载、但召回返回空串」的插件才真的测到空分支。

eval set 21 → 24

新增分层/依赖方向类:判据在 README 的架构约定里而不在代码里,每个文件单看都自洽,考的是「会不会先去找规矩」。

回归

  • pnpm typecheck 退出码 0
  • npx vitest run 883 passed(+17)
  • 真机 eval 8/8:readonly 24/24,六个专项各 3/3

zhangzihao added 2 commits August 30, 2026 16:03
loop 之前拿的是 `{ mounts: MountRegistry, hooks: HookRunner }`,于是多知道两件
不属于它的事:有两套扩展机制(还得自己排先后),以及同一时机可能有多个订阅者
(「合并策略」跟着进了它的词汇表)。`turn:end` 那段最明显——Stop hook 与挂载点
谁先跑、急停凭什么压过续跑,全写在循环里,可那些是组合规则不是循环逻辑。

改成 `LoopExtensions`:一个时机一个闭包,签名里没有任何注册表类型。
`createLoopExtensions(mounts, hooks)` 是 harness 侧唯一决定先后的地方。

这与 P2.7 撤掉的那次不同:那次把 Stop hook 包装成 mount 能力,否决权因此受制于
注册顺序;这次两套机制在 harness 里仍是两套、语义各自保留,只是对 loop 的呈现统一了。
判据是「用户能不能拦住 agent 还取不取决于注册顺序」——那次取决,这次不取决。

保留 Mount*Payload 作入参:payload 描述的是 loop 自己那一刻的事实,由它定义合理;
被赶走的是「多订阅者 + 合并」那套概念。

护栏:
- test/unit/loopExtensions.test.ts(7 例)测合成规则本身。迁移前这些规则只有整机
  集成测试覆盖,现在能直接测规则。
- test/unit/agentLoop/loopExtensionSurface.test.ts(3 例)钉住边界。用「读源码查
  import」这种笨办法,因为这条边界坏掉不会有任何行为症状:谁在 loop 里加一行
  hooks.runXxx(),测试全绿、agent 照跑,只是边界又没了。
三处变异全部命中(去掉急停 early return / 忽略 hadToolUse / loop 重新 import HookRunner)。

顺带纠正 docs/code-organization.md 里一段错的论证:曾用 memoryRecall 论证「适配器不该
放进 Port 包」,但 MemoryPort 的 recall(query) 里根本没说何时调、结果去哪——那套
session:start + <memory> 标签是 capability 自己发明的,换 mem0 一条都表达不了。
改成可检查的判据:Port 的方法签名有没有把调用时机钉死。五个 Port 里四个钉死了,
MemoryPort 是反例。

eval set 21 → 24:新增分层/依赖方向类(src/core 与 src/adapters 的方向违规、
core 做 I/O、业务规则落在 adapter),判据在 README 的架构约定里而不在代码里,
考的是「会不会先去找规矩」。配套「分层/依赖方向专项」场景。

typecheck 退出码 0;876 passed(+10)。
真机 eval 8/8:readonly 22/24,六个专项各 3/3。
## a) 命名收敛

"capability" 在仓库里指三样东西:CapabilityProvider(插件契约)、
kernel/src/capabilities/(port→mount 适配器)、cap-* 包。讨论时永远要先问
"你说的是哪个 capability"。保留 CapabilityProvider(它是对外契约、与包名一致),
另两个改名:

- 目录 kernel/src/capabilities/ → kernel/src/policies/(测试同步)
- 函数 xxxCapability → xxxPolicy(7 个)
- 类型 MountedCapability → MountBundle

## b) 插件自带挂载点

新增 PluginModule.mounts(instance, ctx): MountBundle[]。任何插件包都能自己声明
要挂在哪些时机上,kernel 用 splitPluginMounts() 按作用域拆进会话级 / run 级
两个注册表。插件挂载恒定排在内置之后——context:prepare 是 concat 语义、拼接顺序
模型可见,让第三方插到内置前面去等于让"装了哪个插件"决定提示词长什么样。

迁移前第三方只能供工具与 hook 订阅,挂载点够不到:要挂上去得改 kernel 的装配根,
而第三方改不了核心代码。现在一个"带自定义工具的领域插件"一个包就够:
getTools() 给工具、getHookHandlers() 订阅 hook、mounts() 挂时机,manifest 加一行。

放在 PluginModule 而不是 CapabilityProvider 上,因为想挂载的不只是插件。判据是
Port 的方法签名有没有把调用时机钉死:ModelRouterPort.route(payload) /
CostMeterPort.onLLMCall / ToolResultCachePort.get 钉死了,适配器机械、留装配根;
MemoryPort.recall(query) 没钉死——它没说何时调、结果去哪。

于是 memoryRecallPolicy 从 kernel 搬进 @helios/memory-fs:session:start +
<memory> 标签那套是 MEMORY.md 这一派的策略,换成 mem0(每轮带 query 检索、
结果作消息、turn 结束抽取)一条都用不上,还会在 session:start 白调一次 recall。

## 测试

行为等价的主要证据是断言一字未改:session-mounts.test.ts 里
expect(prefix).toContain("<memory>\nprobe:default\n</memory>") 原样通过,
改的只有 fixture——它现在得像个真实 MemoryPort 插件那样自己声明挂载。

顺带发现"召回为空时不注入空标签"那条变成了空转(不装插件自然没标签),
已改成装一个"会声明挂载、但召回返回空串"的插件,才真的测到空分支。

新增 test/plugin-mounts.test.ts(7 例):splitPluginMounts 单测 + 第三方插件
工具与挂载并存的整机验证(context:prepare 追加的消息真的进了 LLM 请求,
不只是"在树上"——树上有而请求里没有正是这条链路曾经的 bug)。

三处变异命中:不注册 run 级 / 不注册会话级 / 清空作用域清单。

typecheck 退出码 0;883 passed(+7)。
真机 eval 8/8:readonly 24/24,六个专项各 3/3。
@fujiwarazz fujiwarazz changed the title refactor(kernel): loop 的扩展面收成闭包,跨机制先后收进 harness 合成器 refactor(kernel): loop 扩展面收成闭包 + 插件可自带挂载点(capability 三处撞名收敛) Aug 30, 2026
两轮 review(一轮查 helios 自身分层、一轮拆 pi 的 harness)指向同一件事:
helios 里没有 harness。`harness` 只出现在注释里,从未落地成类/文件/目录。
真正干这活的是两段各自独立的代码——Session.sendMessage() 与
DerivedAgentExecutor.runAgentBody(),各抄一遍「装 mounts → 绑工具执行器 →
合成扩展面 → 拼六组入参 → 调 loop」。

代价已经发生:派生 agent 漏了三个字段,全程零报错。
- contextBudgetWarnTokens:上下文预算观测永远关闭
- retry / sleep:永远用默认重试策略

## 修法

不是新建一个 Harness 类,是给「组装」一个名字:runAgentTurns()(src/runAssembly.ts)。
两个调用方只提供真正不同的那部分(树、run 元信息、工具作用域),其余统一推导——
漏配从「靠人记得」变成「类型上不可能」。

对标 pi:它的三种模式(interactive/print/rpc)共用 sdk.ts::createAgentSession(),
各自只加 I/O 适配、不重新组装。反过来 pi 的 AgentSession 有 3342 行、正在被
AgentHarness 重写——所以「把 Session 拆成两个类」不是要学的方向,先有唯一组装点才是。
helios 的 Session 才 1000 行,还没到那一步。

## 会话级装配也收进装配根

新增 buildSessionScopedMounts(),与 buildDefaultMounts() 成对;scopeChecked() 让注册到
错误作用域当场抛错。此前「一个内置策略该进哪套注册表」只存在于人的记忆里——run 级的进
buildDefaultMounts、会话级的写在 Session 私有方法里,写错了没有任何报错,只是它每 run
重建、跨 run 的状态悄悄丢了。

## 两处假断言

注释宣称的事实与代码不符,比没注释更有害:
- loopExtensions.ts 写着「这里是 harness 侧唯一决定谁先谁后的地方」——假的,全仓四处
  各自决定(本文件 / SessionStart / dispose / executeTools)。改成列出全部四处并说明
  为什么刻意分散:抽到一处会丢掉「这条规则为什么长这样」的局部上下文。
- session.ts 写着「Session 不再直接调用任何 Port」——几十行外就有
  ports.checkpoint.restore()。收窄成「不再自己找 CostMeterPort 要」。

## 测试

新增 test/run-assembly.test.ts(7 例):禁止调用方 import runTurnLoop/executeTools/
loopExtensions 的结构护栏 + 作用域校验 + 派生 agent 预算观测的行为验证。

⚠️ 其中一条曾经假绿:派生 agent 只跑一轮,而预算观测只在 turnIndex > 0 时检查,
所以永远测不到。新增 mockLlmDerivedTwoTurns.ts 让子 agent 也跑两轮才真的有牙。

三处变异全部命中:kernel 不传预算阈值 / 去掉作用域校验 / executor 不把阈值传进 io。

eval set 24 → 27:新增重复实现/配置漂移类(src/client/requests.ts:复制版 builder
漏字段、两处常量取值不同、可选回调没透传)。每段单独看都对,必须并排做字段级比对——
正是本轮自己踩的坑。

typecheck 退出码 0;890 passed(+7)。
真机 eval 9/9:readonly 25/27,七个专项各 3/3。
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