From de9ec14542cb86f7d72a36e45fb8aede93f2b951 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 16:03:08 +0800 Subject: [PATCH 01/11] =?UTF-8?q?refactor(kernel):=20loop=20=E7=9A=84?= =?UTF-8?q?=E6=89=A9=E5=B1=95=E9=9D=A2=E6=94=B6=E6=88=90=E9=97=AD=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=E8=B7=A8=E6=9C=BA=E5=88=B6=E5=85=88=E5=90=8E=E6=94=B6?= =?UTF-8?q?=E8=BF=9B=20harness=20=E5=90=88=E6=88=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 + 标签是 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。 --- docs/[IP]loop-decoupling.md | 34 +++++ docs/code-organization.md | 30 +++-- packages/kernel/src/agentLoop/runTurnLoop.ts | 103 +++++++-------- packages/kernel/src/agentLoop/types.ts | 51 ++++++-- .../src/agentSpec/derivedAgentExecutor.ts | 3 +- packages/kernel/src/loopExtensions.ts | 57 +++++++++ packages/kernel/src/session.ts | 3 +- .../test/live/code-review.eval.live.test.ts | 22 ++++ .../kernel/test/live/fixtures/evalProject.ts | 72 +++++++++++ .../agentLoop/loopExtensionSurface.test.ts | 46 +++++++ .../agentLoop/runTurnLoop.provider.test.ts | 7 +- .../agentLoop/runTurnLoop.steering.test.ts | 11 +- .../kernel/test/unit/loopExtensions.test.ts | 119 ++++++++++++++++++ 13 files changed, 472 insertions(+), 86 deletions(-) create mode 100644 packages/kernel/src/loopExtensions.ts create mode 100644 packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts create mode 100644 packages/kernel/test/unit/loopExtensions.test.ts diff --git a/docs/[IP]loop-decoupling.md b/docs/[IP]loop-decoupling.md index 9532165..8b7fc8b 100644 --- a/docs/[IP]loop-decoupling.md +++ b/docs/[IP]loop-decoupling.md @@ -580,6 +580,40 @@ provider 后两者恰好相等,于是永远不重解析,请求仍发给原 p 分组本身零行为变化,真正的价值是**成员资格变成一件要解释的事**:想给 loop 加字段, 先得回答它属于哪一组。平铺的 `RunLoopDeps` 没有这道门槛——`fileSystem` 就是这么混进去的。 +### P2.10(已完成):loop 的扩展面收成闭包 + +P2.7 判定「hook 与 mount 是两套机制,不能嵌套」——对,但**它只回答了机制层, +没回答呈现层**。loop 拿到的仍是 `{ mounts: MountRegistry, hooks: HookRunner }`, +于是它多知道两件不该知道的事: + +1. **有两套机制**,还得自己排先后(`turn:end` 那段里 Stop hook 与挂载点的顺序、 + 「急停压过续跑」的规则,全写在循环里)。 +2. **同一时机可能有多个订阅者**——`MountRegistry` 是个注册表,「合并策略」这个概念 + 跟着进了 loop 的词汇表。 + +现在 loop 收 `LoopExtensions`:**一个时机一个闭包**,签名里没有任何注册表类型。 +`createLoopExtensions(mounts, hooks)`(`kernel/src/loopExtensions.ts`)是 harness 侧 +唯一决定「谁先谁后」的地方。 + +⚠️ **这与 P2.7 撤掉的那次不是一回事,别再混。** 那次是把 Stop hook **包装成一个 mount +能力**,于是否决权受制于注册顺序;这次两套机制在 harness 里仍是两套、语义各自保留, +只是递给 loop 的形状统一了。判据:合并之后「用户能不能拦住 agent」还取不取决于注册顺序? +那次取决,这次不取决(顺序写死在 `composeTurnEnd` 里)。 + +保留 `Mount*Payload` 作为闭包入参不是没做干净:payload 描述的是 **loop 自己那一刻的事实**, +由它定义天经地义;被赶走的是「多订阅者 + 合并」那套概念。 + +护栏:`test/unit/loopExtensions.test.ts`(7 例,合成规则本身)+ +`test/unit/agentLoop/loopExtensionSurface.test.ts`(3 例,钉住边界)。 +后者用「读源码查 import」这种笨办法,因为**这条边界坏掉不会有任何行为症状**—— +谁在 loop 里加一行 `hooks.runXxx(...)`,测试全绿、agent 照跑,只是边界又没了。 +行为测试测不到「它不该知道什么」。三处变异全部命中。 + +**下一步(未做)**:插件包可自带 mounts。判据见 `code-organization.md`—— +时机由接口钉死的 Port 留在装配根,没钉死的(`MemoryPort`)与第三方 Port 必须自己声明。 +P2.10 是它的前提:不先把 loop 的扩展面收干净,第三方 mounts 一进来就会踩到 +loop 里那几处硬编码顺序。 + ### P3 为什么建议不做 **P2.7 之后这条更清楚了**:把 Stop hook 包成能力试过一轮,结果是「用户能不能拦住 agent」 diff --git a/docs/code-organization.md b/docs/code-organization.md index 2135972..8fd2c0a 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -34,15 +34,31 @@ mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后 ⚠️ **payload 里永远不会有 `ports`。** capability 要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 -### `index.ts` 是装配根,它**应该**知道全部接线 +### `index.ts` 是装配根,但只对**时机由接口钉死**的那些 Port 成立 -「kernel 为什么感知到用了哪些 Port」——因为它是 composition root,那正是它的职责。 -挪出去不会消除这份知识,只会换个地方放,而另外两个位置更糟: +判据是一句话:**Port 的方法签名有没有把调用时机钉死?** -- **放进各 Port 的适配器包**:换一个 CostMeter 实现就连带换掉「什么时候记账」这个策略。 - `memoryRecall` 更明显——`` 标签格式与注入位置是**模型可见的策略**, - 不该由 `memory-fs` 还是 `memory-sqlite` 决定。 -- **单开一个 bindings 包**:kernel 反过来要依赖它,依赖方向倒过来了。 +| Port | 方法 | 钉死了吗 | +|---|---|---| +| `ModelRouterPort` | `route(payload)`,payload 就是 `turn:start` 的 | ✅ | +| `CostMeterPort` | `onLLMCall` / `onToolCall`,名字即时机 | ✅ | +| `ToolResultCachePort` | `get` / `set` 配 key,只能夹在工具前后 | ✅ | +| `CompactStrategyPort` | `plan` / `parseSummary` | ✅ | +| `MemoryPort` | `recall(query)` / `remember(entry)` | ❌ **完全没说** | + +钉死的那些,适配器是机械的、全仓只需一份,放装配根合适——kernel 是 composition root, +知道这份接线正是它的职责,挪出去不消除知识只换地方(单开 bindings 包还会让依赖方向倒过来)。 + +⚠️ **`MemoryPort` 是反例,别再拿它当上面那条的论据。** 本文档曾写过 +「`` 标签格式是模型可见策略,不该由 `memory-fs` 还是 `memory-sqlite` 决定」——**错**。 +`recall(query) => string` 里没有任何东西说它该在什么时候调、结果去哪; +`session:start` + `` 标签 + 拼进 system 前缀,全是 `memoryRecallCapability` 自己发明的。 +换成 mem0 那类实现,要的是每轮带当前 query 检索、结果作为消息而非 system 前缀, +`remember` 还要在 turn 结束自动抽取——现在这套一条都表达不了,还会在 `session:start` +白调一次 `recall`。**时机是记忆策略的一部分,必须由实现声明。** +(真正的隐患是「模型可见的东西不该悄悄漂」,那是可观测性问题,不是把它集中起来的理由。) + +这一条同时也是第三方 Port 的答案:kernel 不可能知道一个它没见过的 Port 该在什么时候跑。 ## 测试放哪 diff --git a/packages/kernel/src/agentLoop/runTurnLoop.ts b/packages/kernel/src/agentLoop/runTurnLoop.ts index 0d2dc6a..8de2b5b 100644 --- a/packages/kernel/src/agentLoop/runTurnLoop.ts +++ b/packages/kernel/src/agentLoop/runTurnLoop.ts @@ -11,10 +11,10 @@ import type { import { uid } from "../ids"; import { streamAssistant } from "./streamAssistant"; import type { - ExtensionsGroup, IoGroup, LlmGroup, RunGroup, + LoopExtensions, SessionTreeCallbacks, ToolsGroup, ToolUseBlock, @@ -34,8 +34,8 @@ export interface RunTurnLoopParams { tools: ToolsGroup; /** Session 消息树的操作面:回调驱动树变更,runTurnLoop 不持有任何树内部状态。 */ tree: SessionTreeCallbacks; - /** loop 的两套扩展机制:进程内能力(mounts)与 hooks.json 脚本(hooks)。 */ - extensions: ExtensionsGroup; + /** loop 的扩展面:一个时机一个闭包,背后是什么机制由 harness 合成,loop 不知道。 */ + extensions: LoopExtensions; /** 本 run 的身份与边界。 */ run: RunGroup; /** 对外的口子:中断、事件、日志、trace。 */ @@ -107,7 +107,7 @@ export async function runTurnLoop(params: RunTurnLoopParams): Promise { const { tree, io, run } = params; - const { mounts, hooks } = params.extensions; + const ext = params.extensions; const { runId, runIndex, sessionId, turnIdPrefix, maxTurns, system } = run; const { signal, events, logger } = io; const resolveProvider = params.llm.resolve; @@ -129,16 +129,12 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): // 本 run 是否已降级。只降一次:降级模型再拒答/再超限,说明这条路走不通, // 继续换下去只是在不同模型上重复同一次失败。 let downgraded = false; - const runStart = await mounts.dispatch( - "run:start", - { - ...base, - runId, - firstMessage: firstMessageText(run.leadMessages), - baselineLlmOptions: params.llm.options, - }, - logger, - ); + const runStart = await ext.onRunStart?.({ + ...base, + runId, + firstMessage: firstMessageText(run.leadMessages), + baselineLlmOptions: params.llm.options, + }); /** * 本 run 的基线:`run:start` 有权改写它,`turn:start` 再逐轮覆盖本基线 * (优先级见 docs/[IP]loop-decoupling.md Q2)。不传或无能力时就是调用方给的原值。 @@ -190,15 +186,11 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): } const decision = - (await mounts.dispatch( - "turn:start", - { - ...base, - ...buildRouteContext(sessionId, turnIndex, path, toolRegistry.list().length, routeState), - baselineLlmOptions: llmOptions, - }, - logger, - )) ?? {}; + (await ext.onTurnStart?.({ + ...base, + ...buildRouteContext(sessionId, turnIndex, path, toolRegistry.list().length, routeState), + baselineLlmOptions: llmOptions, + })) ?? {}; const effective: LLMOptions = { ...llmOptions, ...(decision.provider !== undefined ? { provider: decision.provider } : {}), @@ -230,11 +222,14 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): // // 追加的消息必须 appendNode 入树——不入树的消息到不了 LLM(下面重取的 path 来自树), // 却会被 persistTurn 写进 turnMessages,两边就对不上了。 - const prepared = await mounts.dispatch( - "context:prepare", - { ...base, runId, turnId, turnIndex, messages: path, effectiveLlmOptions: effective }, - logger, - ); + const prepared = await ext.onContextPrepare?.({ + ...base, + runId, + turnId, + turnIndex, + messages: path, + effectiveLlmOptions: effective, + }); if (prepared?.append && prepared.append.length > 0) { for (const message of prepared.append) tree.appendNode(message); pendingLeadMessages = [...pendingLeadMessages, ...prepared.append]; @@ -370,16 +365,17 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): const { assistantMsg, stopReason, toolUseBlocks, streamError, parseErrorIds, usage } = streamed; assistantMsg.turnId = turnId; - // 挂载点 llm:response(旁路观察):有 usage 才记。 + // 旁路观察 llm:response:有 usage 才记。 // provider/model 取 streamAssistant 报回的 provenance,与消息上那份、与 usedModels 同源。 // 迁移前这里用 usedProvider.id + effective.model 又算了一遍——当时与 provenance 恒等, // 但「谁产出了这一轮」有两处各自计算,改动路由时只改一处就会静默失配。 if (usage) { - await mounts.dispatch( - "llm:response", - { ...base, runId, turnId, record: { ...streamed.provenance, usage, purpose: "main" } }, - logger, - ); + await ext.onLlmResponse?.({ + ...base, + runId, + turnId, + record: { ...streamed.provenance, usage, purpose: "main" }, + }); } // Bug 7:只有非空 assistant 消息才入树/持久化,避免 content:[] 触发下游 API 报错。 @@ -432,29 +428,15 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): } /** - * 续跑判定有两个来源,**顺序是设计出来的,不是注册次序决定的**: - * - * 1. `hooks.json` 的 Stop hook —— 用户写的外部命令,先问它。 - * 2. `turn:end` 挂载点 —— 进程内能力(session 级评审、自动续写之类)。 - * - * 两套机制刻意不互相嵌套:hook 有否决权与外部进程语义(超时/exit code),能力没有。 - * 曾经把 Stop 包成一个挂在 turn:end 的能力,那让"用户能不能拦住 agent"取决于注册 - * 顺序,是错的。 + * turn 收尾:**问一句,拿到什么照做**。 * - * 有工具调用时本轮无论如何都要续跑,此时不问 Stop hook(省一次进程调用,与迁移前一致)。 + * 续跑意图有几个来源、Stop hook 与 `turn:end` 挂载点谁先跑、急停凭什么压过续跑, + * 全在合成器里(`../loopExtensions.ts`)。那些是组合规则不是循环逻辑—— + * 迁移前它们写在这里,于是每加一种扩展机制都要改这段循环。 */ - const stopDecision = hadToolUse - ? undefined - : await hooks.runStop({ sessionId, turnCount: turnIndex + 1 }); - if (stopDecision?.continue === false) { - halted ??= stopDecision.stopReason ?? "hook 要求终止本轮(continue:false)"; - } - const turnEnd = await mounts.dispatch("turn:end", { ...base, turnId, turnIndex, hadToolUse }, logger); - // 急停压过一切续跑意图:Stop hook 的 block、turn:end 的 continue 都不再注入。 - // 否则「先喊停再被另一个 hook 强制续轮」会让停不下来,且谁赢取决于注册顺序。 - const injectedText = halted - ? undefined - : (stopDecision?.block ? stopDecision.message : undefined) ?? turnEnd?.continue; + const turnEnd = await ext.onTurnEnd?.({ ...base, turnId, turnIndex, hadToolUse }); + if (turnEnd?.halt !== undefined) halted ??= turnEnd.halt; + const injectedText = halted ? undefined : turnEnd?.continue; if (injectedText) { const injected: Message = { id: uid("msg"), role: "user", content: injectedText, turnId }; tree.appendNode(injected); @@ -474,11 +456,12 @@ async function runTurnLoopInternal(params: RunTurnLoopParams, trace: TraceRun): } const outcomeStatus: TaskOutcome["status"] = signal.aborted ? "cancelled" : runError ? "failure" : "success"; - const runEnd = await mounts.dispatch( - "run:end", - { ...base, runId, outcome: { status: outcomeStatus }, usedModels: [...usedModels] }, - logger, - ); + const runEnd = await ext.onRunEnd?.({ + ...base, + runId, + outcome: { status: outcomeStatus }, + usedModels: [...usedModels], + }); return { turnIds, runError, diff --git a/packages/kernel/src/agentLoop/types.ts b/packages/kernel/src/agentLoop/types.ts index 3ed6c80..8ee9567 100644 --- a/packages/kernel/src/agentLoop/types.ts +++ b/packages/kernel/src/agentLoop/types.ts @@ -6,6 +6,16 @@ import type { LLMOptions, AskQuestionRequest, AskQuestionResponse, + MountContextPreparePayload, + MountContextPrepareResult, + MountLlmResponsePayload, + MountRunEndPayload, + MountRunEndResult, + MountRunStartPayload, + MountRunStartResult, + MountTurnEndPayload, + MountTurnStartPayload, + MountTurnStartResult, } from "@helios/ports"; import type { ToolLookup } from "../toolRegistry"; import type { HookRunner } from "../hookRunner"; @@ -111,18 +121,41 @@ export interface RunGroup { } /** - * loop 的两套扩展机制。**并列,不嵌套**——这是刻意的: + * loop 的扩展面:**一个时机一个闭包**。 * - * - `hooks`:执行 `hooks.json` 里用户写的外部命令。有否决权(PreToolUse 的 deny), - * 有外部进程语义(超时 / exit code / stderr)。 - * - `mounts`:进程内能力,在构造期消费 Port。刻意**没有** veto 语义(留到 P3)。 + * loop 只知道「在这个点问一句,拿到什么照做」。背后是 `hooks.json` 的外部脚本、 + * 进程内能力、还是两者合成的结果,它一概不知——那是 harness 的组合问题 + * (合成器见 `../loopExtensions.ts`)。 * - * 曾经把 Stop hook 包成一个挂在 `turn:end` 的能力,那让「用户能不能拦住 agent」 - * 取决于注册顺序。两者受众、权限、失败语义都不同,合并只会让二者都变形。 + * 迁移前这里是 `{ mounts: MountRegistry, hooks: HookRunner }`,于是 loop 多知道了两件 + * 不该知道的事:**有两套机制**(还得自己排先后),以及**同一时机可能有多个订阅者** + * (于是合并策略也进了它的词汇表)。`turn:end` 那段最明显——Stop hook 与 `turn:end` + * 挂载点的先后、急停压过续跑的规则,全写在 loop 里,可那些是组合规则不是循环逻辑。 + * + * ⚠️ 两套机制在 harness 里**仍然是两套**,只是递给 loop 的形状统一了。这与 P2.7 撤掉的 + * 那次(把 Stop hook 包装成一个 mount 能力)不同:那次让否决权受制于注册顺序,这次没有。 + * + * 保留 `Mount*Payload` 作为入参类型不是没做干净:payload 描述的是 **loop 自己那一刻的事实**, + * 由它定义天经地义。被赶走的是 `MountRegistry`——「多订阅者 + 合并策略」那套概念。 */ -export interface ExtensionsGroup { - mounts: MountRegistry; - hooks: HookRunner; +export interface LoopExtensions { + onRunStart?(p: MountRunStartPayload): Promise; + onTurnStart?(p: MountTurnStartPayload): Promise; + onContextPrepare?(p: MountContextPreparePayload): Promise; + onLlmResponse?(p: MountLlmResponsePayload): Promise; + onTurnEnd?(p: MountTurnEndPayload): Promise; + onRunEnd?(p: MountRunEndPayload): Promise; +} + +/** + * `onTurnEnd` 的合成结果。两个字段来源不同,优先级由**合成器**定死、loop 不参与: + * 有 `halt` 就停,否则有 `continue` 就注入并续跑。 + */ +export interface TurnEndOutcome { + /** 强制续跑要注入的用户消息文本。`halt` 非空时 loop 不看它。 */ + continue?: string; + /** 整轮急停的原因。 */ + halt?: string; } export interface IoGroup { diff --git a/packages/kernel/src/agentSpec/derivedAgentExecutor.ts b/packages/kernel/src/agentSpec/derivedAgentExecutor.ts index 5b3545d..fa72aaa 100644 --- a/packages/kernel/src/agentSpec/derivedAgentExecutor.ts +++ b/packages/kernel/src/agentSpec/derivedAgentExecutor.ts @@ -18,6 +18,7 @@ import { createToolBatchExecutor } from "../agentLoop/executeTools"; import type { LlmGroup, TurnRecord, ToolExecutorEnv } from "../agentLoop/types"; import type { Tracer } from "@helios/observability-langsmith"; import type { MountRegistry } from "../agentLoop/mountRegistry"; +import { createLoopExtensions } from "../loopExtensions"; import type { AgentEvent } from "../events"; import { uid } from "../ids"; import { @@ -441,7 +442,7 @@ export class DerivedAgentExecutor { ), }, tree, - extensions: { mounts, hooks: this.opts.toolEnv.hooks }, + extensions: createLoopExtensions(mounts, this.opts.toolEnv.hooks), run: { runId: ctx.agentId, runIndex: 0, diff --git a/packages/kernel/src/loopExtensions.ts b/packages/kernel/src/loopExtensions.ts new file mode 100644 index 0000000..c5d4665 --- /dev/null +++ b/packages/kernel/src/loopExtensions.ts @@ -0,0 +1,57 @@ +import type { MountTurnEndPayload } from "@helios/ports"; +import type { LoopExtensions, TurnEndOutcome } from "./agentLoop/types"; +import type { MountRegistry } from "./agentLoop/mountRegistry"; +import type { HookRunner } from "./hookRunner"; + +/** Stop hook 只给了 `continue:false` 没给理由时的兜底文案。与工具层的那份保持一致。 */ +const HALT_FALLBACK = "hook 要求终止本轮(continue:false)"; + +/** + * 把两套扩展机制合成 loop 认得的闭包集合。**这里是 harness 侧唯一决定「谁先谁后」的地方。** + * + * 迁移前这些规则散在 `runTurnLoop` 里:loop 自己先调 `hooks.runStop`、再 `mounts.dispatch`, + * 还得自己实现「急停压过续跑」。那些是**组合规则**,跟「收消息 → 调工具 → 再来一轮」 + * 这件事无关,放在 loop 里意味着每加一种扩展机制都要改循环。 + * + * 两套机制在这里**仍然是两套**——hook 有否决权与外部进程语义(超时 / exit code), + * 挂载点没有;只是它们对 loop 的呈现被统一成一个闭包。 + */ +export function createLoopExtensions(mounts: MountRegistry, hooks: HookRunner): LoopExtensions { + return { + onRunStart: (p) => mounts.dispatch("run:start", p, p.logger), + onTurnStart: (p) => mounts.dispatch("turn:start", p, p.logger), + onContextPrepare: (p) => mounts.dispatch("context:prepare", p, p.logger), + onLlmResponse: async (p) => void (await mounts.dispatch("llm:response", p, p.logger)), + onTurnEnd: (p) => composeTurnEnd(mounts, hooks, p), + onRunEnd: (p) => mounts.dispatch("run:end", p, p.logger), + }; +} + +/** + * turn 收尾的两个续跑来源,顺序是设计出来的、不由注册次序决定: + * + * 1. `hooks.json` 的 Stop hook —— 用户写的外部命令,先问它。 + * 2. `turn:end` 挂载点 —— 进程内能力(会话级评审、自动续写之类)。 + * + * 有工具调用时本轮无论如何都要续跑,此时不问 Stop hook(省一次进程调用)。 + * 挂载点照问不误:它是旁路观察者,「本轮结束了」这件事对它成立与否与要不要续跑无关。 + * + * 急停压过一切续跑意图。否则「先喊停、再被另一个来源强制续轮」会让 agent 停不下来, + * 且谁赢取决于两者的调用次序——那正是这类规则不该散落在调用点的原因。 + */ +async function composeTurnEnd( + mounts: MountRegistry, + hooks: HookRunner, + p: MountTurnEndPayload, +): Promise { + const stop = p.hadToolUse + ? undefined + : await hooks.runStop({ sessionId: p.sessionId, turnCount: p.turnIndex + 1 }); + const turnEnd = await mounts.dispatch("turn:end", p, p.logger); + + const halt = stop?.continue === false ? (stop.stopReason ?? HALT_FALLBACK) : undefined; + if (halt !== undefined) return { halt }; + + const continueText = (stop?.block ? stop.message : undefined) ?? turnEnd?.continue; + return continueText !== undefined && continueText !== "" ? { continue: continueText } : undefined; +} diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index bcf1478..69148c8 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -23,6 +23,7 @@ import type { LlmRetryOptions } from "./agentLoop/retryBackoff"; import type { ArtifactAction, FileEditObservation } from "./kernel"; import { buildDefaultMounts, compactionCapability, memoryRecallCapability } from "./capabilities"; import { MountRegistry } from "./agentLoop/mountRegistry"; +import { createLoopExtensions } from "./loopExtensions"; import type { Tracer } from "@helios/observability-langsmith"; import { parseJsonLines, @@ -532,7 +533,7 @@ export class Session { snapshotCheckpoint: (turnId) => ports.checkpoint.snapshot(turnId), persistTurn: (record) => this.persistTurn(record), }, - extensions: { mounts, hooks }, + extensions: createLoopExtensions(mounts, hooks), run: { runId, runIndex, diff --git a/packages/kernel/test/live/code-review.eval.live.test.ts b/packages/kernel/test/live/code-review.eval.live.test.ts index 9584e85..5d03b7b 100644 --- a/packages/kernel/test/live/code-review.eval.live.test.ts +++ b/packages/kernel/test/live/code-review.eval.live.test.ts @@ -32,6 +32,8 @@ const TRUST_DEFECTS = ["path-traversal", "unescaped-html", "string-built-filter" const CROSS_FILE_DEFECTS = ["transition-bypass", "duplicated-terminal-set", "shared-mutable-constant"]; /** 第六类:字面上不矛盾,代入一个边界取值才暴露。 */ const NUMERIC_DEFECTS = ["inclusive-end-window", "float-money-round", "utc-days-between"]; +/** 第七类:判据不在代码里,在 README 的架构约定里。 */ +const LAYERING_DEFECTS = ["core-imports-adapter", "core-does-io", "rule-in-adapter"]; const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); @@ -259,6 +261,26 @@ gate(`代码审查 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { 600_000, ); + it( + "分层/依赖方向专项:判据不在代码里,得先去 README 把架构约定读出来", + async () => { + // 第七种阅读方式,与前六类的差别最大:每个文件单看都能跑、都自洽,没有任何自相矛盾。 + // 只有先知道「core 不许依赖 adapters、不许做 I/O、adapters 不许放业务规则」, + // 才看得出 import 是反的。这一类考的是「会不会先去找规矩」而不是「读得细不细」。 + const session = kernel.createSession({ askQuestion: noAsk }); + const out = await session.sendMessage( + [ + "先读 README.md 的架构约定,再检查 src/core/ 与 src/adapters/ 下的代码有没有违反它。", + "逐条给出文件名、函数名和违反了哪一条。只读,不要改任何文件。", + ].join(""), + ); + const found = detectedDefects(reviewText(out)); + report("layering-review", found); + expect(found.filter((id) => LAYERING_DEFECTS.includes(id)).length).toBeGreaterThanOrEqual(1); + }, + 600_000, + ); + it( "派生 agent + fork_context:子带着父的上下文开工,且不产生悬空 tool_use", async () => { diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 8434117..bf98b6f 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -180,6 +180,29 @@ export const DEFECTS: Defect[] = [ summary: "daysBetween() 声明按自然日跨度计费,实际用毫秒差整除 86400000,跨夏令时或非零时区时会少算/多算一天", }, + // --- 分层 / 依赖方向类。前二十一个的判据都在**代码本身**(另一段代码、一句 JSDoc、一个取值); + // 这一类的判据在 README 的架构约定里,光读文件读不出问题——每个文件单看都能跑、都自洽。 + // 必须先知道"这个目录不许依赖那个目录",才看得出 import 是反的。 + { + id: "core-imports-adapter", + file: "src/core/priceEngine.ts", + signals: [["priceengine"], ["quote"]], + summary: + "core/priceEngine.ts 直接 import adapters/httpClient,依赖方向与 README 的「core 不得依赖 adapters」相反", + }, + { + id: "core-does-io", + file: "src/core/priceEngine.ts", + signals: [["console.log"], ["applycoupon"]], + summary: "core/priceEngine.ts 里 applyCoupon() 直接 console.log,违反 README「core 层不做任何 I/O」", + }, + { + id: "rule-in-adapter", + file: "src/adapters/httpClient.ts", + signals: [["freeshipping"], ["free_shipping"], ["postquote"]], + summary: + "免运费阈值这条业务规则被写在 adapters/httpClient.ts 里,README 声明业务规则一律属于 core", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -203,6 +226,15 @@ export const FILES: Record = { - \`add(name, unitPrice, qty)\` 加一行 - \`removeAll(name)\` 移除**全部**同名商品 - \`list()\` 返回当前所有行的只读快照 + +## 架构约定 + +代码分两层,**依赖方向只能是 adapters → core,绝不反向**: + +- \`src/core/\` —— 纯业务规则。不得 import \`src/adapters/\` 下的任何东西, + 也不做任何 I/O(网络、文件、\`console\`)。它必须能在没有外部世界的情况下被测。 +- \`src/adapters/\` —— 与外部世界打交道(HTTP、存储)。只做搬运与格式转换, + **不得包含任何业务规则**;规则一律放在 core,adapter 调用它。 `, "src/pricing.ts": `import type { LineItem } from "./cart"; @@ -498,6 +530,46 @@ export function daysBetween(from: Date, to: Date): number { export function chargeFor(from: Date, to: Date, dailyRate: number): number { return roundToCents(daysBetween(from, to) * dailyRate); } +`, + "src/core/priceEngine.ts": `import { postQuote } from "../adapters/httpClient"; + +/** 报价引擎。纯业务规则,不碰外部世界。 */ + +export interface Quote { + subtotal: number; + coupon: number; + payable: number; +} + +/** 应用优惠券,返回新的报价。 */ +export function applyCoupon(q: Quote, coupon: number): Quote { + console.log("applying coupon", coupon); + const payable = Math.max(0, q.subtotal - coupon); + return { subtotal: q.subtotal, coupon, payable }; +} + +/** 报价并上报给计费服务。 */ +export async function quote(subtotal: number, coupon: number): Promise { + const q = applyCoupon({ subtotal, coupon, payable: subtotal }, coupon); + await postQuote(q); + return q; +} +`, + "src/adapters/httpClient.ts": `import type { Quote } from "../core/priceEngine"; + +const FREE_SHIPPING_THRESHOLD = 99; + +/** 把报价发给计费服务,顺带标注是否免运费。 */ +export async function postQuote(q: Quote): Promise { + const body = { + ...q, + freeShipping: q.payable >= FREE_SHIPPING_THRESHOLD, + }; + await fetch("https://billing.internal/quote", { + method: "POST", + body: JSON.stringify(body), + }); +} `, "test/pricing.test.ts": `import { describe, it, expect } from "vitest"; import { subtotal, total } from "../src/pricing"; diff --git a/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts b/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts new file mode 100644 index 0000000..2ba1618 --- /dev/null +++ b/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts @@ -0,0 +1,46 @@ +// loop 的扩展面边界护栏。 +// +// 为什么要用「读源码」这种笨办法:这条边界坏掉**不会有任何行为症状**。 +// 谁在 `runTurnLoop` 里加一行 `hooks.runPreCompact(...)`,测试全绿、agent 照跑, +// 只是 loop 又开始知道「有 hooks 这套机制」了,而这正是本次重构要消除的东西。 +// 行为测试测不到「它不该知道什么」,只有类型/依赖层面测得到。 +// +// 范围**只管 import**,不做全文关键词扫描——后者会被注释和字符串误伤, +// 而注释里提到 hooks 恰恰是应该的(要解释为什么不在这里做)。 +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +function importedModules(relPath: string): string[] { + const src = readFileSync(fileURLToPath(new URL(relPath, import.meta.url)), "utf8"); + return [...src.matchAll(/^\s*import\s[^;]*?from\s+["']([^"']+)["']/gm)].map((m) => m[1]!); +} + +describe("loop 的扩展面只有闭包", () => { + it("runTurnLoop 不 import 任何具体扩展机制", () => { + const imports = importedModules("../../../src/agentLoop/runTurnLoop.ts"); + const leaked = imports.filter((m) => /mountRegistry|hookRunner|capabilities/i.test(m)); + // 合成器(loopExtensions)才认识这些;loop 只认 `LoopExtensions` 这个纯闭包接口。 + expect(leaked).toEqual([]); + }); + + it("RunTurnLoopParams 的扩展面是 LoopExtensions,不是任何注册表", () => { + const src = readFileSync( + fileURLToPath(new URL("../../../src/agentLoop/runTurnLoop.ts", import.meta.url)), + "utf8", + ); + expect(src).toMatch(/extensions:\s*LoopExtensions;/); + }); + + it("LoopExtensions 契约本身不引用 MountRegistry / HookRunner", () => { + // 契约里出现注册表类型,等于把「多订阅者 + 合并策略」这套概念又递回 loop。 + // 允许引用 `Mount*Payload`:payload 描述的是 loop 自己那一刻的事实。 + const src = readFileSync( + fileURLToPath(new URL("../../../src/agentLoop/types.ts", import.meta.url)), + "utf8", + ); + const contract = src.slice(src.indexOf("export interface LoopExtensions")); + const body = contract.slice(0, contract.indexOf("\n}")); + expect(body).not.toMatch(/MountRegistry|HookRunner/); + }); +}); diff --git a/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts b/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts index ecbd3f1..96e6ca6 100644 --- a/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts +++ b/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts @@ -20,6 +20,7 @@ import { createProviderResolver } from "../../../src/agentLoop/providerResolver" import { createToolBatchExecutor } from "../../../src/agentLoop/executeTools"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; import { HookRunner } from "../../../src/hookRunner"; +import { createLoopExtensions } from "../../../src/loopExtensions"; import { createToolView } from "../../../src/toolRegistry"; import { createDetachedTree } from "../../../src/agentSpec/detachedTree"; @@ -101,7 +102,7 @@ async function drive(opts: { llm: { resolve: createProviderResolver(opts.registry), options: opts.options }, tools: { registry: createToolView([]), execute: executor(hooks) }, tree, - extensions: { mounts, hooks }, + extensions: createLoopExtensions(mounts, hooks), run: { runId: "run-1", runIndex: 0, @@ -216,7 +217,7 @@ describe("provenance 挂在消息上,不由 loop 记流水账", () => { llm: { resolve: createProviderResolver(registryOf(p1, p2)), options: { provider: "p1", model: "m1" } }, tools: { registry: createToolView([]), execute: executor(hooks) }, tree, - extensions: { mounts, hooks }, + extensions: createLoopExtensions(mounts, hooks), run: { runId: "run-1", runIndex: 0, @@ -282,7 +283,7 @@ describe("provenance 挂在消息上,不由 loop 记流水账", () => { }, tools: { registry: createToolView([]), execute: executor(hooks) }, tree, - extensions: { mounts, hooks }, + extensions: createLoopExtensions(mounts, hooks), run: { runId: "run-1", runIndex: 0, diff --git a/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts b/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts index daee4a8..28381a8 100644 --- a/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts +++ b/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts @@ -16,6 +16,7 @@ import { createProviderResolver } from "../../../src/agentLoop/providerResolver" import { createToolBatchExecutor } from "../../../src/agentLoop/executeTools"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; import { HookRunner } from "../../../src/hookRunner"; +import { createLoopExtensions } from "../../../src/loopExtensions"; import { derivedNotificationsCapability } from "../../../src/capabilities/derivedNotifications"; import { createToolView } from "../../../src/toolRegistry"; import { createDetachedTree } from "../../../src/agentSpec/detachedTree"; @@ -107,7 +108,7 @@ async function drive(opts: LoopRun) { execute: testExecutor({ ...(opts.tools !== undefined ? { tools: opts.tools } : {}) }), }, tree, - extensions: { mounts, hooks: new HookRunner() }, + extensions: createLoopExtensions(mounts, new HookRunner()), run: { runId: "run-1", runIndex: 0, @@ -157,7 +158,7 @@ describe("runTurnLoop —— steering 消息必须入树", () => { execute: testExecutor({ hooks }), }, tree, - extensions: { mounts: testMounts(derivedNotificationsCapability(() => queue.splice(0))), hooks }, + extensions: createLoopExtensions(testMounts(derivedNotificationsCapability(() => queue.splice(0))), hooks), run: { runId: "run-1", runIndex: 0, @@ -234,7 +235,7 @@ describe("runTurnLoop —— usedModels 累积", () => { }, tools: { registry: createToolView([]), execute: testExecutor({ hooks }) }, tree, - extensions: { mounts: testMounts(router), hooks }, + extensions: createLoopExtensions(testMounts(router), hooks), run: { runId: "run-1", runIndex: 0, @@ -313,7 +314,7 @@ describe("runTurnLoop —— context:prepare", () => { execute: testExecutor(), }, tree, - extensions: { mounts: testMounts(appender("first", "一"), appender("second", "二")), hooks: new HookRunner() }, + extensions: createLoopExtensions(testMounts(appender("first", "一"), appender("second", "二")), new HookRunner()), run: { runId: "run-1", runIndex: 0, @@ -351,7 +352,7 @@ describe("runTurnLoop —— context:prepare", () => { execute: testExecutor(), }, tree, - extensions: { mounts: testMounts(router, appender("probe", "x", seen)), hooks: new HookRunner() }, + extensions: createLoopExtensions(testMounts(router, appender("probe", "x", seen)), new HookRunner()), run: { runId: "run-1", runIndex: 0, diff --git a/packages/kernel/test/unit/loopExtensions.test.ts b/packages/kernel/test/unit/loopExtensions.test.ts new file mode 100644 index 0000000..5ed7789 --- /dev/null +++ b/packages/kernel/test/unit/loopExtensions.test.ts @@ -0,0 +1,119 @@ +// 合成器的单测。这些规则迁移前写在 `runTurnLoop` 里,只有整机集成测试覆盖; +// 收进合成器之后可以直接测,且**测的就是规则本身**而不是「循环跑完之后的样子」。 +import { describe, expect, it } from "vitest"; +import type { Logger, MountTurnEndPayload, StopDecision } from "@helios/ports"; +import { MountRegistry } from "../../src/agentLoop/mountRegistry"; +import { HookRunner } from "../../src/hookRunner"; +import { createLoopExtensions } from "../../src/loopExtensions"; + +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; + +function payload(hadToolUse: boolean): MountTurnEndPayload { + return { + sessionId: "s1", + signal: new AbortController().signal, + logger: silent, + turnId: "t1", + turnIndex: 0, + hadToolUse, + }; +} + +/** 只在 Stop 事件上表态的 hook。返回 undefined 表示不注册任何 hook。 */ +function hooksWith(stop?: StopDecision): { runner: HookRunner; calls: number[] } { + const runner = new HookRunner(); + const calls: number[] = []; + if (stop !== undefined) { + runner.register([ + { + event: "Stop", + handler: (p) => { + calls.push(p.turnCount); + return stop; + }, + }, + ]); + } + return { runner, calls }; +} + +/** 挂在 turn:end 上、要求续跑的能力。 */ +function continuer(text: string): MountRegistry { + const registry = new MountRegistry(); + registry.register({ + name: "continuer", + mounts: [{ at: "turn:end", run: async () => ({ continue: text }) }], + }); + return registry; +} + +describe("composeTurnEnd:两个续跑来源的合成规则", () => { + it("有工具调用时不问 Stop hook——本轮无论如何都要续跑,省一次外部进程调用", async () => { + const { runner, calls } = hooksWith({ block: true, message: "继续" }); + const ext = createLoopExtensions(new MountRegistry(), runner); + + await ext.onTurnEnd?.(payload(true)); + + expect(calls).toEqual([]); + }); + + it("没有工具调用时才问 Stop hook,turnCount 从 1 起算", async () => { + const { runner, calls } = hooksWith({ block: true, message: "继续" }); + const ext = createLoopExtensions(new MountRegistry(), runner); + + const out = await ext.onTurnEnd?.(payload(false)); + + expect(calls).toEqual([1]); + expect(out).toEqual({ continue: "继续" }); + }); + + it("急停压过一切续跑意图——hook 的 block 与 turn:end 的 continue 都不再生效", async () => { + // 「先喊停、再被另一个来源强制续轮」会让 agent 停不下来。这条规则必须与两个来源的 + // 调用次序无关,所以放在合成器里而不是调用点。 + const { runner } = hooksWith({ continue: false, stopReason: "预算超限", block: true, message: "继续" }); + const ext = createLoopExtensions(continuer("能力也要求续跑"), runner); + + const out = await ext.onTurnEnd?.(payload(false)); + + expect(out).toEqual({ halt: "预算超限" }); + }); + + it("continue:false 没给理由时有兜底文案,不会变成「停了但没人知道为什么」", async () => { + const { runner } = hooksWith({ continue: false }); + const ext = createLoopExtensions(new MountRegistry(), runner); + + const out = await ext.onTurnEnd?.(payload(false)); + + expect(out?.halt).toBe("hook 要求终止本轮(continue:false)"); + }); + + it("hook 的 block 优先于 turn:end 的 continue——用户的外部命令先于进程内能力", async () => { + const { runner } = hooksWith({ block: true, message: "来自 hook" }); + const ext = createLoopExtensions(continuer("来自能力"), runner); + + const out = await ext.onTurnEnd?.(payload(false)); + + expect(out).toEqual({ continue: "来自 hook" }); + }); + + it("两个来源都没意见时返回 undefined,而不是一个空对象——loop 只需判一次有没有", async () => { + const { runner } = hooksWith(); + const ext = createLoopExtensions(new MountRegistry(), runner); + + expect(await ext.onTurnEnd?.(payload(false))).toBeUndefined(); + }); + + it("即便有工具调用(不问 hook),turn:end 挂载点照样分发——它是旁路观察者", async () => { + const seen: string[] = []; + const registry = new MountRegistry(); + registry.register({ + name: "probe", + mounts: [{ at: "turn:end", run: async (p) => void seen.push(p.turnId) }], + }); + const ext = createLoopExtensions(registry, new HookRunner()); + + await ext.onTurnEnd?.(payload(true)); + + expect(seen).toEqual(["t1"]); + }); +}); From 3a8b90df083425debe1db4dad58abb141a22abb6 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 16:34:44 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(kernel):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=8F=AF=E8=87=AA=E5=B8=A6=E6=8C=82=E8=BD=BD=E7=82=B9=EF=BC=9B?= =?UTF-8?q?capability=20=E4=B8=89=E5=A4=84=E6=92=9E=E5=90=8D=E6=94=B6?= =?UTF-8?q?=E6=95=9B=E4=B8=BA=20policy=20/=20MountBundle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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.md 这一派的策略,换成 mem0(每轮带 query 检索、 结果作消息、turn 结束抽取)一条都用不上,还会在 session:start 白调一次 recall。 ## 测试 行为等价的主要证据是断言一字未改:session-mounts.test.ts 里 expect(prefix).toContain("\nprobe:default\n") 原样通过, 改的只有 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。 --- docs/[IP]loop-decoupling.md | 32 ++++- docs/code-organization.md | 41 ++++-- packages/capability-fs/src/index.ts | 4 +- packages/kernel/src/agentLoop/executeTools.ts | 2 +- .../kernel/src/agentLoop/mountRegistry.ts | 4 +- packages/kernel/src/capabilities/index.ts | 82 ------------ .../kernel/src/capabilities/memoryRecall.ts | 27 ---- packages/kernel/src/kernel.ts | 21 ++- packages/kernel/src/pluginLoader.ts | 21 ++- .../{capabilities => policies}/compaction.ts | 4 +- .../{capabilities => policies}/costMeter.ts | 4 +- .../derivedNotifications.ts | 6 +- .../{capabilities => policies}/fileAudit.ts | 4 +- packages/kernel/src/policies/index.ts | 120 ++++++++++++++++++ .../{capabilities => policies}/modelRouter.ts | 4 +- .../{capabilities => policies}/toolCache.ts | 6 +- packages/kernel/src/session.ts | 16 ++- .../test/fixtures/capAnalyticsPlugin.ts | 84 ++++++++++++ packages/kernel/test/fixtures/probeMemory.ts | 31 ++++- packages/kernel/test/p1-impls.test.ts | 4 +- packages/kernel/test/plugin-mounts.test.ts | 115 +++++++++++++++++ packages/kernel/test/session-mounts.test.ts | 19 ++- .../agentLoop/executeTools.mounts.test.ts | 12 +- .../agentLoop/loopExtensionSurface.test.ts | 2 +- .../agentLoop/runTurnLoop.provider.test.ts | 14 +- .../agentLoop/runTurnLoop.steering.test.ts | 16 +-- .../compaction.test.ts | 14 +- .../fileAudit.test.ts | 14 +- .../registrationOrder.test.ts | 8 +- .../toolCache.test.ts | 16 +-- .../toolPayloads.ts | 0 packages/memory-fs/src/index.ts | 32 ++++- packages/ports/src/mount.ts | 11 +- packages/ports/src/types.ts | 17 +++ 34 files changed, 596 insertions(+), 211 deletions(-) delete mode 100644 packages/kernel/src/capabilities/index.ts delete mode 100644 packages/kernel/src/capabilities/memoryRecall.ts rename packages/kernel/src/{capabilities => policies}/compaction.ts (98%) rename packages/kernel/src/{capabilities => policies}/costMeter.ts (84%) rename packages/kernel/src/{capabilities => policies}/derivedNotifications.ts (89%) rename packages/kernel/src/{capabilities => policies}/fileAudit.ts (96%) create mode 100644 packages/kernel/src/policies/index.ts rename packages/kernel/src/{capabilities => policies}/modelRouter.ts (70%) rename packages/kernel/src/{capabilities => policies}/toolCache.ts (93%) create mode 100644 packages/kernel/test/fixtures/capAnalyticsPlugin.ts create mode 100644 packages/kernel/test/plugin-mounts.test.ts rename packages/kernel/test/unit/{capabilities => policies}/compaction.test.ts (93%) rename packages/kernel/test/unit/{capabilities => policies}/fileAudit.test.ts (91%) rename packages/kernel/test/unit/{capabilities => policies}/registrationOrder.test.ts (94%) rename packages/kernel/test/unit/{capabilities => policies}/toolCache.test.ts (87%) rename packages/kernel/test/unit/{capabilities => policies}/toolPayloads.ts (100%) diff --git a/docs/[IP]loop-decoupling.md b/docs/[IP]loop-decoupling.md index 8b7fc8b..ae37bf1 100644 --- a/docs/[IP]loop-decoupling.md +++ b/docs/[IP]loop-decoupling.md @@ -609,10 +609,34 @@ P2.7 判定「hook 与 mount 是两套机制,不能嵌套」——对,但** 谁在 loop 里加一行 `hooks.runXxx(...)`,测试全绿、agent 照跑,只是边界又没了。 行为测试测不到「它不该知道什么」。三处变异全部命中。 -**下一步(未做)**:插件包可自带 mounts。判据见 `code-organization.md`—— -时机由接口钉死的 Port 留在装配根,没钉死的(`MemoryPort`)与第三方 Port 必须自己声明。 -P2.10 是它的前提:不先把 loop 的扩展面收干净,第三方 mounts 一进来就会踩到 -loop 里那几处硬编码顺序。 +**下一步**:插件包可自带 mounts —— 已在 P2.11 落地,见下。 + +### P2.11(已完成):命名收敛 + 插件自带挂载 + +**a) 三个 capability 撞车。** `CapabilityProvider`(插件契约)/ `kernel/src/capabilities/` +(port→mount 适配器)/ `cap-*` 包,共用一个词,讨论时永远要先问「你说的是哪个」。 +保留 `CapabilityProvider`(它是对外契约、与包名一致),另两个改名: +目录 → `kernel/src/policies/`,函数 `xxxCapability` → `xxxPolicy`, +类型 `MountedCapability` → `MountBundle`。 + +**b) `PluginModule.mounts(instance, ctx)`。** 任何插件包都能自己声明挂载, +kernel 用 `splitPluginMounts()` 按作用域拆进会话级 / run 级两个注册表。 +插件挂载**恒定排在内置之后**(`context:prepare` 是 concat,拼接顺序模型可见)。 + +放在 `PluginModule` 而不是 `CapabilityProvider` 上,因为想挂载的不只是插件—— +判据是**Port 的方法签名有没有把调用时机钉死**。`memoryRecallPolicy` 因此从 kernel +**搬进了 `@helios/memory-fs`**:`recall(query)` 里没说何时调、结果去哪, +`session:start` + `` 标签那套是 MEMORY.md 这一派的策略,mem0 一条都用不上。 + +⚠️ **迁移的主要证据是「断言一字未改」**:`session-mounts.test.ts` 里 +`expect(prefix).toContain("\nprobe:default\n")` 原样通过, +改的只有 fixture——它现在得像个真实的 MemoryPort 插件那样自己声明挂载。 + +⚠️ 顺带发现「召回为空时不注入空标签」那条**变成了空转**(不装插件自然没标签), +已改成装一个「会声明挂载、但召回返回空串」的插件,才真的测到空分支。 + +护栏:`test/plugin-mounts.test.ts`(7 例,含 `splitPluginMounts` 单测 + 第三方插件 +工具与挂载并存的整机验证)。三处变异命中(不注册 run 级 / 不注册会话级 / 清空作用域清单)。 ### P3 为什么建议不做 diff --git a/docs/code-organization.md b/docs/code-organization.md index 8fd2c0a..93de411 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -2,38 +2,57 @@ 只写**容易被下一个人搞错、且搞错了不会有任何报错**的几条。 -## `packages/kernel/src/capabilities/` —— 按能力分,不按机制分 +## `packages/kernel/src/policies/` —— 按能力分,不按机制分 -一文件一能力,**文件名说它做什么,不说它用什么机制**: +一文件一策略,**文件名说它做什么,不说它用什么机制**: ``` -capabilities/ +policies/ modelRouter.ts 路由:每轮选模型 costMeter.ts 记账 toolCache.ts 工具结果缓存 fileAudit.ts 文件改动审计 compaction.ts 压缩策略 - memoryRecall.ts 记忆召回 derivedNotifications.ts 派生 agent 完成通知 - index.ts buildDefaultMounts —— 装配根 + index.ts buildDefaultMounts / splitPluginMounts —— 装配根 ``` -**为什么不叫 `mounts/`**:目录里放的是 capability(挂在某个时机上的策略), +**为什么不叫 `mounts/`**:目录里放的是策略(挂在某个时机上的决策规则), mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后果已经发生过一次—— -`fileAuditCapability` 曾被塞进 `costAwareMounts.ts`,审计跟成本毫无关系, -只因为它们在同一个 PR 里落地。**目录名是机制的话,任何新能力都"符合"它,于是什么都能塞。** +`fileAuditPolicy` 曾被塞进 `costAwareMounts.ts`,审计跟成本毫无关系, +只因为它们在同一个 PR 里落地。**目录名是机制的话,任何新东西都"符合"它,于是什么都能塞。** -### 三个概念别混 +**为什么也不叫 `capabilities/`**(它曾经叫这个):仓库里已经有一个 +`CapabilityProvider`(插件契约)和一堆 `cap-*` 包,三者共用"capability"一词, +讨论时永远要先问一句"你说的是哪个 capability"。 + +### 四个概念别混 | | 是什么 | 谁提供 | |---|---|---| | **Port** | 原料 | manifest 里的插件包 | | **mount** | 时机插槽(`turn:start` / `tool:after` / …) | `ports/src/mount.ts` 的封闭联合 | -| **capability** | 挂在时机上的策略 | 本目录 | +| **policy** | 挂在时机上的**内置**策略 | 本目录 | +| **`CapabilityProvider`** | **插件**:供工具 + 订阅 hook(对标 pi 的 Extension) | 第三方 / `cap-*` 包 | -⚠️ **payload 里永远不会有 `ports`。** capability 要什么 Port,构造期自己声明、由装配根注入; +⚠️ **payload 里永远不会有 `ports`。** 策略要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 +### 第三方怎么挂上去:`PluginModule.mounts()` + +任何插件包(不限于 `CapabilityProvider`)都能在模块上导出 `mounts(instance, ctx)` +交出 `MountBundle[]`,kernel 按作用域拆进两个注册表。所以一个「带自定义工具的领域插件」 +一个包就够:`getTools()` 给工具、`getHookHandlers()` 订阅 hook、`mounts()` 挂时机, +manifest 加一行即可,**核心代码一行不动**。 + +⚠️ **插件挂载恒定排在内置之后。** `context:prepare` 是 concat 语义、拼接顺序模型可见, +让第三方插到内置前面去,等于让「装了哪个插件」决定提示词长什么样。 + +⚠️ **`turn:start` 的返回类型里没有 `system` 字段,这是故意的。** 想每轮改系统提示词 —— 不给: +system 前缀每轮变一次,prompt cache 整段作废(实测 30% → 98% 靠的就是前缀稳定)。 +会话开始注入走 `session:start` 的 `additionalContext`,按轮追加只能走 `context:prepare` +往消息**尾部** append。 + ### `index.ts` 是装配根,但只对**时机由接口钉死**的那些 Port 成立 判据是一句话:**Port 的方法签名有没有把调用时机钉死?** diff --git a/packages/capability-fs/src/index.ts b/packages/capability-fs/src/index.ts index 7eb26d0..e22851f 100644 --- a/packages/capability-fs/src/index.ts +++ b/packages/capability-fs/src/index.ts @@ -7,10 +7,10 @@ import type { import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; // @helios/capability-fs —— CapabilityProvider 官方文件扫描实现(替代原 Skill+Extension)。 -// 扫描 .helios/capabilities//SKILL.md,每个目录产出一个工具:调用即返回该 skill 全文, +// 扫描 .helios/policies//SKILL.md,每个目录产出一个工具:调用即返回该 skill 全文, // 供 Agent 按需加载领域指引。是否做"分层解锁"由本实现自行决定,不属接口契约。 -const GLOB = ".helios/capabilities/*/SKILL.md"; +const GLOB = ".helios/policies/*/SKILL.md"; interface ScannedSkill { name: string; diff --git a/packages/kernel/src/agentLoop/executeTools.ts b/packages/kernel/src/agentLoop/executeTools.ts index 093efff..b74061d 100644 --- a/packages/kernel/src/agentLoop/executeTools.ts +++ b/packages/kernel/src/agentLoop/executeTools.ts @@ -131,7 +131,7 @@ async function sequentialMap(items: T[], fn: (item: T) => Promise): Pro * 3. **收尾**——`tool:after` → PostToolUse → 事件 → trace。 * * 这里刻意不认识缓存、审计、记账中的任何一个:它们全都挂在 `tool:before` / `tool:after` - * 上(见 `../capabilities/`)。迁移前这三件事的调用点散在本函数里, + * 上(见 `../policies/`)。迁移前这三件事的调用点散在本函数里, * 迫使工具执行层一路保管 fileSystem / versionProvider 等自己一行都不用的东西。 */ async function runOneToolCall(block: ToolUseBlock, ctx: ToolExecCtx): Promise { diff --git a/packages/kernel/src/agentLoop/mountRegistry.ts b/packages/kernel/src/agentLoop/mountRegistry.ts index 0f1fe2a..10f3332 100644 --- a/packages/kernel/src/agentLoop/mountRegistry.ts +++ b/packages/kernel/src/agentLoop/mountRegistry.ts @@ -1,7 +1,7 @@ import type { AnyMount, Mount, - MountedCapability, + MountBundle, MountPayloads, MountPoint, MountResults, @@ -53,7 +53,7 @@ const CONCAT_FIELD: Partial> = { export class MountRegistry { private readonly buckets = new Map>(); - register(capability: MountedCapability): void { + register(capability: MountBundle): void { for (const mount of capability.mounts) { const list = this.buckets.get(mount.at) ?? []; list.push({ owner: capability.name, mount }); diff --git a/packages/kernel/src/capabilities/index.ts b/packages/kernel/src/capabilities/index.ts deleted file mode 100644 index 27a1eda..0000000 --- a/packages/kernel/src/capabilities/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -// 内建能力的装配根(composition root)。 -// -// **这是全仓库唯一被允许知道「哪个 Port 该在哪个时机跑」的地方。** 组合根的职责就是知道 -// 全部具体接线;把它挪出 kernel 不会消除这份知识,只会换个地方放,而另外两个候选位置更糟: -// -// - 放进各 Port 的适配器包(`costmeter-default` 自己声明挂在 `llm:response`)——那么换一个 -// 实现就连带换掉了策略。`memoryRecall` 更明显:`` 标签格式与注入位置是**模型可见的 -// 策略**,不该由 `memory-fs` 还是 `memory-sqlite` 决定。 -// - 单开一个 bindings 包——kernel 反过来要依赖它,依赖方向倒过来了。 -// -// 目录按**能力**组织(一文件一能力,文件名说它做什么),不按机制。迁移前叫 `mounts/` -// 并按机制分组,结果是 `fileAuditCapability` 被塞进 `costAwareMounts.ts`—— -// 审计跟成本毫无关系,只因为它们在同一个 PR 里落地。 -import type { Message, PortRegistry } from "@helios/ports"; -import { MountRegistry } from "../agentLoop/mountRegistry"; -import type { AgentEvent } from "../events"; -import type { HookRunner } from "../hookRunner"; -import type { ArtifactAction, FileEditObservation } from "../kernel"; -import { costMeterCapability } from "./costMeter"; -import { derivedNotificationsCapability } from "./derivedNotifications"; -import { fileAuditCapability } from "./fileAudit"; -import { modelRouterCapability } from "./modelRouter"; -import { toolCacheCapability } from "./toolCache"; - -export * from "./compaction"; -export * from "./costMeter"; -export * from "./derivedNotifications"; -export * from "./fileAudit"; -export * from "./memoryRecall"; -export * from "./modelRouter"; -export * from "./toolCache"; - -export interface DefaultMountsOptions { - ports: Pick< - PortRegistry, - "modelRouter" | "costMeter" | "toolCache" | "versionProvider" | "fileSystem" - >; - hooks: HookRunner; - recordEdit?: (edit: FileEditObservation) => Promise; - markAuditGap?: (gap: { toolUseId?: string; reason: string; createdAt: number }) => Promise; - /** 本 run 的事件出口,供审计广播 openDiff。 */ - emit: (event: AgentEvent) => void; - /** - * drain 后台派生 agent 的完成通知。不传 = 本会话不接派生 agent, - * 该能力整个不注册(派生执行器自己跑的子 run 也不该再往里塞通知)。 - */ - consumeNotifications?: () => Message[]; -} - -/** - * 装配内建能力。**每个 run 建一份**——不是为了隔离状态(缓存与审计都按 toolUseId 记, - * 什么粒度都安全),而是因为审计要往「本 run 的事件出口」广播 openDiff, - * 而那个出口本来就是 run 级的。 - * - * 同一 run 内 loop 与工具执行层必须共用这一份实例:缓存与审计都在 `tool:before` 存状态、 - * 到 `tool:after` 才取,分别装配会让两边各记各的账。 - * - * 注册顺序即执行顺序,但 `file-audit` 与 `tool-cache` 的先后**不是正确性约束**。 - * 曾经以为是(「审计排后面的话,命中那次连快照都不会拍」)——属实,可那次快照本来就用不上: - * 短路 = 工具没跑 = 文件没被改,审计的 `tool:after` 靠 payload 的 `executed` 直接判掉。 - * 护住审计的是 `executed` 这道判断,不是这里的顺序; - * `test/unit/capabilities/registrationOrder.test.ts` 对照跑过两种顺序,可观测行为逐字相同, - * 并钉住了「短路的调用不得产生编辑记录」这条真正的不变量。 - */ -export function buildDefaultMounts(opts: DefaultMountsOptions): MountRegistry { - const registry = new MountRegistry(); - registry.register(modelRouterCapability(opts.ports.modelRouter)); - registry.register(costMeterCapability(opts.ports.costMeter)); - registry.register( - fileAuditCapability({ - fileSystem: opts.ports.fileSystem, - ...(opts.recordEdit !== undefined ? { recordEdit: opts.recordEdit } : {}), - ...(opts.markAuditGap !== undefined ? { markAuditGap: opts.markAuditGap } : {}), - emitArtifactAction: (event) => opts.emit(event as AgentEvent), - }), - ); - registry.register(toolCacheCapability(opts.ports.toolCache, opts.ports.versionProvider)); - if (opts.consumeNotifications) { - registry.register(derivedNotificationsCapability(opts.consumeNotifications)); - } - return registry; -} diff --git a/packages/kernel/src/capabilities/memoryRecall.ts b/packages/kernel/src/capabilities/memoryRecall.ts deleted file mode 100644 index 8bcd886..0000000 --- a/packages/kernel/src/capabilities/memoryRecall.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MemoryPort, MountedCapability } from "@helios/ports"; - -/** - * 记忆召回:每会话只跑一次,产物拼进冻结的 system 前缀。 - * - * 迁移前是 `Session` 直接调 `ports.memory.recall()` 再手写拼 `` - * ——那是 Session 硬编码了一个 Port 的使用策略,与 compact 迁移前是同一种形状。 - * `session:start` 的合并规则本来就是 concat、返回值本来就是 `additionalContext`, - * 那段手写拼接就是 concat 的手工版。 - * - * **只挂 `session:start` 而不是每轮跑**是缓存纪律:system 前缀每轮变一次, - * prompt cache 就全废(实测命中率 30% → 98% 靠的就是前缀稳定)。 - */ -export function memoryRecallCapability(memory: MemoryPort): MountedCapability { - return { - name: "memory-recall", - mounts: [ - { - at: "session:start", - run: async (p) => { - const recalled = await memory.recall(p.firstMessage); - return recalled ? { additionalContext: `\n${recalled}\n` } : {}; - }, - }, - ], - }; -} diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index e61c142..e308532 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -5,6 +5,7 @@ import type { Tool, AskQuestionRequest, AskQuestionResponse, + MountBundle, } from "@helios/ports"; import { ServiceCollection } from "./serviceCollection"; import { IFileSystemPort } from "./tokens"; @@ -28,7 +29,7 @@ import { createLangSmithTracer, type Tracer } from "@helios/observability-langsm import { DerivedAgentExecutor } from "./agentSpec/derivedAgentExecutor"; import { createAgentCapabilityProvider } from "./agentSpec/agentTools"; import { AgentPortResolver } from "./agentSpec/portResolver"; -import { buildDefaultMounts } from "./capabilities"; +import { buildDefaultMounts, splitPluginMounts } from "./policies"; /** 只读的 port/工具聚合信息,供 UI 的 Ports 页展示。 */ export interface PortInfo { @@ -120,6 +121,12 @@ export class Kernel { private readonly hooks: HookRunner; private readonly ports = createLivePortRegistry(this.services, this.llm); private readonly pluginDisposables: Array<{ dispose(): void | Promise }> = []; + /** + * 插件经 `PluginModule.mounts()` 声明的挂载,按作用域拆好。 + * 在 `start()` 里一次性算出:bundle 对象本身按插件建一次(状态活在闭包里), + * 每 run 重建注册表时把同一批对象重新注册进去即可。 + */ + private pluginMounts: { session: MountBundle[]; run: MountBundle[] } = { session: [], run: [] }; private readonly tracer: Tracer; private started = false; private disposePromise: Promise | undefined; @@ -146,7 +153,7 @@ export class Kernel { ports: this.ports, }; - const { capabilities, disposables } = await loadPlugins( + const { capabilities, disposables, mounts: declaredMounts } = await loadPlugins( this.opts.manifest, this.services, ctx, @@ -154,6 +161,7 @@ export class Kernel { this.opts.resolvePackage, ); this.pluginDisposables.push(...disposables); + this.pluginMounts = splitPluginMounts(declaredMounts); // 必须实现的 Port 校验 if (this.llm.size === 0) { @@ -290,6 +298,7 @@ export class Kernel { llmOptions: this.opts.llmOptions ?? {}, system: this.resolvedSystem, askQuestion: opts.askQuestion, + pluginMounts: this.pluginMounts, maxTurns: opts.maxTurns, llmRetry: opts.llmRetry, sleep: opts.sleep, @@ -358,7 +367,13 @@ export class Kernel { // 每个派生 run 一份;一份实例被子的 loop 与子的工具执行共用 // (分别装配会让缓存/审计在 tool:before 存的状态到 tool:after 取不到)。 buildMounts: (emit) => - buildDefaultMounts({ ports: this.ports, hooks: this.hooks, ...auditCallbacks, emit }), + buildDefaultMounts({ + ports: this.ports, + hooks: this.hooks, + ...auditCallbacks, + emit, + pluginMounts: this.pluginMounts.run, + }), }); } diff --git a/packages/kernel/src/pluginLoader.ts b/packages/kernel/src/pluginLoader.ts index 9a9ffae..81efe0e 100644 --- a/packages/kernel/src/pluginLoader.ts +++ b/packages/kernel/src/pluginLoader.ts @@ -19,6 +19,7 @@ import type { PluginModule, CapabilityProvider, LLMProvider, + MountBundle, } from "@helios/ports"; import { ServiceCollection, type ServiceToken } from "./serviceCollection"; import { @@ -135,6 +136,12 @@ export interface LoadResult { capabilities: CapabilityProvider[]; llm: LiveLLMRegistry; disposables: Array<{ dispose(): void | Promise }>; + /** + * 插件自带的挂载声明,按 manifest 声明顺序。内置策略恒定排在这些之前 + * (见 `policies/index.ts`)——`context:prepare` 是 concat 语义、拼接顺序模型可见, + * 不能让第三方插到内置前面去。 + */ + mounts: MountBundle[]; } /** 某个 Port 要求的 apiVersion 与方法名。按 agent 覆写 Port 时要走同一套校验,故导出。 */ @@ -161,6 +168,7 @@ export async function loadPlugins( ): Promise { const capabilities: CapabilityProvider[] = []; const disposables: Array<{ dispose(): void | Promise }> = []; + const mounts: MountBundle[] = []; const llm = ctx.ports.llm as LiveLLMRegistry; for (const entry of manifest.plugins) { @@ -190,6 +198,17 @@ export async function loadPlugins( } else { capabilities.push(impl as CapabilityProvider); } + + // 挂载声明在 Port 注册**之后**收集:mounts() 可能要用 ctx.ports 里刚落位的东西。 + // 这里失败会连带整个插件被跳过(落进下面的 catch),这是刻意的—— + // 一个声明了挂载却挂不上的插件,装了也是错的,不如整体不装、日志说清楚。 + if (typeof mod.mounts === "function") { + const declared = await mod.mounts(impl, perEntryCtx); + mounts.push(...declared); + logger.info( + `插件 ${entry.package} 声明了 ${declared.length} 组挂载:${declared.map((b) => b.name).join(", ")}`, + ); + } logger.info(`已加载插件 ${entry.package} → ${entry.port}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -198,7 +217,7 @@ export async function loadPlugins( } } - return { capabilities, llm, disposables }; + return { capabilities, llm, disposables, mounts }; } function isDisposable(value: unknown): value is { dispose(): void | Promise } { diff --git a/packages/kernel/src/capabilities/compaction.ts b/packages/kernel/src/policies/compaction.ts similarity index 98% rename from packages/kernel/src/capabilities/compaction.ts rename to packages/kernel/src/policies/compaction.ts index 3af7018..3bfd323 100644 --- a/packages/kernel/src/capabilities/compaction.ts +++ b/packages/kernel/src/policies/compaction.ts @@ -4,7 +4,7 @@ import type { LLMOptions, LLMRegistry, Message, - MountedCapability, + MountBundle, CompactStrategyPort, CostMeterPort, TreeEdit, @@ -47,7 +47,7 @@ export interface CompactionCapabilityDeps { * 三方法接口、inline/standalone 的路由选择、缓存 TTL 启发式。收成能力之后 Session 只做 * 两件事——分发、执行返回的 TreeEdit。 */ -export function compactionCapability(deps: CompactionCapabilityDeps): MountedCapability { +export function compactionPolicy(deps: CompactionCapabilityDeps): MountBundle { let compactFailures = 0; /** 压缩自己发的请求也更新它:紧接着的主请求要据此判断缓存是否还热。 */ let lastCompactCallAt = 0; diff --git a/packages/kernel/src/capabilities/costMeter.ts b/packages/kernel/src/policies/costMeter.ts similarity index 84% rename from packages/kernel/src/capabilities/costMeter.ts rename to packages/kernel/src/policies/costMeter.ts index 6396868..202fb0f 100644 --- a/packages/kernel/src/capabilities/costMeter.ts +++ b/packages/kernel/src/policies/costMeter.ts @@ -1,7 +1,7 @@ -import type { AnyMount, CostMeterPort, MountedCapability } from "@helios/ports"; +import type { AnyMount, CostMeterPort, MountBundle } from "@helios/ports"; /** 记账:LLM 每次响应、工具每次调用各记一笔,run 收尾出报告。 */ -export function costMeterCapability(meter: CostMeterPort): MountedCapability { +export function costMeterPolicy(meter: CostMeterPort): MountBundle { const mounts: AnyMount[] = [ { at: "llm:response", run: (p) => meter.onLLMCall(p.runId, p.record) }, { diff --git a/packages/kernel/src/capabilities/derivedNotifications.ts b/packages/kernel/src/policies/derivedNotifications.ts similarity index 89% rename from packages/kernel/src/capabilities/derivedNotifications.ts rename to packages/kernel/src/policies/derivedNotifications.ts index aaa1c4c..86c33d8 100644 --- a/packages/kernel/src/capabilities/derivedNotifications.ts +++ b/packages/kernel/src/policies/derivedNotifications.ts @@ -1,4 +1,4 @@ -import type { Message, MountedCapability } from "@helios/ports"; +import type { Message, MountBundle } from "@helios/ports"; /** * 后台派生 agent 的完成通知:每轮开始前 drain 一次,作为 user 消息追加到路径尾部。 @@ -12,9 +12,9 @@ import type { Message, MountedCapability } from "@helios/ports"; * 那次把通知排在用户这句话**之前**(通知确实先于用户输入发生),而挂载点只能往 * 尾部追加——追加是缓存安全的,插到前面会让整段前缀缓存失效。 */ -export function derivedNotificationsCapability( +export function derivedNotificationsPolicy( consume: () => Message[], -): MountedCapability { +): MountBundle { return { name: "derived-notifications", mounts: [ diff --git a/packages/kernel/src/capabilities/fileAudit.ts b/packages/kernel/src/policies/fileAudit.ts similarity index 96% rename from packages/kernel/src/capabilities/fileAudit.ts rename to packages/kernel/src/policies/fileAudit.ts index 177c4a5..68f322d 100644 --- a/packages/kernel/src/capabilities/fileAudit.ts +++ b/packages/kernel/src/policies/fileAudit.ts @@ -1,4 +1,4 @@ -import type { AnyMount, FileSystemPort, MountedCapability } from "@helios/ports"; +import type { AnyMount, FileSystemPort, MountBundle } from "@helios/ports"; import type { ArtifactAction, FileEditObservation } from "../kernel"; export interface FileAuditDeps { @@ -31,7 +31,7 @@ export interface FileAuditDeps { * - 用 `exists()` 判断有无而非 catch 读失败——后者会把权限错误误判成「文件不存在」; * - 前后内容相同也照样上报——是否算「真的改了」由 recordEdit 的实现决定,不在这里预判。 */ -export function fileAuditCapability(deps: FileAuditDeps): MountedCapability { +export function fileAuditPolicy(deps: FileAuditDeps): MountBundle { const snapshots = new Map>(); const mounts: AnyMount[] = [ diff --git a/packages/kernel/src/policies/index.ts b/packages/kernel/src/policies/index.ts new file mode 100644 index 0000000..5a04637 --- /dev/null +++ b/packages/kernel/src/policies/index.ts @@ -0,0 +1,120 @@ +// 内建策略的装配根(composition root)。 +// +// **它知道全部内置接线,这是组合根的职责**,挪出去只会换个地方放(放进 Port 适配器包 +// → 换实现连带换策略;单开 bindings 包 → 依赖方向倒过来)。但这只对**时机由接口钉死** +// 的那些 Port 成立: +// +// 判据 = Port 的方法签名有没有把调用时机钉死? +// ✅ ModelRouterPort.route(payload) / CostMeterPort.onLLMCall / ToolResultCachePort.get +// ❌ MemoryPort.recall(query) —— 没说何时调、结果去哪 +// +// ⚠️ 曾经拿 `memoryRecall` 论证「适配器不该放进 Port 包」,**那是错的**: +// `session:start` + `` 标签那套是策略本身自己发明的,换成 mem0(每轮带 query +// 检索、结果作消息、turn 结束抽取)一条都表达不了。没钉死时机的 Port 与第三方 Port +// 一律自己声明挂载,走 `PluginModule.mounts()`。 +// +// 目录按**能力**组织(一文件一能力,文件名说它做什么),不按机制。迁移前叫 `mounts/` +// 并按机制分组,结果是 `fileAuditPolicy` 被塞进 `costAwareMounts.ts`—— +// 审计跟成本毫无关系,只因为它们在同一个 PR 里落地。 +import type { Message, MountBundle, MountPoint, PortRegistry } from "@helios/ports"; +import { MountRegistry } from "../agentLoop/mountRegistry"; +import type { AgentEvent } from "../events"; +import type { HookRunner } from "../hookRunner"; +import type { ArtifactAction, FileEditObservation } from "../kernel"; +import { costMeterPolicy } from "./costMeter"; +import { derivedNotificationsPolicy } from "./derivedNotifications"; +import { fileAuditPolicy } from "./fileAudit"; +import { modelRouterPolicy } from "./modelRouter"; +import { toolCachePolicy } from "./toolCache"; + +export * from "./compaction"; +export * from "./costMeter"; +export * from "./derivedNotifications"; +export * from "./fileAudit"; +export * from "./modelRouter"; +export * from "./toolCache"; + +export interface DefaultMountsOptions { + ports: Pick< + PortRegistry, + "modelRouter" | "costMeter" | "toolCache" | "versionProvider" | "fileSystem" + >; + hooks: HookRunner; + recordEdit?: (edit: FileEditObservation) => Promise; + markAuditGap?: (gap: { toolUseId?: string; reason: string; createdAt: number }) => Promise; + /** 本 run 的事件出口,供审计广播 openDiff。 */ + emit: (event: AgentEvent) => void; + /** + * drain 后台派生 agent 的完成通知。不传 = 本会话不接派生 agent, + * 该能力整个不注册(派生执行器自己跑的子 run 也不该再往里塞通知)。 + */ + consumeNotifications?: () => Message[]; + /** 插件自带的 run 级挂载(已由 {@link splitPluginMounts} 拆过)。恒定排在内置之后。 */ + pluginMounts?: readonly MountBundle[]; +} + +/** + * 装配内建能力。**每个 run 建一份**——不是为了隔离状态(缓存与审计都按 toolUseId 记, + * 什么粒度都安全),而是因为审计要往「本 run 的事件出口」广播 openDiff, + * 而那个出口本来就是 run 级的。 + * + * 同一 run 内 loop 与工具执行层必须共用这一份实例:缓存与审计都在 `tool:before` 存状态、 + * 到 `tool:after` 才取,分别装配会让两边各记各的账。 + * + * 注册顺序即执行顺序,但 `file-audit` 与 `tool-cache` 的先后**不是正确性约束**。 + * 曾经以为是(「审计排后面的话,命中那次连快照都不会拍」)——属实,可那次快照本来就用不上: + * 短路 = 工具没跑 = 文件没被改,审计的 `tool:after` 靠 payload 的 `executed` 直接判掉。 + * 护住审计的是 `executed` 这道判断,不是这里的顺序; + * `test/unit/policies/registrationOrder.test.ts` 对照跑过两种顺序,可观测行为逐字相同, + * 并钉住了「短路的调用不得产生编辑记录」这条真正的不变量。 + */ +export function buildDefaultMounts(opts: DefaultMountsOptions): MountRegistry { + const registry = new MountRegistry(); + registry.register(modelRouterPolicy(opts.ports.modelRouter)); + registry.register(costMeterPolicy(opts.ports.costMeter)); + registry.register( + fileAuditPolicy({ + fileSystem: opts.ports.fileSystem, + ...(opts.recordEdit !== undefined ? { recordEdit: opts.recordEdit } : {}), + ...(opts.markAuditGap !== undefined ? { markAuditGap: opts.markAuditGap } : {}), + emitArtifactAction: (event) => opts.emit(event as AgentEvent), + }), + ); + registry.register(toolCachePolicy(opts.ports.toolCache, opts.ports.versionProvider)); + if (opts.consumeNotifications) { + registry.register(derivedNotificationsPolicy(opts.consumeNotifications)); + } + // 插件自带的挂载**恒定排在内置之后**。`context:prepare` 是 concat 语义、拼接顺序 + // 模型可见,让第三方插到内置前面去,等于让「装了哪个插件」决定提示词长什么样。 + for (const bundle of opts.pluginMounts ?? []) registry.register(bundle); + return registry; +} + +/** + * 会话级挂载点——**每会话建一次**,与每 run 重建的 {@link buildDefaultMounts} 相对。 + * 判据不是「重不重要」,而是**状态要不要跨 run 累积**(压缩的熔断计数要,缓存的账不要)。 + */ +export const SESSION_SCOPED_MOUNTS: readonly MountPoint[] = [ + "session:start", + "session:end", + "context:compact", +]; + +/** + * 把插件交出的挂载按作用域拆成两堆。一个 bundle 里可以两种都有,拆完各进各的注册表。 + * 拆不出东西的那一半不产生空 bundle——注册表里多一个空名字只会污染排障日志。 + */ +export function splitPluginMounts(bundles: readonly MountBundle[]): { + session: MountBundle[]; + run: MountBundle[]; +} { + const session: MountBundle[] = []; + const run: MountBundle[] = []; + for (const bundle of bundles) { + const s = bundle.mounts.filter((m) => SESSION_SCOPED_MOUNTS.includes(m.at)); + const r = bundle.mounts.filter((m) => !SESSION_SCOPED_MOUNTS.includes(m.at)); + if (s.length > 0) session.push({ name: bundle.name, mounts: s }); + if (r.length > 0) run.push({ name: bundle.name, mounts: r }); + } + return { session, run }; +} diff --git a/packages/kernel/src/capabilities/modelRouter.ts b/packages/kernel/src/policies/modelRouter.ts similarity index 70% rename from packages/kernel/src/capabilities/modelRouter.ts rename to packages/kernel/src/policies/modelRouter.ts index 8697097..9542d26 100644 --- a/packages/kernel/src/capabilities/modelRouter.ts +++ b/packages/kernel/src/policies/modelRouter.ts @@ -1,4 +1,4 @@ -import type { ModelRouterPort, MountedCapability } from "@helios/ports"; +import type { ModelRouterPort, MountBundle } from "@helios/ports"; /** * 路由:每轮开始时决定用哪个模型。 @@ -6,7 +6,7 @@ import type { ModelRouterPort, MountedCapability } from "@helios/ports"; * 挂在 `turn:start` 而不是 `run:start`,因为路由信号(工具使用次数、上一轮有没有报错、 * 是否在打转)是逐轮累积的——`run:start` 那一刻还看不到它们。 */ -export function modelRouterCapability(router: ModelRouterPort): MountedCapability { +export function modelRouterPolicy(router: ModelRouterPort): MountBundle { return { name: "model-router", mounts: [{ at: "turn:start", run: (p) => router.route(p) }], diff --git a/packages/kernel/src/capabilities/toolCache.ts b/packages/kernel/src/policies/toolCache.ts similarity index 93% rename from packages/kernel/src/capabilities/toolCache.ts rename to packages/kernel/src/policies/toolCache.ts index c05e496..dd36f82 100644 --- a/packages/kernel/src/capabilities/toolCache.ts +++ b/packages/kernel/src/policies/toolCache.ts @@ -1,4 +1,4 @@ -import type { AnyMount, MountedCapability, Tool, ToolCacheKey, ToolResult, ToolResultCachePort, VersionProviderPort } from "@helios/ports"; +import type { AnyMount, MountBundle, Tool, ToolCacheKey, ToolResult, ToolResultCachePort, VersionProviderPort } from "@helios/ports"; import { stableStringify } from "../agentLoop/canonical"; /** @@ -12,10 +12,10 @@ import { stableStringify } from "../agentLoop/canonical"; * ToolCacheKey 也由本能力自己组——`tool:before` 给的是完整 payload(tool + input), * 谁需要 key 谁去组,这是「payload 按时刻定、不按消费者定」的直接后果。 */ -export function toolCacheCapability( +export function toolCachePolicy( cache: ToolResultCachePort, versions: VersionProviderPort, -): MountedCapability { +): MountBundle { /** * tool:before 组好的 key 要留到 tool:after 才用得上,按 toolUseId 暂存。 * 连 ttl 一起存,是因为 tool:after 的 payload 里 `tool` 是可选的(参数解析失败等路径没有 diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index 69148c8..0424bcf 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -7,6 +7,7 @@ import type { Logger, LLMOptions, MountBase, + MountBundle, AskQuestionRequest, AskQuestionResponse, } from "@helios/ports"; @@ -21,7 +22,7 @@ import { createProviderResolver } from "./agentLoop/providerResolver"; import type { TurnRecord, ToolExecutorEnv } from "./agentLoop/types"; import type { LlmRetryOptions } from "./agentLoop/retryBackoff"; import type { ArtifactAction, FileEditObservation } from "./kernel"; -import { buildDefaultMounts, compactionCapability, memoryRecallCapability } from "./capabilities"; +import { buildDefaultMounts, compactionPolicy } from "./policies"; import { MountRegistry } from "./agentLoop/mountRegistry"; import { createLoopExtensions } from "./loopExtensions"; import type { Tracer } from "@helios/observability-langsmith"; @@ -73,6 +74,11 @@ export interface SessionOptions { llmOptions: LLMOptions; system: string; askQuestion(req: AskQuestionRequest): Promise; + /** + * 插件经 `PluginModule.mounts()` 自带的挂载,已按作用域拆好。 + * 两堆都恒定排在内置策略之后——拼接顺序模型可见,不能由「装了哪个插件」决定。 + */ + pluginMounts?: { session: readonly MountBundle[]; run: readonly MountBundle[] }; /** 单次 run 内最大 turn 数,防失控 */ maxTurns?: number; /** LLM 调用重试策略覆盖;缺省用 DEFAULT_LLM_RETRY(issue #10)。 */ @@ -508,6 +514,7 @@ export class Session { ...(this.opts.derivedAgents ? { consumeNotifications: () => this.opts.derivedAgents!.consumeNotifications() } : {}), + ...(this.opts.pluginMounts ? { pluginMounts: this.opts.pluginMounts.run } : {}), }); // 工具执行在进 loop 之前绑好:loop 只拿到一个"给一批 tool_use、还一条 toolResult"的函数, // 不再需要认识 workDir / askQuestion / mounts。 @@ -690,9 +697,8 @@ export class Session { private sessionMounts(): MountRegistry { if (!this.sessionScopedMounts) { const registry = new MountRegistry(); - registry.register(memoryRecallCapability(this.opts.ports.memory)); registry.register( - compactionCapability({ + compactionPolicy({ compact: this.opts.ports.compact, llm: this.opts.ports.llm, costMeter: this.opts.ports.costMeter, @@ -708,6 +714,10 @@ export class Session { : {}), }), ); + // 插件自带的会话级挂载排在内置之后。记忆召回现在就走这条路—— + // 时机与 `` 标签格式属于 MemoryPort 的实现(见 `@helios/memory-fs`), + // 不属于 kernel:换成 mem0 那类实现,挂载点和格式都该跟着换。 + for (const bundle of this.opts.pluginMounts?.session ?? []) registry.register(bundle); this.sessionScopedMounts = registry; } return this.sessionScopedMounts; diff --git a/packages/kernel/test/fixtures/capAnalyticsPlugin.ts b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts new file mode 100644 index 0000000..86ed101 --- /dev/null +++ b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts @@ -0,0 +1,84 @@ +// 一个「大而全」的第三方插件:既给工具、又自己声明挂载点。 +// 这是 CapabilityProvider + PluginModule.mounts() 组合起来能做到的完整形态—— +// 第三方装它只需要在 manifest 里加一行,kernel 一行不动。 +import type { + CapabilityProvider, + KernelContext, + MountBundle, + Tool, +} from "@helios/ports"; +import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; + +/** 挂载点实际跑到时往里记,测试据此断言分发真的发生了。 */ +export const seen: string[] = []; + +export function reset(): void { + seen.length = 0; +} + +class AnalyticsProvider implements CapabilityProvider { + readonly name = "analytics"; + activate(): void {} + getTools(): Tool[] { + return [ + { + name: "describe_dataset", + description: "描述一个数据集", + inputSchema: { type: "object" }, + execute: async () => ({ output: "dataset ok" }), + }, + ]; + } +} + +export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; + +export function create(_ctx: KernelContext): CapabilityProvider { + return new AnalyticsProvider(); +} + +/** + * 同一个 bundle 里同时有会话级与 run 级挂载,验证 kernel 会按作用域拆开 + * 分别送进两个注册表(会话级那份跨 run 只建一次,run 级那份每 run 重建)。 + */ +export function mounts(_instance: CapabilityProvider, _ctx: KernelContext): MountBundle[] { + return [ + { + name: "analytics:context", + mounts: [ + { + at: "session:start", + run: async () => { + seen.push("session:start"); + return { additionalContext: "领域提示词" }; + }, + }, + { + at: "context:prepare", + run: async () => { + seen.push("context:prepare"); + return { + append: [ + { + id: `analytics-${seen.length}`, + role: "user" as const, + content: "ANALYTICS_NOTE", + ts: Date.now(), + }, + ], + }; + }, + }, + { + at: "turn:end", + run: async () => { + seen.push("turn:end"); + return undefined; + }, + }, + ], + }, + ]; +} + +export default { apiVersion, create, mounts }; diff --git a/packages/kernel/test/fixtures/probeMemory.ts b/packages/kernel/test/fixtures/probeMemory.ts index bde0c11..1a0171a 100644 --- a/packages/kernel/test/fixtures/probeMemory.ts +++ b/packages/kernel/test/fixtures/probeMemory.ts @@ -3,7 +3,7 @@ // // 三个可观测量都挂在模块级:测试用例通过 import 读它们,不必给 Port 接口加测试专用方法。 -import type { KernelContext, MemoryPort } from "@helios/ports"; +import type { KernelContext, MemoryPort, MountBundle } from "@helios/ports"; import { MEMORY_PORT_API_VERSION } from "@helios/ports"; /** 每次 create() 递增,用来断言"同参数只建了一次"。 */ @@ -24,10 +24,12 @@ export const apiVersion = MEMORY_PORT_API_VERSION; export function create(ctx: KernelContext): MemoryPort & { dispose(): void } { createCount += 1; contexts.push(ctx); - const tag = String((ctx.options as { tag?: unknown } | undefined)?.tag ?? "default"); + const opts = ctx.options as { tag?: unknown; empty?: unknown } | undefined; + const tag = String(opts?.tag ?? "default"); + const empty = opts?.empty === true; return { async recall(): Promise { - return `probe:${tag}`; + return empty ? "" : `probe:${tag}`; }, async remember(): Promise {}, dispose(): void { @@ -36,4 +38,25 @@ export function create(ctx: KernelContext): MemoryPort & { dispose(): void } { }; } -export default { apiVersion, create }; +/** + * 与 `@helios/memory-fs` 同一形状:召回时机与 `` 包裹属于 MemoryPort 的**实现**, + * 不属于 kernel。fixture 也得自己声明,否则它就不再是一个真实的 MemoryPort 插件。 + */ +export function mounts(memory: MemoryPort): MountBundle[] { + return [ + { + name: "probe-memory:recall", + mounts: [ + { + at: "session:start", + run: async (p) => { + const recalled = await memory.recall(p.firstMessage); + return recalled ? { additionalContext: `\n${recalled}\n` } : {}; + }, + }, + ], + }, + ]; +} + +export default { apiVersion, create, mounts }; diff --git a/packages/kernel/test/p1-impls.test.ts b/packages/kernel/test/p1-impls.test.ts index c212932..deaa376 100644 --- a/packages/kernel/test/p1-impls.test.ts +++ b/packages/kernel/test/p1-impls.test.ts @@ -133,8 +133,8 @@ describe("@helios/teams-mailbox", () => { describe("@helios/capability-fs", () => { it("扫描 SKILL.md 产出工具,调用返回全文", async () => { - await mkdir(join(workDir, ".helios/capabilities/demo"), { recursive: true }); - await writeFile(join(workDir, ".helios/capabilities/demo/SKILL.md"), "# Demo Skill\ncontent", "utf8"); + await mkdir(join(workDir, ".helios/policies/demo"), { recursive: true }); + await writeFile(join(workDir, ".helios/policies/demo/SKILL.md"), "# Demo Skill\ncontent", "utf8"); const ctx = ctxFor(workDir); const cap = capabilityFs.create(ctx); await cap.activate(ctx); diff --git a/packages/kernel/test/plugin-mounts.test.ts b/packages/kernel/test/plugin-mounts.test.ts new file mode 100644 index 0000000..63eb706 --- /dev/null +++ b/packages/kernel/test/plugin-mounts.test.ts @@ -0,0 +1,115 @@ +// 第三方插件自带挂载点(`PluginModule.mounts()`)。 +// +// 迁移前第三方只能供工具与 hook 订阅,**挂载点是够不到的**——要挂上去得改 kernel 的 +// 装配根,而第三方改不了核心代码。于是「能力市场」只能卖替换既有 Port 的实现, +// 卖不了新增一个挂载策略。 +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AskQuestionRequest, AskQuestionResponse, Logger, MountBundle } from "@helios/ports"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Kernel, type Manifest } from "../src/index"; +import { splitPluginMounts, SESSION_SCOPED_MOUNTS } from "../src/policies"; +import { reset, seen } from "./fixtures/capAnalyticsPlugin"; + +function fixture(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +} +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); + +/** 装了分析插件的 manifest。echo LLM 会把收到的 user 消息全文回显,用来验证真进了请求。 */ +function manifest(): Manifest { + return { + plugins: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, + { port: "CapabilityProvider", package: fixture("capAnalyticsPlugin.ts") }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + }; +} + +let workDir: string; +beforeEach(async () => { + reset(); + workDir = await mkdtemp(join(tmpdir(), "helios-plugin-mounts-")); + return async () => rm(workDir, { recursive: true, force: true }); +}); + +describe("splitPluginMounts", () => { + const bundle = (name: string, ats: MountBundle["mounts"][number]["at"][]): MountBundle => ({ + name, + mounts: ats.map((at) => ({ at, run: async () => undefined })) as MountBundle["mounts"], + }); + + it("同一个 bundle 里的两种作用域被拆进两堆,名字都保留", () => { + const { session, run } = splitPluginMounts([bundle("x", ["session:start", "turn:end"])]); + expect(session.map((b) => b.name)).toEqual(["x"]); + expect(run.map((b) => b.name)).toEqual(["x"]); + expect(session[0]!.mounts.map((m) => m.at)).toEqual(["session:start"]); + expect(run[0]!.mounts.map((m) => m.at)).toEqual(["turn:end"]); + }); + + it("拆不出东西的那一半不产生空 bundle——注册表里多一个空名字只会污染排障日志", () => { + const { session, run } = splitPluginMounts([bundle("only-run", ["tool:before"])]); + expect(session).toEqual([]); + expect(run).toHaveLength(1); + }); + + it("会话级挂载点清单与作用域判据一致:只有这三个跨 run 存活", () => { + // 判据不是「重不重要」,而是状态要不要跨 run 累积。改这份清单等于改会话级注册表的边界。 + expect([...SESSION_SCOPED_MOUNTS]).toEqual(["session:start", "session:end", "context:compact"]); + }); +}); + +describe("第三方插件自带挂载点", () => { + it("插件声明的 session:start 与 run 级挂载点都被真的分发", async () => { + const kernel = new Kernel({ workDir, manifest: manifest(), logger: silent }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk, maxTurns: 3 }); + + await session.sendMessage("干活"); + + expect(seen).toContain("session:start"); + expect(seen).toContain("context:prepare"); + expect(seen).toContain("turn:end"); + }); + + it("插件在 context:prepare 追加的消息真的进了发给 LLM 的请求", async () => { + // 只断言「在消息树上」不够:树上有而没进请求,正是这条链路迁移前的那个 bug。 + // echo provider 把收到的全部 user 消息回显进 assistant 正文,断言落在模型真看到的东西上。 + const kernel = new Kernel({ workDir, manifest: manifest(), logger: silent }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk, maxTurns: 3 }); + + await session.sendMessage("干活"); + + const echoed = session + .getHistory() + .filter((m) => m.role === "assistant") + .map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content))) + .join("\n"); + expect(echoed).toContain("SAW="); + expect(echoed).toContain("ANALYTICS_NOTE"); + }); + + it("插件在 session:start 给的 additionalContext 进了冻结的 system 前缀", async () => { + const kernel = new Kernel({ workDir, manifest: manifest(), logger: silent }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk, maxTurns: 3 }); + + await session.sendMessage("干活"); + + const prefix = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + expect(prefix).toContain("领域提示词"); + }); + + it("插件的工具与它的挂载点同时可用——一个包搞定「带自定义工具的领域插件」", async () => { + const kernel = new Kernel({ workDir, manifest: manifest(), logger: silent }); + await kernel.start(); + + expect(kernel.listTools()).toContain("analytics__describe_dataset"); + }); +}); diff --git a/packages/kernel/test/session-mounts.test.ts b/packages/kernel/test/session-mounts.test.ts index b7f2b80..d31fc4c 100644 --- a/packages/kernel/test/session-mounts.test.ts +++ b/packages/kernel/test/session-mounts.test.ts @@ -11,7 +11,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { AskQuestionRequest, AskQuestionResponse, Logger, MountedCapability } from "@helios/ports"; +import type { AskQuestionRequest, AskQuestionResponse, Logger, MountBundle } from "@helios/ports"; import { Kernel, type Manifest } from "../src/index"; function fixture(name: string): string { @@ -37,8 +37,8 @@ beforeEach(async () => { }); /** 往会话级注册表里塞一个探针能力。Session 不暴露注册口,这里从实例上取。 */ -function probeSessionMounts(session: unknown, capability: MountedCapability): void { - const s = session as { sessionMounts(): { register(c: MountedCapability): void } }; +function probeSessionMounts(session: unknown, capability: MountBundle): void { + const s = session as { sessionMounts(): { register(c: MountBundle): void } }; s.sessionMounts().register(capability); } @@ -128,7 +128,18 @@ describe("记忆召回从 Session 直调 Port 改成 session:start 能力", () = }); it("召回为空时不注入空的 标签", async () => { - const kernel = new Kernel({ workDir, manifest: manifest(), logger: silent }); + // 必须装一个**会声明挂载、但召回返回空串**的 MemoryPort。 + // 不装插件也能让断言通过,但那是空转——没挂载点自然没标签,测不到空分支。 + const kernel = new Kernel({ + workDir, + manifest: { + plugins: [ + ...manifest().plugins, + { port: "MemoryPort", package: fixture("probeMemory.ts"), options: { empty: true } }, + ], + }, + logger: silent, + }); await kernel.start(); const session = kernel.createSession({ askQuestion: noAsk }); await session.sendMessage("干活"); diff --git a/packages/kernel/test/unit/agentLoop/executeTools.mounts.test.ts b/packages/kernel/test/unit/agentLoop/executeTools.mounts.test.ts index 2a724be..3251ead 100644 --- a/packages/kernel/test/unit/agentLoop/executeTools.mounts.test.ts +++ b/packages/kernel/test/unit/agentLoop/executeTools.mounts.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { Logger, MountedCapability, Tool } from "@helios/ports"; +import type { Logger, MountBundle, Tool } from "@helios/ports"; import type { TraceRun } from "@helios/observability-langsmith"; import { createToolBatchExecutor } from "../../../src/agentLoop/executeTools"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; @@ -31,7 +31,7 @@ function block(name = "echo", input: unknown = { a: 1 }, id = "u1"): ToolUseBloc function run(opts: { tools?: Tool[]; hooks?: HookRunner; - capabilities?: MountedCapability[]; + capabilities?: MountBundle[]; blocks?: ToolUseBlock[]; parseErrorIds?: Set; }) { @@ -63,7 +63,7 @@ function run(opts: { } /** 记录 tool:after 看到的 payload,供断言。 */ -function recorder(seen: unknown[]): MountedCapability { +function recorder(seen: unknown[]): MountBundle { return { name: "recorder", mounts: [{ at: "tool:after", run: (p) => void seen.push(p) }], @@ -84,7 +84,7 @@ describe("executeTools —— tool:after 必须早于 PostToolUse", () => { }, }, ]); - const capture: MountedCapability = { + const capture: MountBundle = { name: "capture", mounts: [ { @@ -110,7 +110,7 @@ describe("executeTools —— tool:after 必须早于 PostToolUse", () => { hooks.register([ { event: "PostToolUse", handler: ({ output }) => ({ output: `${String(output)}|post` }) }, ]); - const rewrite: MountedCapability = { + const rewrite: MountBundle = { name: "rewrite", mounts: [{ at: "tool:after", run: () => ({ output: "mount" }) }], }; @@ -165,7 +165,7 @@ describe("executeTools —— tool:before 短路", () => { it("短路时工具不执行,且 tool:after 收到 shortCircuited=true / executed=false", async () => { let executed = 0; const seen: Array> = []; - const shortCircuit: MountedCapability = { + const shortCircuit: MountBundle = { name: "sc", mounts: [{ at: "tool:before", run: () => ({ shortCircuit: { output: "cached" } }) }], }; diff --git a/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts b/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts index 2ba1618..8360194 100644 --- a/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts +++ b/packages/kernel/test/unit/agentLoop/loopExtensionSurface.test.ts @@ -19,7 +19,7 @@ function importedModules(relPath: string): string[] { describe("loop 的扩展面只有闭包", () => { it("runTurnLoop 不 import 任何具体扩展机制", () => { const imports = importedModules("../../../src/agentLoop/runTurnLoop.ts"); - const leaked = imports.filter((m) => /mountRegistry|hookRunner|capabilities/i.test(m)); + const leaked = imports.filter((m) => /mountRegistry|hookRunner|policies/i.test(m)); // 合成器(loopExtensions)才认识这些;loop 只认 `LoopExtensions` 这个纯闭包接口。 expect(leaked).toEqual([]); }); diff --git a/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts b/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts index 96e6ca6..a65a0ee 100644 --- a/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts +++ b/packages/kernel/test/unit/agentLoop/runTurnLoop.provider.test.ts @@ -11,7 +11,7 @@ import type { LLMRegistry, Logger, Message, - MountedCapability, + MountBundle, StreamEvent, } from "@helios/ports"; import type { Tracer } from "@helios/observability-langsmith"; @@ -84,7 +84,7 @@ function executor(hooks: HookRunner) { async function drive(opts: { registry: LLMRegistry; options: LLMOptions; - capabilities?: MountedCapability[]; + capabilities?: MountBundle[]; warnings?: string[]; }) { const tree = createDetachedTree(); @@ -129,7 +129,7 @@ describe("runTurnLoop —— provider 解析", () => { it("turn:start 路由到另一个 provider 时,请求真的发给了它", async () => { const p1 = countingProvider("p1"); const p2 = countingProvider("p2"); - const router: MountedCapability = { + const router: MountBundle = { name: "router", mounts: [{ at: "turn:start", run: () => ({ provider: "p2" }) }], }; @@ -149,7 +149,7 @@ describe("runTurnLoop —— provider 解析", () => { // 不修回去的话,trace input 与发给 provider 的 options 里会留下一个不存在的 id。 const p1 = countingProvider("p1"); const warnings: string[] = []; - const router: MountedCapability = { + const router: MountBundle = { name: "router", mounts: [{ at: "turn:start", run: () => ({ provider: "ghost" }) }], }; @@ -172,7 +172,7 @@ describe("runTurnLoop —— provider 解析", () => { // 重解析——run:start 改写后两者恰好相等,于是永远不重解析。 const p1 = countingProvider("p1"); const p2 = countingProvider("p2"); - const runStart: MountedCapability = { + const runStart: MountBundle = { name: "run-start-router", mounts: [{ at: "run:start", run: () => ({ llmOptions: { provider: "p2", model: "m1" } }) }], }; @@ -202,7 +202,7 @@ describe("provenance 挂在消息上,不由 loop 记流水账", () => { // 同一个问题,那份账不落盘也不跟分支,且逼 loop 认识 provider 实例。 const p1 = countingProvider("p1"); const p2 = countingProvider("p2"); - const router: MountedCapability = { + const router: MountBundle = { name: "router", mounts: [{ at: "turn:start", run: () => ({ provider: "p2", model: "m2" }) }], }; @@ -263,7 +263,7 @@ describe("provenance 挂在消息上,不由 loop 记流水账", () => { }; const p2 = countingProvider("p2"); const records: Array<{ provider: string; model: string }> = []; - const recorder: MountedCapability = { + const recorder: MountBundle = { name: "recorder", mounts: [{ at: "llm:response", run: (p) => void records.push({ ...p.record }) }], }; diff --git a/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts b/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts index 28381a8..998ceca 100644 --- a/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts +++ b/packages/kernel/test/unit/agentLoop/runTurnLoop.steering.test.ts @@ -6,7 +6,7 @@ import type { Logger, Message, MountContextPreparePayload, - MountedCapability, + MountBundle, StreamEvent, Tool, } from "@helios/ports"; @@ -17,7 +17,7 @@ import { createToolBatchExecutor } from "../../../src/agentLoop/executeTools"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; import { HookRunner } from "../../../src/hookRunner"; import { createLoopExtensions } from "../../../src/loopExtensions"; -import { derivedNotificationsCapability } from "../../../src/capabilities/derivedNotifications"; +import { derivedNotificationsPolicy } from "../../../src/policies/derivedNotifications"; import { createToolView } from "../../../src/toolRegistry"; import { createDetachedTree } from "../../../src/agentSpec/detachedTree"; @@ -28,7 +28,7 @@ const silentLogger: Logger = { debug: () => {}, info: () => {}, warn: () => {}, * Stop hook 迁移成 turn:end 挂载点后,不装它 loop 就不会再问「能停了吗」。 * extras 按传入顺序注册——`context:prepare` 是 concat,注册顺序即拼接顺序。 */ -function testMounts(...extras: MountedCapability[]): MountRegistry { +function testMounts(...extras: MountBundle[]): MountRegistry { const registry = new MountRegistry(); for (const extra of extras) registry.register(extra); return registry; @@ -84,7 +84,7 @@ function scriptedProvider(id: string, texts: string[]): LLMProvider { interface LoopRun { provider: LLMProvider; tools?: Tool[]; - capability?: MountedCapability; + capability?: MountBundle; llmRegistry?: LLMRegistry; llmOptions?: LLMOptions; maxTurns?: number; @@ -158,7 +158,7 @@ describe("runTurnLoop —— steering 消息必须入树", () => { execute: testExecutor({ hooks }), }, tree, - extensions: createLoopExtensions(testMounts(derivedNotificationsCapability(() => queue.splice(0))), hooks), + extensions: createLoopExtensions(testMounts(derivedNotificationsPolicy(() => queue.splice(0))), hooks), run: { runId: "run-1", runIndex: 0, @@ -191,7 +191,7 @@ describe("runTurnLoop —— usedModels 累积", () => { const p1 = scriptedProvider("p1", ["a", "b"]); const p2 = scriptedProvider("p2", ["c"]); let turn = 0; - const router: MountedCapability = { + const router: MountBundle = { name: "test-router", mounts: [ { @@ -258,7 +258,7 @@ describe("runTurnLoop —— context:prepare", () => { name: string, text: string, seen?: MountContextPreparePayload[], - ): MountedCapability { + ): MountBundle { let n = 0; return { name, @@ -334,7 +334,7 @@ describe("runTurnLoop —— context:prepare", () => { // 这个区分是 payload 三规则推出来的:turn:start 之后模型才最终确定, // 处在它下游的挂载点必须拿得到那个决策的结果,否则会在错误的时刻读到错误的模型。 const seen: MountContextPreparePayload[] = []; - const router: MountedCapability = { + const router: MountBundle = { name: "router", mounts: [{ at: "turn:start", run: () => ({ model: "routed-model" }) }], }; diff --git a/packages/kernel/test/unit/capabilities/compaction.test.ts b/packages/kernel/test/unit/policies/compaction.test.ts similarity index 93% rename from packages/kernel/test/unit/capabilities/compaction.test.ts rename to packages/kernel/test/unit/policies/compaction.test.ts index 204f2c4..28d2996 100644 --- a/packages/kernel/test/unit/capabilities/compaction.test.ts +++ b/packages/kernel/test/unit/policies/compaction.test.ts @@ -1,4 +1,4 @@ -// compactionCapability 的单元测试。 +// compactionPolicy 的单元测试。 // // 覆盖的是 `test/compaction.test.ts`(走真实 Kernel 的集成测试)**没有**覆盖的三条: // 取消导致的 skipped、precomputed 零请求、force 绕过熔断。前两条此前完全没有断言, @@ -17,7 +17,7 @@ import type { StreamEvent, } from "@helios/ports"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; -import { compactionCapability, type CompactionCapabilityDeps } from "../../../src/capabilities/compaction"; +import { compactionPolicy, type CompactionCapabilityDeps } from "../../../src/policies/compaction"; import type { AgentEvent } from "../../../src/events"; import { createToolView } from "../../../src/toolRegistry"; @@ -75,7 +75,7 @@ function harness( ...over, }; const registry = new MountRegistry(); - registry.register(compactionCapability(deps)); + registry.register(compactionPolicy(deps)); const path: Message[] = [{ id: "m1", role: "user", content: "很长的历史" }]; return { events, @@ -98,7 +98,7 @@ function harness( const endsOf = (events: AgentEvent[]) => events.filter((e): e is Extract => e.type === "compact_end"); -describe("compactionCapability —— 取消不算失败", () => { +describe("compactionPolicy —— 取消不算失败", () => { it("用户主动取消导致的异常 → status=skipped,且不计入熔断", async () => { // 不计入熔断很重要:几次手动停止就会让熔断在并非连续失败时提前触发, // 之后即使网络恢复也不再自动压缩。此前这条路径零覆盖。 @@ -118,7 +118,7 @@ describe("compactionCapability —— 取消不算失败", () => { }); }); -describe("compactionCapability —— precomputed 是逃生舱,不是回落", () => { +describe("compactionPolicy —— precomputed 是逃生舱,不是回落", () => { it("plan.precomputed 非空 → 一个 LLM 请求都不发", async () => { const spy = provider(() => emits({ type: "text-delta", text: "不该被调用" })); const h = harness({ @@ -133,7 +133,7 @@ describe("compactionCapability —— precomputed 是逃生舱,不是回落", }); }); -describe("compactionCapability —— force 绕过熔断", () => { +describe("compactionPolicy —— force 绕过熔断", () => { it("熔断之后显式压缩仍会发请求,并把计数归零", async () => { // 熔断不能是静默死锁:用户换了模型 / 网络恢复了,得有办法让它再试。 let shouldFail = true; @@ -162,7 +162,7 @@ describe("compactionCapability —— force 绕过熔断", () => { }); }); -describe("compactionCapability —— 不碰树", () => { +describe("compactionPolicy —— 不碰树", () => { it("产出只是 TreeEdit 意图,coveredMessageIds 原样来自 plan(切点吸附是 Session 的事)", async () => { const h = harness({ compact: strategy({ plan: () => plan({ coveredMessageIds: ["m1", "m2"] }) }) }); const result = (await h.dispatch()) as { edit?: { kind: string; coveredMessageIds: string[] } }; diff --git a/packages/kernel/test/unit/capabilities/fileAudit.test.ts b/packages/kernel/test/unit/policies/fileAudit.test.ts similarity index 91% rename from packages/kernel/test/unit/capabilities/fileAudit.test.ts rename to packages/kernel/test/unit/policies/fileAudit.test.ts index 4a5f9c0..cb56fd2 100644 --- a/packages/kernel/test/unit/capabilities/fileAudit.test.ts +++ b/packages/kernel/test/unit/policies/fileAudit.test.ts @@ -1,19 +1,19 @@ import { describe, it, expect, vi } from "vitest"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; -import { fileAuditCapability } from "../../../src/capabilities/fileAudit"; +import { fileAuditPolicy } from "../../../src/policies/fileAudit"; import { tool, beforePayload, afterPayload, memFs } from "./toolPayloads"; -describe("fileAuditCapability", () => { +describe("fileAuditPolicy", () => { const writer = tool({ fileMutations: () => [{ path: "/w/a.txt", operationHint: "write" as const }] }); async function run( _fs: ReturnType, - deps: Parameters[0], + deps: Parameters[0], mutate: () => void, after: Parameters[2] = {}, ) { const r = new MountRegistry(); - r.register(fileAuditCapability(deps)); + r.register(fileAuditPolicy(deps)); await r.dispatch("tool:before", beforePayload(writer, {})); mutate(); await r.dispatch("tool:after", afterPayload(writer, {}, after)); @@ -113,7 +113,7 @@ describe("fileAuditCapability", () => { const fs = memFs(); const spy = vi.spyOn(fs.port, "exists"); const r = new MountRegistry(); - r.register(fileAuditCapability({ fileSystem: fs.port })); + r.register(fileAuditPolicy({ fileSystem: fs.port })); const plain = tool(); await r.dispatch("tool:before", beforePayload(plain, {})); await r.dispatch("tool:after", afterPayload(plain, {})); @@ -122,7 +122,7 @@ describe("fileAuditCapability", () => { it("快照按 toolUseId 隔离,且 tool:after 后不残留(否则按调用次数泄漏)", async () => { const fs = memFs(); - const cap = fileAuditCapability({ fileSystem: fs.port }); + const cap = fileAuditPolicy({ fileSystem: fs.port }); const r = new MountRegistry(); r.register(cap); await r.dispatch("tool:before", beforePayload(writer, {}, "uA")); @@ -131,7 +131,7 @@ describe("fileAuditCapability", () => { // uB 的快照还在,uA 的已清;再对 uA 发一次 after 应当是空操作而非重复上报 const edits: unknown[] = []; const r2 = new MountRegistry(); - r2.register(fileAuditCapability({ fileSystem: fs.port, recordEdit: async (e) => void edits.push(e) })); + r2.register(fileAuditPolicy({ fileSystem: fs.port, recordEdit: async (e) => void edits.push(e) })); await r2.dispatch("tool:after", afterPayload(writer, {}, { toolUseId: "uA" })); expect(edits).toHaveLength(0); }); diff --git a/packages/kernel/test/unit/capabilities/registrationOrder.test.ts b/packages/kernel/test/unit/policies/registrationOrder.test.ts similarity index 94% rename from packages/kernel/test/unit/capabilities/registrationOrder.test.ts rename to packages/kernel/test/unit/policies/registrationOrder.test.ts index 71a5c46..8434bfc 100644 --- a/packages/kernel/test/unit/capabilities/registrationOrder.test.ts +++ b/packages/kernel/test/unit/policies/registrationOrder.test.ts @@ -4,8 +4,8 @@ import type { Tool, ToolCacheKey, ToolResult, ToolResultCachePort, VersionProvid import { describe, expect, it } from "vitest"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; import type { FileEditObservation } from "../../../src/kernel"; -import { fileAuditCapability } from "../../../src/capabilities/fileAudit"; -import { toolCacheCapability } from "../../../src/capabilities/toolCache"; +import { fileAuditPolicy } from "../../../src/policies/fileAudit"; +import { toolCachePolicy } from "../../../src/policies/toolCache"; import { base, memFs } from "./toolPayloads"; /** 既 cacheable 又声明 fileMutations —— 两个能力唯一会在同一次调用上相遇的形状。 */ @@ -71,14 +71,14 @@ function build(order: "audit-first" | "cache-first", edits: FileEditObservation[ const registry = new MountRegistry(); const audit = () => registry.register( - fileAuditCapability({ + fileAuditPolicy({ fileSystem: fs.port, recordEdit: async (e) => { edits.push(e); }, }), ); - const cache = () => registry.register(toolCacheCapability(sharedCache, versions)); + const cache = () => registry.register(toolCachePolicy(sharedCache, versions)); if (order === "audit-first") { audit(); cache(); diff --git a/packages/kernel/test/unit/capabilities/toolCache.test.ts b/packages/kernel/test/unit/policies/toolCache.test.ts similarity index 87% rename from packages/kernel/test/unit/capabilities/toolCache.test.ts rename to packages/kernel/test/unit/policies/toolCache.test.ts index 6ac6702..8e66ecd 100644 --- a/packages/kernel/test/unit/capabilities/toolCache.test.ts +++ b/packages/kernel/test/unit/policies/toolCache.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import type { ToolCacheKey, ToolResult } from "@helios/ports"; import { MountRegistry } from "../../../src/agentLoop/mountRegistry"; -import { toolCacheCapability } from "../../../src/capabilities/toolCache"; +import { toolCachePolicy } from "../../../src/policies/toolCache"; import { tool, beforePayload, afterPayload } from "./toolPayloads"; /** 记下 set 进来的 key,用来断言 key 的构成而不只是「有没有缓存」。 */ @@ -23,11 +23,11 @@ function fakeCache() { }; } -describe("toolCacheCapability", () => { +describe("toolCachePolicy", () => { it("不可缓存的工具直接放行,连 key 都不组(更不会去问 VersionProvider)", async () => { const cache = fakeCache(); const versions = { get: vi.fn() }; - const cap = toolCacheCapability(cache.port, versions); + const cap = toolCachePolicy(cache.port, versions); const r = new MountRegistry(); r.register(cap); const t = tool(); // 没有 cacheable @@ -39,7 +39,7 @@ describe("toolCacheCapability", () => { it("miss → 执行后写入;同参再来 → 短路命中", async () => { const cache = fakeCache(); - const cap = toolCacheCapability(cache.port, { get: () => undefined }); + const cap = toolCachePolicy(cache.port, { get: () => undefined }); const r = new MountRegistry(); r.register(cap); const t = tool({ cacheable: true }); @@ -53,7 +53,7 @@ describe("toolCacheCapability", () => { it("key 的构成与迁移前一致:scope/scopeId/argsCanonical/version", async () => { const cache = fakeCache(); - const cap = toolCacheCapability(cache.port, { get: () => "ws-abc" }); + const cap = toolCachePolicy(cache.port, { get: () => "ws-abc" }); const r = new MountRegistry(); r.register(cap); const t = tool({ cacheable: true, cacheScope: "session", cacheVersionKind: "workspace" }); @@ -76,7 +76,7 @@ describe("toolCacheCapability", () => { ] as const) { const cache = fakeCache(); const r = new MountRegistry(); - r.register(toolCacheCapability(cache.port, { get: () => undefined })); + r.register(toolCachePolicy(cache.port, { get: () => undefined })); const t = tool({ cacheable: true, cacheScope: scope }); await r.dispatch("tool:before", beforePayload(t, {})); await r.dispatch("tool:after", afterPayload(t, {})); @@ -87,7 +87,7 @@ describe("toolCacheCapability", () => { it("出错的结果不写缓存——偶发失败不该被固化", async () => { const cache = fakeCache(); const r = new MountRegistry(); - r.register(toolCacheCapability(cache.port, { get: () => undefined })); + r.register(toolCachePolicy(cache.port, { get: () => undefined })); const t = tool({ cacheable: true }); await r.dispatch("tool:before", beforePayload(t, {})); await r.dispatch("tool:after", afterPayload(t, {}, { isError: true })); @@ -97,7 +97,7 @@ describe("toolCacheCapability", () => { it("没真执行(被短路/被拒)也不写缓存", async () => { const cache = fakeCache(); const r = new MountRegistry(); - r.register(toolCacheCapability(cache.port, { get: () => undefined })); + r.register(toolCachePolicy(cache.port, { get: () => undefined })); const t = tool({ cacheable: true }); await r.dispatch("tool:before", beforePayload(t, {})); await r.dispatch("tool:after", afterPayload(t, {}, { executed: false })); diff --git a/packages/kernel/test/unit/capabilities/toolPayloads.ts b/packages/kernel/test/unit/policies/toolPayloads.ts similarity index 100% rename from packages/kernel/test/unit/capabilities/toolPayloads.ts rename to packages/kernel/test/unit/policies/toolPayloads.ts diff --git a/packages/memory-fs/src/index.ts b/packages/memory-fs/src/index.ts index 61abd4a..06cfb1c 100644 --- a/packages/memory-fs/src/index.ts +++ b/packages/memory-fs/src/index.ts @@ -3,6 +3,7 @@ import type { MemoryEntry, KernelContext, FileSystemPort, + MountBundle, } from "@helios/ports"; import { MEMORY_PORT_API_VERSION } from "@helios/ports"; import { createGuardedFileSystem } from "@helios/fs-node"; @@ -57,4 +58,33 @@ export function create(ctx: KernelContext): MemoryPort { return new FsMemory(ctx.ports.fileSystem); } -export default { apiVersion, create }; +/** + * 召回时机与呈现格式**属于本实现,不属于 kernel**。 + * + * `MemoryPort.recall(query)` 的签名里没有任何东西说它该在什么时候调、结果去哪; + * 「会话开始召回一次 + 用 `` 标签包起来拼进冻结的 system 前缀」是 MEMORY.md + * 这一套的策略。换成 mem0 那类实现,合理做法是每轮带当前 query 检索、结果作为消息 + * 追加(`context:prepare`)、`remember` 在 `turn:end` 自动抽取——同一个 Port, + * 完全不同的挂载。所以时机由实现声明,kernel 替谁定都是错的。 + * + * **只挂 `session:start` 而不是每轮跑**是缓存纪律:system 前缀每轮变一次, + * prompt cache 就全废(实测命中率 30% → 98% 靠的就是前缀稳定)。 + */ +export function mounts(memory: MemoryPort): MountBundle[] { + return [ + { + name: "memory-fs:recall", + mounts: [ + { + at: "session:start", + run: async (p) => { + const recalled = await memory.recall(p.firstMessage); + return recalled ? { additionalContext: `\n${recalled}\n` } : {}; + }, + }, + ], + }, + ]; +} + +export default { apiVersion, create, mounts }; diff --git a/packages/ports/src/mount.ts b/packages/ports/src/mount.ts index 2d115cc..ba5ebbd 100644 --- a/packages/ports/src/mount.ts +++ b/packages/ports/src/mount.ts @@ -269,8 +269,15 @@ export interface Mount { */ export type AnyMount = { [A in MountPoint]: Mount }[MountPoint]; -/** 一份能力:若干挂载声明。名字只用于日志与排障。 */ -export interface MountedCapability { +/** + * 一组挂载声明,由某个策略打包交出。`name` 只用于日志与排障。 + * + * ⚠️ 别跟 `./capability.ts` 的 `CapabilityProvider` 混:那是**插件** + * (供工具 + hook 订阅,对标 pi 的 Extension),这是**挂载声明的载体**。 + * 曾经这个类型叫 `MountedCapability`,与前者共用"capability"一词, + * 讨论时永远要先问一句"你说的是哪个 capability"——所以改掉了。 + */ +export interface MountBundle { name: string; mounts: AnyMount[]; } diff --git a/packages/ports/src/types.ts b/packages/ports/src/types.ts index 72c36ae..04aefdb 100644 --- a/packages/ports/src/types.ts +++ b/packages/ports/src/types.ts @@ -500,4 +500,21 @@ export interface LLMRegistry { export interface PluginModule { apiVersion: number; create(ctx: KernelContext): T | Promise; + /** + * 可选:声明本插件要挂在哪些时机上。 + * + * **放在 `PluginModule` 而不是 `CapabilityProvider` 上,因为想挂载的不只是插件。** + * 判据:**Port 的方法签名有没有把调用时机钉死?** + * + * - 钉死了(`CostMeterPort.onLLMCall`、`ModelRouterPort.route(payload)`……)→ + * 适配器是机械的、全仓只需一份,留在 kernel 的装配根,实现方不必也不该重复声明。 + * - 没钉死 → 时机是**策略的一部分**,只有实现方知道。`MemoryPort.recall(query)` + * 就没说何时调、结果去哪:MEMORY.md 那种要在会话开始拼进 system 前缀; + * mem0 那种要每轮带 query 检索、结果作消息、turn 结束自动抽取。kernel 替谁定都是错的。 + * - 第三方新 Port 一律属于后者——kernel 不可能知道一个它没见过的 Port 该在什么时候跑。 + * + * `instance` 是 `create()` 刚产出的那一个,**显式传入**而不让模块自己藏在闭包里: + * 模块级变量会在同一个包被声明两次时串味。 + */ + mounts?(instance: T, ctx: KernelContext): import("./mount").MountBundle[] | Promise; } From 2ca73e29c8b626beb3f478ce0f63784d9eeda689 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 17:16:32 +0800 Subject: [PATCH 03/11] =?UTF-8?q?refactor(kernel):=20=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E7=9A=84=20run=20=E7=BB=84=E8=A3=85=E7=82=B9?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E6=B4=BE=E7=94=9F=20agent=20=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E9=9D=99=E9=BB=98=E6=BC=8F=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两轮 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。 --- docs/[IP]loop-decoupling.md | 38 +++++ docs/code-organization.md | 49 ++++++ .../src/agentSpec/derivedAgentExecutor.ts | 46 +++--- packages/kernel/src/kernel.ts | 11 +- packages/kernel/src/loopExtensions.ts | 16 +- packages/kernel/src/policies/index.ts | 58 ++++++- packages/kernel/src/runAssembly.ts | 106 +++++++++++++ packages/kernel/src/session.ts | 66 ++++---- .../test/fixtures/mockLlmDerivedTwoTurns.ts | 55 +++++++ .../test/live/code-review.eval.live.test.ts | 24 +++ .../kernel/test/live/fixtures/evalProject.ts | 77 ++++++++++ packages/kernel/test/run-assembly.test.ts | 143 ++++++++++++++++++ 12 files changed, 622 insertions(+), 67 deletions(-) create mode 100644 packages/kernel/src/runAssembly.ts create mode 100644 packages/kernel/test/fixtures/mockLlmDerivedTwoTurns.ts create mode 100644 packages/kernel/test/run-assembly.test.ts diff --git a/docs/[IP]loop-decoupling.md b/docs/[IP]loop-decoupling.md index ae37bf1..7721eab 100644 --- a/docs/[IP]loop-decoupling.md +++ b/docs/[IP]loop-decoupling.md @@ -638,6 +638,44 @@ kernel 用 `splitPluginMounts()` 按作用域拆进会话级 / run 级两个注 护栏:`test/plugin-mounts.test.ts`(7 例,含 `splitPluginMounts` 单测 + 第三方插件 工具与挂载并存的整机验证)。三处变异命中(不注册 run 级 / 不注册会话级 / 清空作用域清单)。 +### P2.12(已完成):唯一的 run 组装点 + +两轮 review(一轮查 helios 自身、一轮拆 pi)指向同一件事:**helios 里没有 harness**。 +`harness` 只出现在注释里,从未落地。真正干这活的是两段各自独立的代码—— +`Session.sendMessage()` 与 `DerivedAgentExecutor.runAgentBody()`, +各抄一遍「装 mounts → 绑工具执行器 → 合成扩展面 → 拼六组入参 → 调 loop」。 + +代价已经发生:派生 agent 漏了三个字段(`contextBudgetWarnTokens` / `retry` / `sleep`), +观测永远关闭、重试永远用默认策略,**全程零报错**。这不是设计选择,是复制粘贴漏的。 + +**修法不是新建一个 Harness 类**,是给「组装」一个名字:`runAgentTurns()` +(`kernel/src/runAssembly.ts`)。两个调用方只提供真正不同的那部分(树、run 元信息、 +工具作用域),其余统一推导——漏配从「靠人记得」变成「类型上不可能」。 + +对标 pi:它的三种模式共用 `sdk.ts::createAgentSession()`,各自只加 I/O 适配。 +反过来 pi 的 `AgentSession` 有 3342 行、正在被 `AgentHarness` 重写——所以 +「把 Session 拆成两个类」不是要学的方向,**先有唯一组装点**才是。helios 的 Session +才 1000 行,还没到那一步。 + +**同期修掉的两处假断言**(注释宣称的事实与代码不符,比没注释更有害): + +- `loopExtensions.ts` 写着「这里是 harness 侧唯一决定谁先谁后的地方」——假的, + 全仓四处各自决定。改成列出全部四处并说明为什么刻意分散。 +- `session.ts` 写着「Session 不再直接调用任何 Port」——几十行外就有 + `ports.checkpoint.restore()`。收窄成「不再自己找 CostMeterPort 要」。 + +**会话级装配也收进装配根**:新增 `buildSessionScopedMounts()` 与 `buildDefaultMounts()` +成对,`scopeChecked()` 让注册到错误作用域**当场抛错**。此前「一个内置策略该进哪套注册表」 +只存在于人的记忆里,写错了没有任何报错——只是它每 run 重建、跨 run 的状态悄悄丢了。 + +护栏:`test/run-assembly.test.ts`(7 例)。三处变异全部命中。 +⚠️ 其中一条曾经**假绿**:派生 agent 只跑一轮,而预算观测只在 `turnIndex > 0` 时检查, +所以测不到。新增 `mockLlmDerivedTwoTurns.ts` 让子 agent 也跑两轮才真的有牙。 + +eval set 24 → 27:新增**重复实现 / 配置漂移**类(`src/client/requests.ts`:复制版 +builder 漏字段、两处常量取值不同、可选回调没透传)。每段单独看都对,必须并排做字段级比对—— +正是本轮自己踩的坑。 + ### P3 为什么建议不做 **P2.7 之后这条更清楚了**:把 Stop hook 包成能力试过一轮,结果是「用户能不能拦住 agent」 diff --git a/docs/code-organization.md b/docs/code-organization.md index 93de411..9c98669 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -2,6 +2,55 @@ 只写**容易被下一个人搞错、且搞错了不会有任何报错**的几条。 +## 分层:谁是 harness + +``` +Kernel 装 Port、拆插件挂载、造 Session (进程级组装) + └ Session 拥有消息树 + 会话状态;分发会话级挂载点 (会话级 harness) + └ runAgentTurns() 唯一的 run 组装点 ← kernel/src/runAssembly.ts + └ runTurnLoop() 收消息 → 调 LLM → 分发工具 → 再来一轮 +``` + +**Session 就是 harness**(会话级那一层)。`createLoopExtensions` / `buildDefaultMounts` / +`createToolBatchExecutor` **不是层**,是组装用的零件,全部由 `runAgentTurns()` 调用。 +(曾经在架构图里把 `createLoopExtensions` 与 Session 并列画,两者根本不在一个粒度上。) + +⚠️ **`runAgentTurns` 是唯一进 loop 的路径。** 此前 `Session.sendMessage()` 与 +`DerivedAgentExecutor.runAgentBody()` 各自裸写一遍组装,结果派生 agent 漏了三个字段 +(`contextBudgetWarnTokens` / `retry` / `sleep`)——观测关闭、重试降级,**全程零报错**。 +新增第三个调用方时走这个函数,不要再抄。护栏在 `test/run-assembly.test.ts` +(禁止调用方 import `runTurnLoop` / `executeTools` / `loopExtensions`)。 + +对标 pi:它的三种运行模式(interactive / print / rpc)共用 +`sdk.ts::createAgentSession()`,各自只加 I/O 适配、不重新组装。helios 此前缺这一格。 +反过来 pi 的 `AgentSession` 有 3342 行,它自己正在用 `AgentHarness` 重写—— +所以「把 Session 拆成两个类」不是这里要学的方向,**先有唯一组装点**才是。 + +## 两个挂载点装配根,作用域由类型强制 + +| 函数 | 生命周期 | 收哪些挂载点 | +|---|---|---| +| `buildDefaultMounts()` | **每 run 一份** | `SESSION_SCOPED_MOUNTS` 之外的全部 | +| `buildSessionScopedMounts()` | **每会话一份** | `session:start` / `session:end` / `context:compact` | + +判据不是「重不重要」,而是**状态要不要跨 run 累积**(压缩的熔断计数要,缓存的账不要)。 +注册到错误的作用域会**当场抛错**——此前这条规则只存在于人的记忆里: +run 级的进 `buildDefaultMounts`、会话级的写在 Session 的私有方法里,写错了没有任何报错, +只是它每 run 重建、跨 run 的状态悄悄丢了。 + +## hook 与 mount 的先后:**四处各自决定,没有唯一权威** + +| 位置 | 顺序 | 理由 | +|---|---|---| +| `loopExtensions.ts` `composeTurnEnd` | Stop hook → `turn:end` | 先问用户的外部命令 | +| `session.ts` SessionStart | hook → `session:start` | 两者产物都要进冻结的 system 前缀 | +| `session.ts` `dispose()` | SessionEnd hook → `session:end` | 进程内收尾应看到 hook 已跑完 | +| `executeTools.ts` | PreToolUse → `tool:before` → 执行 → `tool:after` → PostToolUse | 缓存要存原始返回值 | + +抽到一处会丢掉「这条规则为什么长这样」的局部上下文,所以刻意保持分散。 +⚠️ 别再在某个文件里写「**这里是唯一决定谁先谁后的地方**」——那句话曾经写在 +`loopExtensions.ts` 里,是假的。 + ## `packages/kernel/src/policies/` —— 按能力分,不按机制分 一文件一策略,**文件名说它做什么,不说它用什么机制**: diff --git a/packages/kernel/src/agentSpec/derivedAgentExecutor.ts b/packages/kernel/src/agentSpec/derivedAgentExecutor.ts index fa72aaa..da52557 100644 --- a/packages/kernel/src/agentSpec/derivedAgentExecutor.ts +++ b/packages/kernel/src/agentSpec/derivedAgentExecutor.ts @@ -13,12 +13,10 @@ import { mkdir, readFile, writeFile, appendFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join } from "node:path"; import type { AgentDefinition, AgentSpec, Logger, Message, WorkflowSpec } from "@helios/ports"; -import { runTurnLoop } from "../agentLoop/runTurnLoop"; -import { createToolBatchExecutor } from "../agentLoop/executeTools"; import type { LlmGroup, TurnRecord, ToolExecutorEnv } from "../agentLoop/types"; import type { Tracer } from "@helios/observability-langsmith"; import type { MountRegistry } from "../agentLoop/mountRegistry"; -import { createLoopExtensions } from "../loopExtensions"; +import { runAgentTurns, llmGroup, ioGroup } from "../runAssembly"; import type { AgentEvent } from "../events"; import { uid } from "../ids"; import { @@ -225,6 +223,8 @@ export interface DerivedAgentExecutorOptions { /** 父会话 id,进 derivedFr baseLlm: Omit; /** trace 出口,沿用父会话。 */ tracer: Tracer; + /** 上下文预算观测阈值。派生 agent 曾漏传这个字段,于是它的观测永远关闭。 */ + contextBudgetWarnTokens?: number; /** * 工具执行的会话级依赖,与父会话同一份(workDir / askQuestion / hooks)。 * 每个派生 agent 配上自己的 scope——子的工具集、子的中断信号、子的 detached 树——绑成执行器。 @@ -421,28 +421,21 @@ export class DerivedAgentExecutor { // 子的能力集:loop 与子的工具执行层共用同一实例。 const mounts = this.opts.buildMounts(ctx.emit); try { - const result = await runTurnLoop({ - llm: { - ...this.opts.baseLlm, - options: agent.llmOptions, - }, - tools: { - registry: agent.tools, - // 子的工具执行器:env 沿用父会话那份,scope 全是子自己的 - // (过滤后的工具集、子的中断信号、子的 detached 树)。 - execute: createToolBatchExecutor( - { ...this.opts.toolEnv, mounts }, - { - toolRegistry: agent.tools, - signal: ctx.signal, - events: { emit: ctx.emit }, - runId: ctx.agentId, - getConversationPath: () => tree.pathToHead(), - }, - ), + const result = await runAgentTurns({ + mounts, + hooks: this.opts.toolEnv.hooks, + // env 沿用父会话那份,scope 全是子自己的 + // (过滤后的工具集、子的中断信号、子的 detached 树)。 + toolEnv: this.opts.toolEnv, + toolScope: { + toolRegistry: agent.tools, + signal: ctx.signal, + events: { emit: ctx.emit }, + runId: ctx.agentId, + getConversationPath: () => tree.pathToHead(), }, + llm: llmGroup({ ...this.opts.baseLlm, options: agent.llmOptions }), tree, - extensions: createLoopExtensions(mounts, this.opts.toolEnv.hooks), run: { runId: ctx.agentId, runIndex: 0, @@ -452,12 +445,15 @@ export class DerivedAgentExecutor { system: agent.system, leadMessages: seed, }, - io: { + io: ioGroup({ signal: ctx.signal, events: { emit: ctx.emit }, logger: this.opts.logger, tracer: this.opts.tracer, - }, + ...(this.opts.contextBudgetWarnTokens !== undefined + ? { contextBudgetWarnTokens: this.opts.contextBudgetWarnTokens } + : {}), + }), }); turnIds.push(...result.turnIds); used = result.usedModels; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index e308532..671b527 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -355,8 +355,17 @@ export class Kernel { ...(this.opts.globalInstructionDir !== undefined ? { stateDir: join(this.opts.globalInstructionDir, "derived-agents") } : {}), - baseLlm: { resolve: createProviderResolver(this.llm) }, + // 重试策略与预算观测跟主会话一致。此前这里只给了 resolve,于是派生 agent + // 永远用默认重试、观测永远关闭——不是设计选择,是两处组装各写一遍时漏的。 + baseLlm: { + resolve: createProviderResolver(this.llm), + ...(opts.llmRetry !== undefined ? { retry: opts.llmRetry } : {}), + ...(opts.sleep !== undefined ? { sleep: opts.sleep } : {}), + }, tracer: this.tracer, + ...(opts.contextBudgetWarnTokens !== undefined + ? { contextBudgetWarnTokens: opts.contextBudgetWarnTokens } + : {}), toolEnv: { workDir: this.opts.workDir, sessionId: id, diff --git a/packages/kernel/src/loopExtensions.ts b/packages/kernel/src/loopExtensions.ts index c5d4665..f584ffb 100644 --- a/packages/kernel/src/loopExtensions.ts +++ b/packages/kernel/src/loopExtensions.ts @@ -7,12 +7,26 @@ import type { HookRunner } from "./hookRunner"; const HALT_FALLBACK = "hook 要求终止本轮(continue:false)"; /** - * 把两套扩展机制合成 loop 认得的闭包集合。**这里是 harness 侧唯一决定「谁先谁后」的地方。** + * 把两套扩展机制合成 loop 认得的闭包集合。 * * 迁移前这些规则散在 `runTurnLoop` 里:loop 自己先调 `hooks.runStop`、再 `mounts.dispatch`, * 还得自己实现「急停压过续跑」。那些是**组合规则**,跟「收消息 → 调工具 → 再来一轮」 * 这件事无关,放在 loop 里意味着每加一种扩展机制都要改循环。 * + * ⚠️ **本文件不是「唯一决定 hook/mount 先后」的地方**——曾经这么写过,是假的。 + * 全仓一共四处各自决定这个先后,每处都有本地化的理由: + * + * | 位置 | 顺序 | 理由 | + * |---|---|---| + * | 本文件 `composeTurnEnd` | Stop hook → `turn:end` | 先问用户的外部命令 | + * | `session.ts` SessionStart | hook → `session:start` | 两者产物都要进冻结的 system 前缀 | + * | `session.ts` `dispose()` | SessionEnd hook → `session:end` | 进程内收尾应看到 hook 已跑完 | + * | `executeTools.ts` | PreToolUse → `tool:before` → 执行 → `tool:after` → PostToolUse | 见该文件注释 | + * + * 抽到一处会丢掉「这条规则为什么长这样」的局部上下文(`tool:after` 早于 PostToolUse + * 是因为缓存要存原始返回值,跟 turn 收尾毫无关系),所以刻意保持分散。 + * 本文件只负责**它自己那一处**。 + * * 两套机制在这里**仍然是两套**——hook 有否决权与外部进程语义(超时 / exit code), * 挂载点没有;只是它们对 loop 的呈现被统一成一个闭包。 */ diff --git a/packages/kernel/src/policies/index.ts b/packages/kernel/src/policies/index.ts index 5a04637..145e191 100644 --- a/packages/kernel/src/policies/index.ts +++ b/packages/kernel/src/policies/index.ts @@ -21,6 +21,7 @@ import { MountRegistry } from "../agentLoop/mountRegistry"; import type { AgentEvent } from "../events"; import type { HookRunner } from "../hookRunner"; import type { ArtifactAction, FileEditObservation } from "../kernel"; +import { compactionPolicy } from "./compaction"; import { costMeterPolicy } from "./costMeter"; import { derivedNotificationsPolicy } from "./derivedNotifications"; import { fileAuditPolicy } from "./fileAudit"; @@ -70,9 +71,10 @@ export interface DefaultMountsOptions { */ export function buildDefaultMounts(opts: DefaultMountsOptions): MountRegistry { const registry = new MountRegistry(); - registry.register(modelRouterPolicy(opts.ports.modelRouter)); - registry.register(costMeterPolicy(opts.ports.costMeter)); - registry.register( + const register = scopeChecked(registry, "run"); + register(modelRouterPolicy(opts.ports.modelRouter)); + register(costMeterPolicy(opts.ports.costMeter)); + register( fileAuditPolicy({ fileSystem: opts.ports.fileSystem, ...(opts.recordEdit !== undefined ? { recordEdit: opts.recordEdit } : {}), @@ -80,13 +82,13 @@ export function buildDefaultMounts(opts: DefaultMountsOptions): MountRegistry { emitArtifactAction: (event) => opts.emit(event as AgentEvent), }), ); - registry.register(toolCachePolicy(opts.ports.toolCache, opts.ports.versionProvider)); + register(toolCachePolicy(opts.ports.toolCache, opts.ports.versionProvider)); if (opts.consumeNotifications) { - registry.register(derivedNotificationsPolicy(opts.consumeNotifications)); + register(derivedNotificationsPolicy(opts.consumeNotifications)); } // 插件自带的挂载**恒定排在内置之后**。`context:prepare` 是 concat 语义、拼接顺序 // 模型可见,让第三方插到内置前面去,等于让「装了哪个插件」决定提示词长什么样。 - for (const bundle of opts.pluginMounts ?? []) registry.register(bundle); + for (const bundle of opts.pluginMounts ?? []) register(bundle); return registry; } @@ -100,6 +102,50 @@ export const SESSION_SCOPED_MOUNTS: readonly MountPoint[] = [ "context:compact", ]; +/** + * 包一层 `register`,注册到错误的作用域时**当场抛错**。 + * + * 此前「一个内置策略该进哪套注册表」只存在于人的记忆里:run 级的进 + * `buildDefaultMounts`、会话级的写在 Session 类的私有方法里,两边都不查 + * {@link SESSION_SCOPED_MOUNTS}(那张表当时只给插件分流用)。新增一个挂在 + * `session:start` 的内置策略时,作者得凭记性知道要去改 Session 而不是这里, + * 写错了没有任何报错——只是它每 run 重建,跨 run 的状态悄悄丢了。 + */ +function scopeChecked(registry: MountRegistry, scope: "run" | "session") { + return (bundle: MountBundle): void => { + for (const m of bundle.mounts) { + const isSessionScoped = SESSION_SCOPED_MOUNTS.includes(m.at); + if (isSessionScoped !== (scope === "session")) { + throw new Error( + `策略 '${bundle.name}' 的挂载点 '${m.at}' 属于${isSessionScoped ? "会话" : "run"}级,` + + `不能注册进${scope === "session" ? "会话" : "run"}级注册表`, + ); + } + } + registry.register(bundle); + }; +} + +/** 建会话级注册表所需的依赖。与 {@link DefaultMountsOptions} 分开,因为两者生命周期不同。 */ +export interface SessionScopedMountsOptions { + compaction: Parameters[0]; + /** 插件自带的会话级挂载(已由 {@link splitPluginMounts} 拆过)。恒定排在内置之后。 */ + pluginMounts?: readonly MountBundle[]; +} + +/** + * 会话级装配根。与 {@link buildDefaultMounts} 成对——**「哪个策略属于哪一套」现在只在 + * 本文件里决定**。此前会话级那半散在 `Session.sessionMounts()` 私有方法里, + * 而本文件的头注释还声称自己「知道全部内置接线」。 + */ +export function buildSessionScopedMounts(opts: SessionScopedMountsOptions): MountRegistry { + const registry = new MountRegistry(); + const register = scopeChecked(registry, "session"); + register(compactionPolicy(opts.compaction)); + for (const bundle of opts.pluginMounts ?? []) register(bundle); + return registry; +} + /** * 把插件交出的挂载按作用域拆成两堆。一个 bundle 里可以两种都有,拆完各进各的注册表。 * 拆不出东西的那一半不产生空 bundle——注册表里多一个空名字只会污染排障日志。 diff --git a/packages/kernel/src/runAssembly.ts b/packages/kernel/src/runAssembly.ts new file mode 100644 index 0000000..5493c0b --- /dev/null +++ b/packages/kernel/src/runAssembly.ts @@ -0,0 +1,106 @@ +import type { LLMOptions } from "@helios/ports"; +import { createToolBatchExecutor } from "./agentLoop/executeTools"; +import { runTurnLoop, type RunTurnLoopResult } from "./agentLoop/runTurnLoop"; +import type { + IoGroup, + LlmGroup, + RunGroup, + SessionTreeCallbacks, + ToolExecutorEnv, + ToolExecutorScope, +} from "./agentLoop/types"; +import type { MountRegistry } from "./agentLoop/mountRegistry"; +import type { HookRunner } from "./hookRunner"; +import { createLoopExtensions } from "./loopExtensions"; + +/** + * 跑一个 agent run 所需的**全部原料**。 + * + * 这个类型存在的理由,是它曾经不存在:`Session.sendMessage()` 与 + * `DerivedAgentExecutor.runAgentBody()` 各自裸写了一遍「装工具执行器 → 合成扩展面 → + * 拼六组入参 → 调 loop」,于是每加一个字段就得改两处,而漏改**不会有任何报错**。 + * 实际漏过三个:派生 agent 的 `contextBudgetWarnTokens`(上下文预算观测永远关闭)、 + * `retry` 与 `sleep`(永远用默认重试策略)。 + * + * 现在两个调用方只提供**真正不同**的那部分(树、run 元信息、工具作用域), + * 其余由 {@link runAgentTurns} 统一推导——漏配从「靠人记得」变成「类型上不可能」。 + */ +export interface AgentRunAssembly { + /** + * 本 run 的挂载注册表。**loop 与工具执行层必须共用同一个实例**: + * 缓存与审计都在 `tool:before` 存状态、到 `tool:after` 才取,分别装配会两边各记各的账。 + * 这条不变量以前靠两个调用点各自记得传同一个变量,现在由本函数保证。 + */ + mounts: MountRegistry; + hooks: HookRunner; + /** 工具执行的会话级依赖。`mounts` 由本函数补上,调用方不必也不该自己拼。 */ + toolEnv: Omit; + toolScope: ToolExecutorScope; + llm: LlmGroup; + tree: SessionTreeCallbacks; + run: RunGroup; + io: IoGroup; +} + +/** + * **唯一的 run 组装点。** Session(主会话)与 DerivedAgentExecutor(派生 agent) + * 都从这里进 loop。 + * + * 对标 pi 的 `sdk.ts::createAgentSession()`——它的三种运行模式 + * (interactive / print / rpc)共用同一个组装函数,各自只加 I/O 适配,不重新组装。 + * helios 此前没有这一格,两条路径就漂了。 + * + * 本函数**只做组装,不做决策**:谁先谁后在 {@link createLoopExtensions}, + * 挂载点装配在 `policies/index.ts`,这里只负责把它们接到一起。 + */ +export function runAgentTurns(a: AgentRunAssembly): Promise { + return runTurnLoop({ + llm: a.llm, + tools: { + registry: a.toolScope.toolRegistry, + execute: createToolBatchExecutor({ ...a.toolEnv, mounts: a.mounts }, a.toolScope), + }, + tree: a.tree, + extensions: createLoopExtensions(a.mounts, a.hooks), + run: a.run, + io: a.io, + }); +} + +/** + * 按「有才传」拼可选字段。`exactOptionalPropertyTypes` 下不能直接写 `retry: undefined`, + * 而这三个字段恰好就是上次两处漏配的那三个——集中一处拼,比在每个调用点各写一遍 + * 三元展开更难漏。 + */ +export function llmGroup(input: { + resolve: LlmGroup["resolve"]; + options: LLMOptions; + retry?: LlmGroup["retry"]; + sleep?: LlmGroup["sleep"]; +}): LlmGroup { + return { + resolve: input.resolve, + options: input.options, + ...(input.retry !== undefined ? { retry: input.retry } : {}), + ...(input.sleep !== undefined ? { sleep: input.sleep } : {}), + }; +} + +/** 同上,`contextBudgetWarnTokens` 也曾在派生那条路径上被漏掉。 */ +export function ioGroup(input: { + signal: AbortSignal; + events: IoGroup["events"]; + logger: IoGroup["logger"]; + tracer: IoGroup["tracer"]; + contextBudgetWarnTokens?: number; +}): IoGroup { + return { + signal: input.signal, + events: input.events, + logger: input.logger, + tracer: input.tracer, + ...(input.contextBudgetWarnTokens !== undefined + ? { contextBudgetWarnTokens: input.contextBudgetWarnTokens } + : {}), + }; +} diff --git a/packages/kernel/src/session.ts b/packages/kernel/src/session.ts index 0424bcf..58df209 100644 --- a/packages/kernel/src/session.ts +++ b/packages/kernel/src/session.ts @@ -16,15 +16,13 @@ import { HookRunner } from "./hookRunner"; import { uid } from "./ids"; import type { AgentEvent, AgentEventListener } from "./events"; import { snapCompactionCut, buildLlmPath } from "./messageTree"; -import { runTurnLoop } from "./agentLoop/runTurnLoop"; -import { createToolBatchExecutor } from "./agentLoop/executeTools"; import { createProviderResolver } from "./agentLoop/providerResolver"; import type { TurnRecord, ToolExecutorEnv } from "./agentLoop/types"; import type { LlmRetryOptions } from "./agentLoop/retryBackoff"; import type { ArtifactAction, FileEditObservation } from "./kernel"; -import { buildDefaultMounts, compactionPolicy } from "./policies"; +import { buildDefaultMounts, buildSessionScopedMounts } from "./policies"; import { MountRegistry } from "./agentLoop/mountRegistry"; -import { createLoopExtensions } from "./loopExtensions"; +import { runAgentTurns, llmGroup, ioGroup } from "./runAssembly"; import type { Tracer } from "@helios/observability-langsmith"; import { parseJsonLines, @@ -215,14 +213,13 @@ export class Session { * 工具执行的会话级依赖。整个会话一份,每个 run 配上自己的 scope 绑成执行器。 * 这些字段刻意不进 loop 的六组入参:它们只服务于工具执行,loop 自己一行都不用。 */ - private toolExecutorEnv(mounts: MountRegistry): ToolExecutorEnv { + private toolExecutorEnv(): Omit { return { workDir: this.opts.workDir, sessionId: this.id, logger: this.opts.logger, askQuestion: this.opts.askQuestion, hooks: this.opts.hooks, - mounts, }; } @@ -516,23 +513,25 @@ export class Session { : {}), ...(this.opts.pluginMounts ? { pluginMounts: this.opts.pluginMounts.run } : {}), }); - // 工具执行在进 loop 之前绑好:loop 只拿到一个"给一批 tool_use、还一条 toolResult"的函数, - // 不再需要认识 workDir / askQuestion / mounts。 - const executeToolBatch = createToolBatchExecutor(this.toolExecutorEnv(mounts), { - toolRegistry: this.opts.tools, - signal: abort.signal, - events: { emit }, - runId, - getConversationPath: () => this.pathToHead(), - }); - const { turnIds, runError, reachedMaxTurns, costReport, usedModels } = await runTurnLoop({ - llm: { + // 组装与调用都交给唯一的 run 组装点:工具执行器怎么绑、扩展面怎么合成, + // 主会话与派生 agent 必须一模一样——此前两处各写一遍,派生那边漏了三个字段。 + const { turnIds, runError, reachedMaxTurns, costReport, usedModels } = await runAgentTurns({ + mounts, + hooks, + toolEnv: this.toolExecutorEnv(), + toolScope: { + toolRegistry: this.opts.tools, + signal: abort.signal, + events: { emit }, + runId, + getConversationPath: () => this.pathToHead(), + }, + llm: llmGroup({ resolve: createProviderResolver(ports.llm), options: this.opts.llmOptions, ...(this.opts.llmRetry !== undefined ? { retry: this.opts.llmRetry } : {}), ...(this.opts.sleep !== undefined ? { sleep: this.opts.sleep } : {}), - }, - tools: { registry: this.opts.tools, execute: executeToolBatch }, + }), tree: { appendNode: (msg) => this.appendNode(msg), currentHeadId: () => this.headId, @@ -540,7 +539,6 @@ export class Session { snapshotCheckpoint: (turnId) => ports.checkpoint.snapshot(turnId), persistTurn: (record) => this.persistTurn(record), }, - extensions: createLoopExtensions(mounts, hooks), run: { runId, runIndex, @@ -550,7 +548,7 @@ export class Session { system, leadMessages: [...notifications, userMsg], }, - io: { + io: ioGroup({ signal: abort.signal, events: { emit }, logger, @@ -558,7 +556,7 @@ export class Session { ...(this.opts.contextBudgetWarnTokens !== undefined ? { contextBudgetWarnTokens: this.opts.contextBudgetWarnTokens } : {}), - }, + }), }); // run 收尾即"最后一次 LLM 调用"的近似时刻,供下次压缩判断前缀缓存是否还热。 @@ -591,7 +589,8 @@ export class Session { } const newMessages = this.pathToHead().slice(before); - // costReport 已由 runTurnLoop 内部对 runtimes 分发 onRunEnd 产出;Session 不再直接调用任何 Port。 + // costReport 由 loop 分发 run:end 时产出,Session 不再自己找 CostMeterPort 要。 + // (Session 仍直调 checkpoint:快照与回溯是树操作,只能由树的所有者做,见 rollback。) this.emit({ type: "agent_end", runId, @@ -696,14 +695,15 @@ export class Session { private sessionScopedMounts: MountRegistry | undefined; private sessionMounts(): MountRegistry { if (!this.sessionScopedMounts) { - const registry = new MountRegistry(); - registry.register( - compactionPolicy({ + // 装配交给 policies/ 的会话级装配根,与 run 级的 buildDefaultMounts 成对。 + // 「哪个策略属于哪一套」不再由这里凭记性决定——注册到错误作用域会当场抛错。 + this.sessionScopedMounts = buildSessionScopedMounts({ + compaction: { compact: this.opts.ports.compact, llm: this.opts.ports.llm, costMeter: this.opts.ports.costMeter, tools: this.opts.tools, - emit: (e) => this.emit(e), + emit: (e: AgentEvent) => this.emit(e), logger: this.opts.logger, ...(this.opts.compactionLlmOptions !== undefined ? { compactionLlmOptions: this.opts.compactionLlmOptions } @@ -712,13 +712,11 @@ export class Session { ...(this.opts.compactInlineMaxTokens !== undefined ? { compactInlineMaxTokens: this.opts.compactInlineMaxTokens } : {}), - }), - ); - // 插件自带的会话级挂载排在内置之后。记忆召回现在就走这条路—— - // 时机与 `` 标签格式属于 MemoryPort 的实现(见 `@helios/memory-fs`), - // 不属于 kernel:换成 mem0 那类实现,挂载点和格式都该跟着换。 - for (const bundle of this.opts.pluginMounts?.session ?? []) registry.register(bundle); - this.sessionScopedMounts = registry; + }, + // 记忆召回走这条路——时机与 `` 标签格式属于 MemoryPort 的实现 + // (见 `@helios/memory-fs`),不属于 kernel。 + ...(this.opts.pluginMounts ? { pluginMounts: this.opts.pluginMounts.session } : {}), + }); } return this.sessionScopedMounts; } diff --git a/packages/kernel/test/fixtures/mockLlmDerivedTwoTurns.ts b/packages/kernel/test/fixtures/mockLlmDerivedTwoTurns.ts new file mode 100644 index 0000000..a51388d --- /dev/null +++ b/packages/kernel/test/fixtures/mockLlmDerivedTwoTurns.ts @@ -0,0 +1,55 @@ +import type { LLMProvider, StreamEvent, Message, KernelContext } from "@helios/ports"; +import { LLM_PROVIDER_API_VERSION } from "@helios/ports"; + +/** + * 与 `mockLlmCallsAgent` 同构,但**派生 agent 自己也跑两轮**。 + * + * 为什么需要它:上下文预算观测只在 `turnIndex > 0` 时检查(第一轮的历史预算属于 + * run-start compact 的覆盖范围)。单轮的派生 agent 永远触发不到,用它测「派生 agent + * 的预算观测有没有被接上」会得到一个假绿。 + */ +const provider: LLMProvider = { + id: "mock", + async *streamMessage(messages: Message[], _tools, opts): AsyncGenerator { + const isDerived = (opts.system ?? "").includes("delegated agent"); + const hasToolResult = messages.some((m) => m.role === "toolResult"); + if (isDerived) { + if (!hasToolResult) { + // 故意调一个不存在的工具:拿不到结果也会产生 toolResult 并推进到第二轮, + // 这正是要覆盖的时机,不必为此给子 agent 真的配一个工具。 + yield { type: "tool-call-start", id: "d1", name: "no_such_tool" }; + yield { type: "tool-call-delta", id: "d1", argsDelta: "{}" }; + yield { type: "tool-call-end", id: "d1" }; + yield { type: "message-stop", stopReason: "tool_use" }; + return; + } + yield { type: "text-delta", text: "子 agent 的结论" }; + yield { type: "message-stop", stopReason: "end_turn" }; + return; + } + if (!hasToolResult) { + yield { type: "tool-call-start", id: "t1", name: "Agent" }; + yield { + type: "tool-call-delta", + id: "t1", + argsDelta: JSON.stringify({ + agent_type: "reader", + description: "派个子 agent", + prompt: "干点活", + run_in_background: true, + }), + }; + yield { type: "tool-call-end", id: "t1" }; + yield { type: "message-stop", stopReason: "tool_use" }; + return; + } + yield { type: "text-delta", text: "父收尾" }; + yield { type: "message-stop", stopReason: "end_turn" }; + }, +}; + +export const apiVersion = LLM_PROVIDER_API_VERSION; +export function create(_ctx: KernelContext): LLMProvider { + return provider; +} +export default { apiVersion, create }; diff --git a/packages/kernel/test/live/code-review.eval.live.test.ts b/packages/kernel/test/live/code-review.eval.live.test.ts index 5d03b7b..09c1f56 100644 --- a/packages/kernel/test/live/code-review.eval.live.test.ts +++ b/packages/kernel/test/live/code-review.eval.live.test.ts @@ -34,6 +34,8 @@ const CROSS_FILE_DEFECTS = ["transition-bypass", "duplicated-terminal-set", "sha const NUMERIC_DEFECTS = ["inclusive-end-window", "float-money-round", "utc-days-between"]; /** 第七类:判据不在代码里,在 README 的架构约定里。 */ const LAYERING_DEFECTS = ["core-imports-adapter", "core-does-io", "rule-in-adapter"]; +/** 第八类:每段单独看都对,错在两段本该一致的代码各自演化后不一致了。 */ +const DUPLICATION_DEFECTS = ["duplicated-request-builder", "drifted-default", "silent-optional-drop"]; const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); @@ -281,6 +283,28 @@ gate(`代码审查 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { 600_000, ); + it( + "重复实现/配置漂移专项:每段单独看都对,错在两段本该一致的代码不一致了", + async () => { + // 第八种阅读方式。前七类都能靠「这段代码本身不对」抓到;这一类每段都自洽, + // 必须把两个长得像的函数**并排做字段级比对**。「看起来差不多」正是它藏身的地方, + // 也是本仓库自己踩过的坑(两处组装各写一遍,漏了三个字段且完全静默)。 + const session = kernel.createSession({ askQuestion: noAsk }); + const out = await session.sendMessage( + [ + "只看 src/client/requests.ts。文件头 JSDoc 声明每个请求字段只在一处决定、", + "首发与重试走同一套构造逻辑。逐字段比对两个 build* 函数的产出差异,", + "并检查常量是否只有一个来源、可选参数有没有被完整透传。", + "逐条给出函数名和原因。只读,不要改任何文件。", + ].join(""), + ); + const found = detectedDefects(reviewText(out)); + report("duplication-review", found); + expect(found.filter((id) => DUPLICATION_DEFECTS.includes(id)).length).toBeGreaterThanOrEqual(1); + }, + 600_000, + ); + it( "派生 agent + fork_context:子带着父的上下文开工,且不产生悬空 tool_use", async () => { diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index bf98b6f..722aa44 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -203,6 +203,29 @@ export const DEFECTS: Defect[] = [ summary: "免运费阈值这条业务规则被写在 adapters/httpClient.ts 里,README 声明业务规则一律属于 core", }, + // --- 重复实现 / 配置漂移类。前二十四个都能靠「这段代码本身不对」抓到; + // 这一类的每一段**单独看都完全正确**,错在两段本该一致的代码各自演化后不一致了。 + // 必须把两个函数并排做字段级比对才看得出来——「看起来差不多」正是它藏身的地方。 + { + id: "duplicated-request-builder", + file: "src/client/requests.ts", + signals: [["buildretryrequest"]], + summary: + "buildRetryRequest() 是 buildRequest() 的复制版,漏了 timeoutMs 与 traceId 两个字段,重试请求因此没有超时保护也追踪不到", + }, + { + id: "drifted-default", + file: "src/client/requests.ts", + signals: [["default_timeout_ms"], ["retry_timeout_ms"], ["超时", "常量"]], + summary: "两处各自定义了默认超时常量且取值不同(3000 / 5000),文件头 JSDoc 声明只应有一个来源", + }, + { + id: "silent-optional-drop", + file: "src/client/requests.ts", + signals: [["sendwithretry"], ["onretry"]], + summary: + "sendWithRetry() 没有把可选回调 onRetry 往下透传,调用方注册的重试观测永远不会被触发,且不会报错", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -570,6 +593,60 @@ export async function postQuote(q: Quote): Promise { body: JSON.stringify(body), }); } +`, + "src/client/requests.ts": `/** + * 出站请求构造。 + * + * 约定:**每个请求字段只在一处决定**——超时、追踪 id、鉴权头的默认值都只应有一个来源, + * 首发与重试走同一套构造逻辑,避免两条路径各自演化。 + */ + +export const DEFAULT_TIMEOUT_MS = 3000; +const RETRY_TIMEOUT_MS = 5000; + +export interface Request { + url: string; + headers: Record; + timeoutMs?: number; + traceId?: string; +} + +export function buildRequest(url: string, token: string, traceId: string): Request { + return { + url, + headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" }, + timeoutMs: DEFAULT_TIMEOUT_MS, + traceId, + }; +} + +export function buildRetryRequest(url: string, token: string, _traceId: string): Request { + return { + url, + headers: { authorization: \`Bearer \${token}\`, "content-type": "application/json" }, + }; +} + +export interface SendOptions { + /** 每次重试前回调,供调用方观测。 */ + onRetry?: (attempt: number) => void; +} + +async function send(req: Request): Promise { + return fetch(req.url, { headers: req.headers }); +} + +/** 发送并在失败时重试一次。 */ +export async function sendWithRetry( + url: string, + token: string, + traceId: string, + options: SendOptions = {}, +): Promise { + const first = await send(buildRequest(url, token, traceId)); + if (first.ok) return first; + return send({ ...buildRetryRequest(url, token, traceId), timeoutMs: RETRY_TIMEOUT_MS }); +} `, "test/pricing.test.ts": `import { describe, it, expect } from "vitest"; import { subtotal, total } from "../src/pricing"; diff --git a/packages/kernel/test/run-assembly.test.ts b/packages/kernel/test/run-assembly.test.ts new file mode 100644 index 0000000..4e487bd --- /dev/null +++ b/packages/kernel/test/run-assembly.test.ts @@ -0,0 +1,143 @@ +// run 组装的护栏。 +// +// 本文件盯的是**「只有一个组装点」**这条不变量。它坏掉不会有任何行为症状—— +// 谁再拷一份 `runTurnLoop({...})` 出来,测试全绿、agent 照跑,只是下一个字段又会漏。 +// 实际漏过三个(派生 agent 的 contextBudgetWarnTokens / retry / sleep), +// 全都是「静默失效」:观测关闭、重试降级,没有任何报错。 +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { AskQuestionRequest, AskQuestionResponse, Logger, MountBundle } from "@helios/ports"; +import { beforeEach, describe, expect, it } from "vitest"; +import { Kernel } from "../src/index"; +import { buildSessionScopedMounts } from "../src/policies"; + +function fixture(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +} +function src(rel: string): string { + return readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8"); +} +function importsOf(rel: string): string[] { + return [...src(rel).matchAll(/^\s*import\s[^;]*?from\s+["']([^"']+)["']/gm)].map((m) => m[1]!); +} + +const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); + +describe("只有一个 run 组装点", () => { + it.each([ + ["session.ts", "session.ts"], + ["agentSpec/derivedAgentExecutor.ts", "agentSpec/derivedAgentExecutor.ts"], + ])("%s 不直接 import runTurnLoop / createToolBatchExecutor / createLoopExtensions", (_n, rel) => { + // 这三样是组装的零件。调用方碰到任何一个,就说明它在自己拼装而不是走 runAssembly—— + // 也就是「两条路径又分头长」的开始。 + const leaked = importsOf(rel).filter((m) => + /runTurnLoop|executeTools|loopExtensions/.test(m), + ); + expect(leaked).toEqual([]); + }); + + it("两条路径都调 runAgentTurns", () => { + expect(src("session.ts")).toMatch(/await runAgentTurns\(/); + expect(src("agentSpec/derivedAgentExecutor.ts")).toMatch(/await runAgentTurns\(/); + }); + + it("runAssembly 是唯一 import runTurnLoop 的地方(loop 自身与测试除外)", () => { + // 换句话说:新增第三个调用方时,它必须走组装点,而不是再抄一遍。 + const files = ["session.ts", "agentSpec/derivedAgentExecutor.ts", "kernel.ts"]; + for (const f of files) { + expect(importsOf(f).some((m) => m.includes("runTurnLoop"))).toBe(false); + } + expect(importsOf("runAssembly.ts").some((m) => m.includes("runTurnLoop"))).toBe(true); + }); +}); + +describe("挂载点作用域校验", () => { + const bundle = (at: MountBundle["mounts"][number]["at"]): MountBundle => ({ + name: "probe", + mounts: [{ at, run: async () => undefined }] as MountBundle["mounts"], + }); + const compaction = {} as Parameters[0]["compaction"]; + + it("把 run 级挂载注册进会话级注册表会当场抛错", () => { + // 此前这种错误完全无声:它会被塞进每 run 重建的注册表,跨 run 的状态悄悄丢掉。 + expect(() => + buildSessionScopedMounts({ compaction, pluginMounts: [bundle("turn:end")] }), + ).toThrow(/turn:end.*属于run级/); + }); + + it("会话级挂载注册进会话级注册表不报错", () => { + expect(() => + buildSessionScopedMounts({ compaction, pluginMounts: [bundle("session:start")] }), + ).not.toThrow(); + }); +}); + +describe("派生 agent 与主会话拿到同一套配置", () => { + let workDir: string; + let specDir: string; + const booted: Kernel[] = []; + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "helios-run-assembly-")); + specDir = join(workDir, "specs"); + await mkdir(specDir, { recursive: true }); + await writeFile( + join(specDir, "reader-aaaa0001.md"), + ["---", "id: aaaa0001", "name: reader", "description: 只读", "---", "看一眼就好。", ""].join("\n"), + ); + return async () => { + // 后台派生 agent 会活过用例本身,边跑边写 derived-agents/,与 rm 抢同一棵目录树。 + await Promise.all(booted.map((k) => k.dispose().catch(() => {}))); + await rm(workDir, { recursive: true, force: true }); + }; + }); + + it("contextBudgetWarnTokens 在派生 agent 的 run 里也生效", async () => { + // 迁移前这个字段只在主会话那条路径上被传下去,派生 agent 的观测永远关闭—— + // 不是设计选择,是两处组装各写一遍时漏的。阈值设 1 保证必然触发。 + const warns: string[] = []; + const logger: Logger = { + debug() {}, + info() {}, + warn: (m: string) => void warns.push(m), + error() {}, + }; + const kernel = new Kernel({ + workDir, + logger, + agentSpecDir: specDir, + globalInstructionDir: join(workDir, "global"), + manifest: { + plugins: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmDerivedTwoTurns.ts") }, + ], + }, + }); + await kernel.start(); + booted.push(kernel); + const session = kernel.createSession({ + askQuestion: noAsk, + maxTurns: 4, + contextBudgetWarnTokens: 1, + }); + const executor = ( + kernel as unknown as { + derivedAgents: Map< + string, + { list(): Array<{ agentId: string }>; waitFor(id: string, ms: number): Promise } + >; + } + ).derivedAgents.get(session.id)!; + + await session.sendMessage("派个 agent 去干活"); + const agentId = executor.list().at(-1)!.agentId; + await executor.waitFor(agentId, 5000); + + // 派生 run 的 turnId 前缀是 agentId;主会话的是 `-`。 + const derived = warns.filter((w) => w.includes("message path 估算值") && w.includes(agentId)); + expect(derived.length).toBeGreaterThan(0); + }); +}); From ce872f2f001d373b5ce7c593b98a19a7832ea2c2 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 21:57:49 +0800 Subject: [PATCH 04/11] =?UTF-8?q?refactor(kernel):=20manifest=20=E6=8B=86?= =?UTF-8?q?=20ports=20/=20extensions=20=E4=B8=A4=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E8=A1=A5=20requires=20=E6=8B=93=E6=89=91=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 判据只有一条:kernel 调不调你的方法 10 个 PortName 里 9 个 kernel 会调(含 LLMProvider——调它的 streamMessage, 多实例不改变调用方向),只有 CapabilityProvider 不调、只从它那里收工具。 但这两类此前共用一个 `plugins` 数组和同一个 PortName 联合,于是 `"port": "CapabilityProvider"` 这行字面自相矛盾,要看出差别得去翻 PORT_META 的 kind 字段——而 kind 还不够用:LLMProvider 与 CapabilityProvider 都是 "multi", 语义却完全不同。kind 这个字段的存在本身就是"这里塞了两类东西"的证据。 拆成两段后 extensions 条目不再有 port 字段(kernel 不调它,无从声明是哪个 Port), PortName 联合与 PORT_META 只剩真·Port 语义。 ## requires:修的是静默失效,不是崩溃 八个有 no-op 兜底的 Port(memory/checkpoint/compact/router/costMeter/ toolCache/versionProvider/multiAgent)在缺席时返回 NoopXxx。所以 manifest 顺序 写反的后果不是报错,是功能悄悄没了、零异常零日志——此前这条规则只写在 helios.config.json 的一行注释里。 新增 PortEntry.requires + 装配前 Kahn 拓扑排序,自依赖与成环在加载任何插件之前 抛错。排序稳定:不写 requires 时输出逐条等于输入(否则会悄悄改变 context:prepare 的插件挂载顺序,而那是 concat 语义、拼接顺序模型可见)。 extension 一律在全部 Port 之后加载,所以它总能看到完整的 PortRegistry, 不需要 requires——这也顺带把"插件间顺序"缩小到只剩 Port 之间。 ## 测试 test/unit/pluginLoader.requires.test.ts(9 例):排序稳定性 / 提前被依赖项 / 自依赖 / 成环 / 依赖不在 manifest 里只 warn,加一组端到端正反对照—— MemoryPort 写在 FileSystemPort 前面时记忆静默失效,声明 requires 后正常召回。 端到端用真 @helios/memory-fs 而非 probeMemory fixture:只有前者在 create(ctx) 里 读 ctx.ports.fileSystem,才复现得出这条病症。 四处变异全部命中:不排序 / 去掉成环检测 / 自依赖不抛 / ready 队列反向取。 eval set 27 → 30:新增配置 / schema 语义漂移类(src/config/pluginConfig.ts: enabled 默认值与 schema 相反、schema 写 retries 实现读 retryCount、sources 数组 同时装数据源与导出器)。前 27 个缺陷都在代码里,这一类代码全都能跑、结果也看着对, 错在配置字段的含义与自己的 schema 文档对不上——写配置的人以为生效了,实际静默 走了默认值或反向语义。正是本轮诊断出的那类病。 ## codemod 自己踩了同一个坑 批量改名把 apps/{web,electron}/vite.config.ts 的 `plugins: [react()]` 也改成了 `ports:`,而 typecheck 与 899 个测试全绿——Vite 配置不在任何一个 tsconfig 项目里, CI 也不跑 build,所以坏掉的配置会静默出厂。已回退,并逐文件核对了 diff 里全部 36 处 `ports: [` 都确实处于 Manifest 上下文。 这与上一轮 3a8b90d 把 .helios/capabilities/ 全局改名成 .helios/policies/ (一个用户可见路径,与 kernel/src/policies/ 毫无关系)是同一类错误。 typecheck 退出码 0;899 passed(+9)。 真机 eval:readonly 21/30,七个专项各 3/3,写路径与派生 agent 全绿。 --- apps/cli/src/index.ts | 14 +- apps/cli/test/cli.e2e.test.ts | 10 +- apps/electron/electron/main.ts | 10 +- apps/web/server/host.ts | 10 +- docs/code-organization.md | 45 ++++- helios.config.json | 14 +- packages/host/src/electronIpc.test.ts | 14 +- packages/host/src/index.test.ts | 18 +- packages/host/src/workspaceHost.test.ts | 2 +- packages/kernel/src/agentSpec/portResolver.ts | 5 +- packages/kernel/src/index.ts | 11 +- packages/kernel/src/pluginLoader.ts | 175 +++++++++++++----- packages/kernel/test/agent-loop-fixes.test.ts | 34 ++-- packages/kernel/test/agent-tools.test.ts | 7 +- packages/kernel/test/branch-tree.test.ts | 8 +- packages/kernel/test/cancel.test.ts | 6 +- packages/kernel/test/compaction.test.ts | 4 +- packages/kernel/test/cost-runtime.test.ts | 29 +-- .../kernel/test/derived-agent-merge.test.ts | 4 +- .../test/derived-notification-midrun.test.ts | 6 +- .../kernel/test/hook-universal-halt.test.ts | 18 +- packages/kernel/test/kernel.test.ts | 54 +++--- packages/kernel/test/lifecycle-order.test.ts | 6 +- .../kernel/test/list-sessions-ports.test.ts | 2 +- .../test/live/code-review.eval.live.test.ts | 5 +- .../test/live/derived-agent.live.test.ts | 5 +- .../kernel/test/live/fixtures/evalProject.ts | 92 +++++++++ .../test/live/write-audit.eval.live.test.ts | 5 +- packages/kernel/test/llm-downgrade.test.ts | 6 +- packages/kernel/test/p1-pluggability.test.ts | 6 +- packages/kernel/test/p2-rollback.test.ts | 6 +- packages/kernel/test/plugin-dispose.test.ts | 10 +- packages/kernel/test/plugin-mounts.test.ts | 8 +- packages/kernel/test/port-overrides.test.ts | 2 +- packages/kernel/test/prompt.test.ts | 2 +- packages/kernel/test/resume.test.ts | 2 +- packages/kernel/test/run-assembly.test.ts | 2 +- packages/kernel/test/session-mounts.test.ts | 14 +- packages/kernel/test/thinking.test.ts | 2 +- .../test/tool-default-permission.test.ts | 6 +- .../test/unit/pluginLoader.requires.test.ts | 147 +++++++++++++++ packages/kernel/test/wiring.test.ts | 6 +- packages/protocol/src/ws.e2e.test.ts | 6 +- .../workspace/src/runtimeRegistry.test.ts | 4 +- packages/workspace/src/runtimeRegistry.ts | 3 +- packages/workspace/src/workspace.e2e.test.ts | 2 +- 46 files changed, 637 insertions(+), 210 deletions(-) create mode 100644 packages/kernel/test/unit/pluginLoader.requires.test.ts diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index e69bda6..e5294f7 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -17,7 +17,7 @@ import { createTuiLogger } from "./tui/tuiLogger"; import { openCliWorkspace, type CliWorkspaceRuntime } from "./workspaceRuntime"; const DEFAULT_MANIFEST: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: "@helios/llm-openai", options: {} }, ], @@ -33,17 +33,19 @@ async function readManifest(workDir: string): Promise { } function resolveManifest(manifest: Manifest, workDir: string): Manifest { + const resolvePkg = (entry: T): T => ({ + ...entry, + package: resolvePluginPackage(entry.package, workDir), + }); return { - plugins: manifest.plugins.map((entry) => ({ - ...entry, - package: resolvePluginPackage(entry.package, workDir), - })), + ports: manifest.ports.map(resolvePkg), + ...(manifest.extensions ? { extensions: manifest.extensions.map(resolvePkg) } : {}), }; } /** `/model` reports configuration only; runtime routing stays a manifest/Kernel concern. */ function describeManifestModel(manifest: Manifest): ModelDescription | undefined { - const entry = manifest.plugins.find((plugin) => plugin.port === "LLMProvider"); + const entry = manifest.ports.find((plugin) => plugin.port === "LLMProvider"); if (!entry) return undefined; const options = (entry.options ?? {}) as { model?: string; baseURL?: string }; return { provider: entry.package, model: options.model, baseURL: options.baseURL }; diff --git a/apps/cli/test/cli.e2e.test.ts b/apps/cli/test/cli.e2e.test.ts index 016739b..c293f9f 100644 --- a/apps/cli/test/cli.e2e.test.ts +++ b/apps/cli/test/cli.e2e.test.ts @@ -20,15 +20,14 @@ const ENDPOINT = process.env.HELIOS_LLM_BASE_URL ?? "http://127.0.0.1:8788"; const MODEL = process.env.HELIOS_LLM_MODEL ?? "Claude-4.8-opus"; const require = createRequire(import.meta.url); const MOCK_MANIFEST: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: require.resolve("@helios/fs-node") }, { port: "LLMProvider", package: fileURLToPath( new URL("../../../packages/kernel/test/fixtures/mockLlmTextOnly.ts", import.meta.url), ), - }, - ], + }], }; function runCli( @@ -56,14 +55,13 @@ let workDir: string; beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), "helios-e2e-")); const manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: "@helios/llm-anthropic", options: { baseURL: ENDPOINT, apiKey: "local", model: MODEL }, - }, - ], + }], }; await writeFile(join(workDir, "helios.config.json"), JSON.stringify(manifest, null, 2), "utf8"); return async () => rm(workDir, { recursive: true, force: true }); diff --git a/apps/electron/electron/main.ts b/apps/electron/electron/main.ts index c94f5c3..8c7c585 100644 --- a/apps/electron/electron/main.ts +++ b/apps/electron/electron/main.ts @@ -36,11 +36,13 @@ const CODE_MODE = process.env.HELIOS_CODE_MODE === "1"; async function loadManifest(): Promise { const raw = await readFile(CONFIG_PATH, "utf8"); const manifest = JSON.parse(raw) as Manifest; + const resolvePkg = (entry: T): T => ({ + ...entry, + package: import.meta.resolve(entry.package), + }); return { - plugins: manifest.plugins.map((entry) => ({ - ...entry, - package: import.meta.resolve(entry.package), - })), + ports: manifest.ports.map(resolvePkg), + ...(manifest.extensions ? { extensions: manifest.extensions.map(resolvePkg) } : {}), }; } diff --git a/apps/web/server/host.ts b/apps/web/server/host.ts index 037e282..e1b3be6 100644 --- a/apps/web/server/host.ts +++ b/apps/web/server/host.ts @@ -22,11 +22,13 @@ const CONFIG_PATH = fileURLToPath(new URL("../../../helios.config.json", import. async function loadManifest(): Promise { const raw = await readFile(CONFIG_PATH, "utf8"); const manifest = JSON.parse(raw) as Manifest; + const resolvePkg = (entry: T): T => ({ + ...entry, + package: import.meta.resolve(entry.package), + }); return { - plugins: manifest.plugins.map((entry) => ({ - ...entry, - package: import.meta.resolve(entry.package), - })), + ports: manifest.ports.map(resolvePkg), + ...(manifest.extensions ? { extensions: manifest.extensions.map(resolvePkg) } : {}), }; } diff --git a/docs/code-organization.md b/docs/code-organization.md index 9c98669..e930106 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -77,12 +77,41 @@ mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后 ### 四个概念别混 -| | 是什么 | 谁提供 | -|---|---|---| -| **Port** | 原料 | manifest 里的插件包 | -| **mount** | 时机插槽(`turn:start` / `tool:after` / …) | `ports/src/mount.ts` 的封闭联合 | -| **policy** | 挂在时机上的**内置**策略 | 本目录 | -| **`CapabilityProvider`** | **插件**:供工具 + 订阅 hook(对标 pi 的 Extension) | 第三方 / `cap-*` 包 | +判据只有一条:**kernel 调不调你的方法。** + +| | 契约在哪 | 实现是谁 | kernel 调它吗 | +|---|---|---|---| +| **Port** | `ports/src/.ts` | adapter 包 | **调**。`LLMProvider` 也在此列——kernel 调它的 `streamMessage`,多实例不改变调用方向 | +| **`CapabilityProvider`**(extension) | `ports/src/capability.ts` | 第三方 / `cap-*` 包 | **不调**。只从它那里收工具(对标 pi 的 Extension) | +| **mount** | `ports/src/mount.ts` 的封闭联合 | policy(内置)+ 插件自带 `mounts()` | 分发它 | +| **policy** | —— | 本目录 | 挂在 mount 上的**内置**策略 | + +mount 之于 policy,正如 Port 之于 adapter:**前者是类型,后者是它的一个实现**, +不是包含关系。`PluginModule` 也不是第五个概念,是**壳**——上面两类的包都长 +`{ apiVersion, create, mounts? }` 这个形状。 + +### manifest 两段:`ports` / `extensions` + +```jsonc +{ + "ports": [{ "port": "MemoryPort", "package": "…", "requires": ["FileSystemPort"] }], + "extensions": [{ "package": "@helios/capability-fs" }] +} +``` + +`extensions` 段**没有 `port` 字段**,因为 kernel 不调它,无从声明是哪个 Port。 +两者曾共用一个 `plugins` 数组和同一个 `PortName` 联合,于是 +`"port": "CapabilityProvider"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 +才看得出差别——**`kind` 这个字段的存在本身就是"这里塞了两类东西"的证据**。 + +⚠️ **`requires` 解决的是静默失效,不是崩溃。** 八个有 no-op 兜底的 Port +(memory/checkpoint/compact/router/costMeter/toolCache/versionProvider/multiAgent) +在缺席时返回 `NoopXxx`,所以顺序写反的后果是**功能悄悄没了,零异常零日志**。 +排序稳定:不写 `requires` 时输出逐条等于输入。护栏见 +`test/unit/pluginLoader.requires.test.ts`(含"写反顺序 → 记忆静默失效"的正反对照)。 + +extension 一律在**全部 Port 之后**加载,所以它总能看到完整的 `PortRegistry`, +不需要 `requires`。 ⚠️ **payload 里永远不会有 `ports`。** 策略要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 @@ -91,8 +120,8 @@ mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后 任何插件包(不限于 `CapabilityProvider`)都能在模块上导出 `mounts(instance, ctx)` 交出 `MountBundle[]`,kernel 按作用域拆进两个注册表。所以一个「带自定义工具的领域插件」 -一个包就够:`getTools()` 给工具、`getHookHandlers()` 订阅 hook、`mounts()` 挂时机, -manifest 加一行即可,**核心代码一行不动**。 +一个包就够:`getTools()` 给工具、`mounts()` 挂时机,manifest 加一行即可, +**核心代码一行不动**。 ⚠️ **插件挂载恒定排在内置之后。** `context:prepare` 是 concat 语义、拼接顺序模型可见, 让第三方插到内置前面去,等于让「装了哪个插件」决定提示词长什么样。 diff --git a/helios.config.json b/helios.config.json index 7dfdc19..c9299e0 100644 --- a/helios.config.json +++ b/helios.config.json @@ -1,12 +1,15 @@ { - "//": "manifest 声明顺序即加载顺序(串行)。被依赖的基础 Port(FileSystemPort)必须写在前面。", - "plugins": [ + "//": "ports = kernel 主动调其方法的实现;extensions = kernel 只从它那里收工具的插件。加载顺序按 requires 拓扑排序,未声明 requires 的沿用书写顺序。", + "ports": [ { "port": "FileSystemPort", "package": "@helios/fs-node" }, - { "port": "MemoryPort", "package": "@helios/memory-fs" }, + { + "port": "MemoryPort", + "package": "@helios/memory-fs", + "requires": ["FileSystemPort"] + }, { "port": "CheckpointPort", "package": "@helios/checkpoint-fs" }, { "port": "CompactStrategyPort", "package": "@helios/compact-default" }, { "port": "MultiAgentPort", "package": "@helios/teams-mailbox" }, - { "port": "CapabilityProvider", "package": "@helios/capability-fs" }, { "port": "CostMeterPort", "package": "@helios/costmeter-default", @@ -32,5 +35,6 @@ "model": "kimi-k3" } } - ] + ], + "extensions": [{ "package": "@helios/capability-fs" }] } diff --git a/packages/host/src/electronIpc.test.ts b/packages/host/src/electronIpc.test.ts index c0b2ec3..d1fd9b4 100644 --- a/packages/host/src/electronIpc.test.ts +++ b/packages/host/src/electronIpc.test.ts @@ -93,11 +93,13 @@ afterEach(async () => { describe("@helios/host serveKernelOverElectronIpc —— 与 serveKernelOverWs 同构的连接受理循环", () => { it("connect → sessionId → sendMessage 驱动 run → 事件流 + history(不经 WebSocket)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -137,11 +139,13 @@ describe("@helios/host serveKernelOverElectronIpc —— 与 serveKernelOverWs it("同一 bridge 上两条 connectionId 互不串扰(多路复用)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -229,7 +233,7 @@ describe("@helios/host serveWorkspaceHostOverElectronIpc", () => { sessions, materializer: new LocalWorkspaceMaterializer({ paths }), manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], diff --git a/packages/host/src/index.test.ts b/packages/host/src/index.test.ts index d79ffef..b34ea6a 100644 --- a/packages/host/src/index.test.ts +++ b/packages/host/src/index.test.ts @@ -30,11 +30,13 @@ const cleanups: Array<() => void> = []; beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), "helios-host-")); const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -111,11 +113,13 @@ describe("@helios/host serveKernelOverWs —— 客户端驱动真实 Kernel Ses describe("@helios/host serveKernelOverWs —— 工具渲染描述符(Tool.describe 接线)", () => { it("工具实现了 describe 时,tool_execution_end 广播事件带上服务端算好的 descriptor", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapabilityWithDescribe.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapabilityWithDescribe.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -143,11 +147,13 @@ describe("@helios/host serveKernelOverWs —— 连接关闭触发 SessionEnd", it("客户端断开连接后,绑定的 Session.dispose() 被调用,SessionEnd handler 收到通知", async () => { hookCalls.length = 0; const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("hookCaptureCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("hookCaptureCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); diff --git a/packages/host/src/workspaceHost.test.ts b/packages/host/src/workspaceHost.test.ts index ca174da..00e192d 100644 --- a/packages/host/src/workspaceHost.test.ts +++ b/packages/host/src/workspaceHost.test.ts @@ -45,7 +45,7 @@ describe("serveWorkspaceHostOverWs", () => { sessions, materializer: new LocalWorkspaceMaterializer({ paths }), manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], diff --git a/packages/kernel/src/agentSpec/portResolver.ts b/packages/kernel/src/agentSpec/portResolver.ts index a46e00a..7e7395d 100644 --- a/packages/kernel/src/agentSpec/portResolver.ts +++ b/packages/kernel/src/agentSpec/portResolver.ts @@ -89,7 +89,7 @@ export class AgentPortResolver { if (override === undefined) continue; const entry = this.findEntry(override.module); if (entry === undefined) { - const allowed = this.opts.manifest.plugins.map((p) => p.package).join(", ") || "(无)"; + const allowed = this.opts.manifest.ports.map((p) => p.package).join(", ") || "(无)"; errors.push( `ports.${port}.module '${override.module}' 未在 manifest 中声明,不予加载。已声明的模块:${allowed}`, ); @@ -161,8 +161,9 @@ export class AgentPortResolver { } as KernelContext; } + /** 只在 `ports` 段里找:agent 覆写的对象一律是 Port,extension 不能被当作 Port 实例化。 */ private findEntry(module: string) { - return this.opts.manifest.plugins.find((p) => p.package === module); + return this.opts.manifest.ports.find((p) => p.package === module); } } diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 00d1e1c..f2f8d7c 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -31,8 +31,15 @@ export { export { ToolRegistry, createToolView } from "./toolRegistry"; export type { ToolLookup } from "./toolRegistry"; export { HookRunner } from "./hookRunner"; -export { loadPlugins } from "./pluginLoader"; -export type { Manifest, PluginEntry, PortName, LoadResult, PackageResolver } from "./pluginLoader"; +export { loadPlugins, sortPortEntries } from "./pluginLoader"; +export type { + Manifest, + PortEntry, + ExtensionEntry, + PortName, + LoadResult, + PackageResolver, +} from "./pluginLoader"; export { LiveLLMRegistry, createLivePortRegistry } from "./portRegistry"; export { NoopMemory, diff --git a/packages/kernel/src/pluginLoader.ts b/packages/kernel/src/pluginLoader.ts index 81efe0e..36dbaee 100644 --- a/packages/kernel/src/pluginLoader.ts +++ b/packages/kernel/src/pluginLoader.ts @@ -35,6 +35,15 @@ import { } from "./tokens"; import { LiveLLMRegistry } from "./portRegistry"; +/** + * kernel **主动调用**其方法的那一类实现。判据就是这一句:kernel 调不调你。 + * `LLMProvider` 在列是因为 kernel 调它的 `streamMessage`——多实例不改变调用方向。 + * + * 反过来 extension(原 `CapabilityProvider`)不在这里:kernel 从不调它的业务方法, + * 只从它那里收工具。两者曾共用本联合与 manifest 的 `port` 字段,于是 + * `"port": "CapabilityProvider"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 + * 才能看出差别——`kind` 这个字段的存在本身就是"这里塞了两类东西"的证据。 + */ export type PortName = | "FileSystemPort" | "MemoryPort" @@ -42,20 +51,39 @@ export type PortName = | "CompactStrategyPort" | "CheckpointPort" | "LLMProvider" - | "CapabilityProvider" | "ModelRouterPort" | "CostMeterPort" | "ToolResultCachePort" | "VersionProviderPort"; -export interface PluginEntry { +export interface PortEntry { port: PortName; package: string; options?: Record; + /** + * 本条在 `create(ctx)` 时要从 `ctx.ports` 取用的 Port。装配前据此拓扑排序。 + * + * 不写 = 沿用声明顺序。之所以需要它:`ctx.ports` 只含**已注册**的 Port,而八个有 + * no-op 兜底的 Port(memory/checkpoint/compact/router/costMeter/toolCache/ + * versionProvider/multiAgent)在缺席时返回 `NoopXxx` 而非报错——顺序写反的后果是 + * 功能悄悄失效,零日志零异常。 + */ + requires?: PortName[]; +} + +/** extension(原 `CapabilityProvider`):kernel 不调它,只从它那里收工具。 */ +export interface ExtensionEntry { + package: string; + options?: Record; } export interface Manifest { - plugins: PluginEntry[]; + ports: PortEntry[]; + /** + * 一律在**全部 Port 注册完之后**加载,所以每个 extension 都能看到完整的 PortRegistry。 + * 这也是它不需要 `requires` 的原因:唯一可能的依赖对象已经全部就位。 + */ + extensions?: ExtensionEntry[]; } interface PortMeta { @@ -101,11 +129,6 @@ const PORT_META: Record = { requiredMethods: ["streamMessage"], kind: "multi", }, - CapabilityProvider: { - apiVersion: CAPABILITY_PROVIDER_API_VERSION, - requiredMethods: ["activate"], - kind: "multi", - }, ModelRouterPort: { apiVersion: MODEL_ROUTER_PORT_API_VERSION, requiredMethods: ["route"], @@ -132,6 +155,11 @@ const PORT_META: Record = { }, }; +const EXTENSION_META = { + apiVersion: CAPABILITY_PROVIDER_API_VERSION, + requiredMethods: ["activate"], +}; + export interface LoadResult { capabilities: CapabilityProvider[]; llm: LiveLLMRegistry; @@ -152,13 +180,61 @@ export function portMeta(port: PortName): { apiVersion: number; requiredMethods: export { importPlugin, assertApiVersionCompatible, validateShape, isDisposable }; -/** - * 按 manifest 声明顺序串行加载。后加载的插件在 create(ctx) 时通过 ctx.ports - * 拿到前面已注册的 Port。基础 Port(尤其 FileSystemPort)应写在 manifest 前面。 - */ /** 宿主 app 提供的裸包解析器(如 `(spec) => import.meta.resolve(spec)`),锚定到 app 自己的依赖。 */ export type PackageResolver = (spec: string) => string; +/** + * 按 `requires` 拓扑排序后串行加载 Port,再按声明顺序加载 extension。 + * + * 排序稳定:同时可加载时取声明靠前的那条,所以**没有任何 `requires` 时输出逐条等于输入** + * (旧行为不变)。`sortPortEntries` 的护栏见 test/unit/pluginLoader.requires.test.ts。 + */ +export function sortPortEntries(entries: PortEntry[], logger: Logger): PortEntry[] { + const providersOf = new Map(); + entries.forEach((e, i) => { + const list = providersOf.get(e.port); + if (list) list.push(i); + else providersOf.set(e.port, [i]); + }); + + const adjacency: number[][] = entries.map(() => []); + const indegree = entries.map(() => 0); + entries.forEach((entry, i) => { + for (const dep of entry.requires ?? []) { + if (dep === entry.port) { + throw new Error(`manifest 依赖成环:${entry.package} 的 requires 指向自身 Port '${dep}'`); + } + const providers = providersOf.get(dep); + if (!providers) { + // 依赖的 Port 没在 manifest 里:不报错——它会落到 no-op 兜底,是合法的降级配置。 + logger.warn(`${entry.package} 声明依赖 '${dep}',但 manifest 未提供,将使用 no-op 兜底`); + continue; + } + for (const j of providers) { + adjacency[j]!.push(i); + indegree[i]! += 1; + } + } + }); + + const ready = entries.map((_, i) => i).filter((i) => indegree[i] === 0); + const sorted: PortEntry[] = []; + while (ready.length > 0) { + ready.sort((a, b) => a - b); + const i = ready.shift()!; + sorted.push(entries[i]!); + for (const next of adjacency[i]!) { + indegree[next]! -= 1; + if (indegree[next] === 0) ready.push(next); + } + } + if (sorted.length !== entries.length) { + const stuck = entries.filter((_, i) => indegree[i]! > 0).map((e) => e.package); + throw new Error(`manifest 依赖成环,无法确定加载顺序:${stuck.join(" → ")}`); + } + return sorted; +} + export async function loadPlugins( manifest: Manifest, services: ServiceCollection, @@ -171,35 +247,25 @@ export async function loadPlugins( const mounts: MountBundle[] = []; const llm = ctx.ports.llm as LiveLLMRegistry; - for (const entry of manifest.plugins) { - const meta = PORT_META[entry.port]; - if (!meta) { - logger.error(`未知 Port 类型:${entry.port}(package=${entry.package}),跳过`); - continue; - } + // 一条 manifest 条目的完整加载:import → 校验 → create → 落位 → 收挂载。 + // 单条失败只跳过它自己;必须实现的 Port 缺失在装配收尾统一中止。 + const loadEntry = async ( + label: string, + entry: { package: string; options?: Record }, + meta: { apiVersion: number; requiredMethods: string[] }, + place: (impl: unknown) => void, + ): Promise => { try { const mod = await importPlugin(entry.package, ctx.workDir, resolvePackage); - assertApiVersionCompatible(entry.port, mod.apiVersion, meta.apiVersion); + assertApiVersionCompatible(label, mod.apiVersion, meta.apiVersion); const perEntryCtx: KernelContext = { ...ctx, options: entry.options }; const impl = await mod.create(perEntryCtx); - validateShape(entry.port, impl, meta.requiredMethods); + validateShape(label, impl, meta.requiredMethods); if (isDisposable(impl)) disposables.push(impl); + place(impl); - if (meta.kind === "single") { - if (services.has(meta.token!)) { - throw new Error( - `单实例 Port '${entry.port}' 已有实现,禁止重复声明(package=${entry.package})`, - ); - } - services.set(meta.token!, impl); - } else if (entry.port === "LLMProvider") { - llm.add(impl as LLMProvider); - } else { - capabilities.push(impl as CapabilityProvider); - } - - // 挂载声明在 Port 注册**之后**收集:mounts() 可能要用 ctx.ports 里刚落位的东西。 + // 挂载声明在落位**之后**收集:mounts() 可能要用 ctx.ports 里刚落位的东西。 // 这里失败会连带整个插件被跳过(落进下面的 catch),这是刻意的—— // 一个声明了挂载却挂不上的插件,装了也是错的,不如整体不装、日志说清楚。 if (typeof mod.mounts === "function") { @@ -209,12 +275,37 @@ export async function loadPlugins( `插件 ${entry.package} 声明了 ${declared.length} 组挂载:${declared.map((b) => b.name).join(", ")}`, ); } - logger.info(`已加载插件 ${entry.package} → ${entry.port}`); + logger.info(`已加载插件 ${entry.package} → ${label}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - // 单个插件失败:记录清晰错误并跳过;必须实现的 Port 缺失在装配收尾统一中止。 - logger.error(`加载插件失败 ${entry.package} → ${entry.port}:${msg}`); + logger.error(`加载插件失败 ${entry.package} → ${label}:${msg}`); } + }; + + for (const entry of sortPortEntries(manifest.ports, logger)) { + const meta = PORT_META[entry.port]; + if (!meta) { + logger.error(`未知 Port 类型:${entry.port}(package=${entry.package}),跳过`); + continue; + } + await loadEntry(entry.port, entry, meta, (impl) => { + if (meta.kind === "single") { + if (services.has(meta.token!)) { + throw new Error( + `单实例 Port '${entry.port}' 已有实现,禁止重复声明(package=${entry.package})`, + ); + } + services.set(meta.token!, impl); + } else { + llm.add(impl as LLMProvider); + } + }); + } + + for (const entry of manifest.extensions ?? []) { + await loadEntry("extension", entry, EXTENSION_META, (impl) => { + capabilities.push(impl as CapabilityProvider); + }); } return { capabilities, llm, disposables, mounts }; @@ -254,26 +345,26 @@ async function importPlugin( } function assertApiVersionCompatible( - port: PortName, + label: string, provided: number, required: number, ): void { // apiVersion 是纯整数即 major version;只有相等才兼容。 if (provided !== required) { throw new Error( - `port ${port} 需要 apiVersion ${required},插件提供 ${provided}(major 不符,拒绝加载)`, + `${label} 需要 apiVersion ${required},插件提供 ${provided}(major 不符,拒绝加载)`, ); } } -function validateShape(port: PortName, impl: unknown, requiredMethods: string[]): void { +function validateShape(label: string, impl: unknown, requiredMethods: string[]): void { if (impl === null || typeof impl !== "object") { - throw new Error(`port ${port} 的实现不是对象`); + throw new Error(`${label} 的实现不是对象`); } const obj = impl as Record; for (const m of requiredMethods) { if (typeof obj[m] !== "function") { - throw new Error(`port ${port} 的实现缺少方法:${m}()`); + throw new Error(`${label} 的实现缺少方法:${m}()`); } } } diff --git a/packages/kernel/test/agent-loop-fixes.test.ts b/packages/kernel/test/agent-loop-fixes.test.ts index 6c98145..a09726b 100644 --- a/packages/kernel/test/agent-loop-fixes.test.ts +++ b/packages/kernel/test/agent-loop-fixes.test.ts @@ -61,7 +61,7 @@ beforeEach(async () => { async function bootSession( llmFixture: string, - extraPlugins: Manifest["plugins"] = [], + extraExtensions: NonNullable = [], maxTurns?: number, sessionOpts: { llmRetry?: LlmRetryOptions; @@ -73,11 +73,11 @@ async function bootSession( } = {}, ) { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - ...extraPlugins, { port: "LLMProvider", package: fixture(llmFixture) }, ], + extensions: extraExtensions, }; const { askQuestion, logger, ...rest } = sessionOpts; const kernel = new Kernel({ workDir, manifest, logger: logger ?? silentLogger, tracer: sessionOpts.tracer } as never); @@ -129,7 +129,7 @@ describe("LangSmith trace hierarchy", () => { const tracer = new RecordingTracer(); const { session } = await bootSession( "mockLlmParallel.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapabilityParallel.ts") }], + [{ package: fixture("mockCapabilityParallel.ts") }], undefined, { tracer }, ); @@ -180,7 +180,7 @@ describe("Bug 3 —— LLM 流错误优雅收尾(不 throw 穿透)", () => { describe("Bug 4 —— tool_use 参数 JSON 解析失败回传错误而非静默 {}", () => { it("非法参数不执行工具,回传 isError 的 tool_result 让 LLM 重试", async () => { const { session, events } = await bootSession("mockLlmBadArgs.ts", [ - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, + { package: fixture("mockCapability.ts") }, ]); await session.sendMessage("go"); @@ -200,7 +200,7 @@ describe("Bug 5 —— 达到 turn 上限优雅结束并标注", () => { it("永不结束的工具循环撞上 maxTurns:agent_end.reachedMaxTurns=true", async () => { const { session, events } = await bootSession( "mockLlmLoop.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapability.ts") }], + [{ package: fixture("mockCapability.ts") }], 3, ); @@ -221,7 +221,7 @@ describe("工具执行 —— 默认串行,声明 executionMode:'parallel' 才 it("两个都声明 parallel 的工具确实并发执行(执行区间重叠),结果仍按模型给出顺序组装", async () => { parallelCallLog.length = 0; const { session, events } = await bootSession("mockLlmParallel.ts", [ - { port: "CapabilityProvider", package: fixture("mockCapabilityParallel.ts") }, + { package: fixture("mockCapabilityParallel.ts") }, ]); await session.sendMessage("go"); @@ -250,7 +250,7 @@ describe("工具执行 —— 默认串行,声明 executionMode:'parallel' 才 describe("输出截断(stopReason: max_tokens)—— 工具调用整批判失败,不执行", () => { it("参数碰巧是合法 JSON 也不执行,回传截断错误让 LLM 重试", async () => { const { session, events } = await bootSession("mockLlmMaxTokens.ts", [ - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, + { package: fixture("mockCapability.ts") }, ]); await session.sendMessage("go"); @@ -376,7 +376,7 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool it("PreToolUse deny:start/end 各恰好一次、toolUseId 一致、start.input 是模型原始请求参数", async () => { preToolUseBehavior.preToolUse = () => ({ decision: "deny", reason: "禁止" }); const { session, events } = await bootSession("mockLlmWithTool.ts", [ - { port: "CapabilityProvider", package: fixture("mockCapabilityPreToolUse.ts") }, + { package: fixture("mockCapabilityPreToolUse.ts") }, ]); await session.sendMessage("go"); @@ -395,7 +395,7 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool const rejectAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["拒绝"] }); const { session, events } = await bootSession( "mockLlmWithTool.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapabilityPreToolUse.ts") }], + [{ package: fixture("mockCapabilityPreToolUse.ts") }], undefined, { askQuestion: rejectAsk }, ); @@ -414,7 +414,7 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool it("正常路径回归:PreToolUse 改写 input 后,start.input 仍是改写后的值(不是模型原始请求)", async () => { preToolUseBehavior.preToolUse = () => ({ decision: "allow", input: { text: "rewritten" } }); const { session, events } = await bootSession("mockLlmWithTool.ts", [ - { port: "CapabilityProvider", package: fixture("mockCapabilityPreToolUse.ts") }, + { package: fixture("mockCapabilityPreToolUse.ts") }, ]); await session.sendMessage("go"); @@ -446,7 +446,7 @@ describe("Fix 3(可观测性)—— 上下文预算 warning:不改变压 const { logger, warnCalls } = recordingLogger(); const { session } = await bootSession( "mockLlmLoop.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapability.ts") }], + [{ package: fixture("mockCapability.ts") }], 5, { logger }, ); @@ -460,7 +460,7 @@ describe("Fix 3(可观测性)—— 上下文预算 warning:不改变压 const { logger, warnCalls } = recordingLogger(); const { session, events } = await bootSession( "mockLlmLoop.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapability.ts") }], + [{ package: fixture("mockCapability.ts") }], 5, { logger, contextBudgetWarnTokens: 1 }, ); @@ -482,7 +482,7 @@ describe("Fix 3(可观测性)—— 上下文预算 warning:不改变压 const { logger: withThresholdLogger } = recordingLogger(); const { session: withThreshold, events: eventsWithThreshold } = await bootSession( "mockLlmLoop.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapability.ts") }], + [{ package: fixture("mockCapability.ts") }], 5, { logger: withThresholdLogger, contextBudgetWarnTokens: 1 }, ); @@ -493,11 +493,13 @@ describe("Fix 3(可观测性)—— 上下文预算 warning:不改变压 const secondWorkDir = await mkdtemp(join(tmpdir(), "helios-loopfix-")); try { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmLoop.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const { logger: withoutThresholdLogger } = recordingLogger(); const kernel = new Kernel({ workDir: secondWorkDir, manifest, logger: withoutThresholdLogger }); diff --git a/packages/kernel/test/agent-tools.test.ts b/packages/kernel/test/agent-tools.test.ts index 715ab59..b943489 100644 --- a/packages/kernel/test/agent-tools.test.ts +++ b/packages/kernel/test/agent-tools.test.ts @@ -22,7 +22,7 @@ const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -455,7 +455,7 @@ describe("走完整 turn 循环时的树形", () => { globalInstructionDir: globalDir, agentSpecDir: specDir, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmCallsAgent.ts") }, ], @@ -823,7 +823,8 @@ describe("AgentSpecSave —— workflow", () => { const { tools, sessionId } = await boot(); const ghost = await tools.get("AgentSpecSave")!.execute( - { name: "bad", description: "d", nodes: [{ agent: "ghost" }], generated_from: "x" }, + { name: "bad", description: "d", nodes: [{ agent: "ghost" }, + ], generated_from: "x" }, toolCtx(sessionId), ); expect(ghost.isError).toBe(true); diff --git a/packages/kernel/test/branch-tree.test.ts b/packages/kernel/test/branch-tree.test.ts index c774b2f..e8cf96d 100644 --- a/packages/kernel/test/branch-tree.test.ts +++ b/packages/kernel/test/branch-tree.test.ts @@ -17,7 +17,7 @@ function textOf(m: Message): string { } const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -103,7 +103,7 @@ describe("compact-on-tree —— 部分覆盖不丢近端上下文", () => { const kernel = new Kernel({ workDir, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("mockCompactPartial.ts") }, { port: "LLMProvider", package: fixture("mockLlmCounter.ts") }, @@ -140,7 +140,7 @@ describe("compact-on-tree —— Q3:压缩不误伤共享 tail 节点的兄弟 const kernel = new Kernel({ workDir, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("mockCompactOnceAll.ts") }, { port: "LLMProvider", package: fixture("mockLlmCounter.ts") }, @@ -188,7 +188,7 @@ describe("compact-on-tree —— Q3:压缩不误伤共享 tail 节点的兄弟 describe("compact-on-tree —— 压缩记录跨 resume 持久化", () => { it("resume 后仍是压缩视图(summary 在、被覆盖内容不在),而非全量历史", async () => { const buildManifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("mockCompactPartial.ts") }, { port: "LLMProvider", package: fixture("mockLlmCounter.ts") }, diff --git a/packages/kernel/test/cancel.test.ts b/packages/kernel/test/cancel.test.ts index d25c528..c997b06 100644 --- a/packages/kernel/test/cancel.test.ts +++ b/packages/kernel/test/cancel.test.ts @@ -21,10 +21,12 @@ beforeEach(async () => { describe("Session.cancel() 中断当前 run", () => { it("cancel 后 run 迅速收敛,远不到 maxTurns,且不抛异常", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmCancelLoop.ts") }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, + ], + extensions: [ + { package: fixture("mockCapability.ts") }, ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); diff --git a/packages/kernel/test/compaction.test.ts b/packages/kernel/test/compaction.test.ts index 7a12147..e83ae51 100644 --- a/packages/kernel/test/compaction.test.ts +++ b/packages/kernel/test/compaction.test.ts @@ -26,7 +26,7 @@ const compactEnds = (events: AgentEvent[]): CompactEnd[] => /** 装 mockCompactViaLlm(无 precomputed → kernel 必须真发请求)+ 能区分压缩请求的假 provider。 */ function manifestViaLlm(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("mockCompactViaLlm.ts") }, { port: "LLMProvider", package: fixture("mockLlmCompactAware.ts") }, @@ -62,7 +62,7 @@ describe("kernel 发起压缩调用", () => { it("压缩开销计入本 run 的成本报告(kernel 侧上报,Port 不再自报)", async () => { const manifest = manifestViaLlm(); - manifest.plugins.push({ port: "CostMeterPort", package: "@helios/costmeter-default" }); + manifest.ports.push({ port: "CostMeterPort", package: "@helios/costmeter-default" }); const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); const session = kernel.createSession({ askQuestion: noAsk }); diff --git a/packages/kernel/test/cost-runtime.test.ts b/packages/kernel/test/cost-runtime.test.ts index 2ddeadc..22e626e 100644 --- a/packages/kernel/test/cost-runtime.test.ts +++ b/packages/kernel/test/cost-runtime.test.ts @@ -30,7 +30,7 @@ beforeEach(async () => { describe("ModelRouter 接入:按 tier 改写 model", () => { it("短输入无工具 → tier0,provider 收到映射表的 tier0 model", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "ModelRouterPort", @@ -50,7 +50,7 @@ describe("ModelRouter 接入:按 tier 改写 model", () => { it("未装 ModelRouter(noop)→ 不改写 model(回显 none)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmEchoModel.ts") }, ], @@ -66,7 +66,7 @@ describe("ModelRouter 接入:按 tier 改写 model", () => { describe("CostMeter 接入:agent_end 携带成本报告", () => { it("装 costmeter-default → 报告累计 usage 与 outcome", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CostMeterPort", package: "@helios/costmeter-default" }, { port: "LLMProvider", package: fixture("mockLlmEchoModel.ts") }, @@ -87,7 +87,7 @@ describe("CostMeter 接入:agent_end 携带成本报告", () => { it("未装 CostMeter(noop)→ 报告为全零(可插拔回归)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmEchoModel.ts") }, ], @@ -107,13 +107,15 @@ describe("CostMeter 接入:agent_end 携带成本报告", () => { describe("ToolResultCache 接入:同 session 同参第二个 run 命中缓存", () => { it("run1 执行工具、run2 命中缓存不再执行;三指标正确", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CostMeterPort", package: "@helios/costmeter-default" }, { port: "ToolResultCachePort", package: "@helios/toolcache-mem" }, - { port: "CapabilityProvider", package: fixture("capCacheProbe.ts") }, { port: "LLMProvider", package: fixture("mockLlmCacheProbe.ts") }, ], + extensions: [ + { package: fixture("capCacheProbe.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -145,11 +147,13 @@ describe("ToolResultCache 接入:同 session 同参第二个 run 命中缓存" it("未装 ToolResultCache(noop)→ 每个 run 都执行(count 递增)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("capCacheProbe.ts") }, { port: "LLMProvider", package: fixture("mockLlmCacheProbe.ts") }, ], + extensions: [ + { package: fixture("capCacheProbe.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); @@ -178,13 +182,15 @@ describe("ToolResultCache 接入:同 session 同参第二个 run 命中缓存" */ describe("VersionProvider 接入:版本变化让缓存自然失效", () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "ToolResultCachePort", package: "@helios/toolcache-mem" }, { port: "VersionProviderPort", package: fixture("versionProviderFlip.ts") }, - { port: "CapabilityProvider", package: fixture("capVersionedProbe.ts") }, { port: "LLMProvider", package: fixture("mockLlmVersionedProbe.ts") }, ], + extensions: [ + { package: fixture("capVersionedProbe.ts") }, + ], }; /** 取本 run 里工具的输出(count=N),用它判断工具到底执行没执行。 */ @@ -224,7 +230,8 @@ describe("VersionProvider 接入:版本变化让缓存自然失效", () => { it("未装 VersionProvider(noop)→ version 恒 undefined,仍按 scope 命中缓存", async () => { // 反向对照:证明上一条测到的确实是"版本"这个变量,而不是缓存本身时灵时不灵。 const noVersion: Manifest = { - plugins: manifest.plugins.filter((p) => p.port !== "VersionProviderPort"), + ports: manifest.ports.filter((p) => p.port !== "VersionProviderPort"), + ...(manifest.extensions ? { extensions: manifest.extensions } : {}), }; const kernel = new Kernel({ workDir, manifest: noVersion, logger: silent }); await kernel.start(); diff --git a/packages/kernel/test/derived-agent-merge.test.ts b/packages/kernel/test/derived-agent-merge.test.ts index ac8c0aa..7c8f704 100644 --- a/packages/kernel/test/derived-agent-merge.test.ts +++ b/packages/kernel/test/derived-agent-merge.test.ts @@ -17,7 +17,7 @@ const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -245,7 +245,7 @@ describe("rollback —— 拒绝占位 checkpoint ref", () => { sessionDataRoot, logger: silent, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, { port: "CheckpointPort", package: fixture("sentinelCheckpoint.ts") }, diff --git a/packages/kernel/test/derived-notification-midrun.test.ts b/packages/kernel/test/derived-notification-midrun.test.ts index 630e5f8..37ae676 100644 --- a/packages/kernel/test/derived-notification-midrun.test.ts +++ b/packages/kernel/test/derived-notification-midrun.test.ts @@ -55,11 +55,13 @@ function lateNotifier(): { executor: DerivedAgentExecutor; drainCount: () => num function manifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; } diff --git a/packages/kernel/test/hook-universal-halt.test.ts b/packages/kernel/test/hook-universal-halt.test.ts index 9f2cf8d..346dfdd 100644 --- a/packages/kernel/test/hook-universal-halt.test.ts +++ b/packages/kernel/test/hook-universal-halt.test.ts @@ -24,11 +24,13 @@ const noAsk = async (_r: AskQuestionRequest): Promise => ({ /** 永远发起工具调用、从不自己结束——不停下就会一路跑到 maxTurns。 */ function loopingManifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmLoop.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; } @@ -127,11 +129,13 @@ describe("continue:false —— 整轮急停,比 deny/block 更强", () => { const kernel = new Kernel({ workDir, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }, logger: silent, }); @@ -169,11 +173,13 @@ describe("continue:false —— 整轮急停,比 deny/block 更强", () => { const kernel = new Kernel({ workDir, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }, logger: silent, }); diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts index 7f5befd..0883578 100644 --- a/packages/kernel/test/kernel.test.ts +++ b/packages/kernel/test/kernel.test.ts @@ -49,7 +49,7 @@ beforeEach(async () => { describe("Kernel 集成 —— 纯文本 turn", () => { it("跑通 agent_start→turn→agent_end,产出 user+assistant 两条消息并持久化 turn", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -100,7 +100,7 @@ describe("Kernel 集成 —— 纯文本 turn", () => { workDir, sessionDataRoot: blockedRoot, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -118,11 +118,13 @@ describe("Kernel 集成 —— 纯文本 turn", () => { describe("Kernel 集成 —— 工具调用 turn 循环", () => { it("发起工具调用 → 执行 → 结果喂回 → 第二 turn 文本结束", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -157,7 +159,7 @@ describe("Kernel 集成 —— 工具调用 turn 循环", () => { describe("Kernel 装配 —— 必须实现的 Port", () => { it("无 LLMProvider → start 中止", async () => { const manifest: Manifest = { - plugins: [{ port: "FileSystemPort", package: "@helios/fs-node" }], + ports: [{ port: "FileSystemPort", package: "@helios/fs-node" }], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -166,7 +168,7 @@ describe("Kernel 装配 —— 必须实现的 Port", () => { it("无 FileSystemPort → start 中止", async () => { const manifest: Manifest = { - plugins: [{ port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }], + ports: [{ port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -177,7 +179,7 @@ describe("Kernel 装配 —— 必须实现的 Port", () => { describe("Kernel 装配 —— 降级:可选 Port 全不加载仍正常对话", () => { it("只配 fs + llm,无 memory/multiAgent/compact/checkpoint,对话照常", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], @@ -194,7 +196,7 @@ describe("Kernel 装配 —— 降级:可选 Port 全不加载仍正常对话" describe("PluginLoader —— 版本与 shape 校验", () => { it("apiVersion 不符的 LLM 被拒载(且无其它 LLM → start 中止)", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("badVersionLlm.ts") }, ], @@ -207,7 +209,7 @@ describe("PluginLoader —— 版本与 shape 校验", () => { it("shape 校验失败的插件被跳过,kernel 仍能启动", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("malformedCompact.ts") }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, @@ -227,7 +229,7 @@ describe("PluginLoader —— 版本与 shape 校验", () => { it("单实例 Port 重复声明报错并被记录", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, @@ -242,11 +244,13 @@ describe("PluginLoader —— 版本与 shape 校验", () => { async function bootHookCaptureSession() { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("hookCaptureCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmCapture.ts") }, ], + extensions: [ + { package: fixture("hookCaptureCapability.ts") }, + ], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -298,11 +302,13 @@ describe("SessionStart —— 懒触发 + 冻结注入", () => { it("resumeSession 恢复历史会话时 source === 'resume'", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("hookCaptureCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmCapture.ts") }, ], + extensions: [ + { package: fixture("hookCaptureCapability.ts") }, + ], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -346,12 +352,14 @@ describe("SessionEnd —— dispose() 通知", () => { describe("sessionId 贯穿所有 Hook 事件(对齐 valos HookBaseStdin)", () => { it("UserPromptSubmit/SessionStart/PreToolUse/PostToolUse/Stop/SessionEnd payload 均带正确 sessionId", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("hookCaptureCapability.ts") }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("hookCaptureCapability.ts") }, + { package: fixture("mockCapability.ts") }, + ], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -389,11 +397,13 @@ describe("HookConfigLoader —— 装配到 Kernel.start()", () => { "utf8", ); const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const { logger } = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger }); @@ -421,11 +431,13 @@ describe("HookRunner 接线 —— Kernel 真实使用自定义 logger 记录 ho throw new Error("boom-in-hook"); }; const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapabilityPreToolUse.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapabilityPreToolUse.ts") }, + ], }; const cap = capturingLogger(); const kernel = new Kernel({ workDir, manifest, logger: cap.logger }); diff --git a/packages/kernel/test/lifecycle-order.test.ts b/packages/kernel/test/lifecycle-order.test.ts index e7eb02e..da1bc25 100644 --- a/packages/kernel/test/lifecycle-order.test.ts +++ b/packages/kernel/test/lifecycle-order.test.ts @@ -23,11 +23,13 @@ const noAsk = async (_r: AskQuestionRequest): Promise => ({ function manifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; } diff --git a/packages/kernel/test/list-sessions-ports.test.ts b/packages/kernel/test/list-sessions-ports.test.ts index 442405d..4a16033 100644 --- a/packages/kernel/test/list-sessions-ports.test.ts +++ b/packages/kernel/test/list-sessions-ports.test.ts @@ -13,7 +13,7 @@ const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: "@helios/checkpoint-fs" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, diff --git a/packages/kernel/test/live/code-review.eval.live.test.ts b/packages/kernel/test/live/code-review.eval.live.test.ts index 09c1f56..3079153 100644 --- a/packages/kernel/test/live/code-review.eval.live.test.ts +++ b/packages/kernel/test/live/code-review.eval.live.test.ts @@ -122,15 +122,14 @@ gate(`代码审查 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { await writeFile(abs, content, "utf8"); } const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: "@helios/checkpoint-fs" }, { port: "LLMProvider", package: "@helios/llm-openai", options: { baseURL: BASE_URL, apiKey: API_KEY, model }, - }, - ], + }], }; kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); diff --git a/packages/kernel/test/live/derived-agent.live.test.ts b/packages/kernel/test/live/derived-agent.live.test.ts index 4086c65..0aa7b15 100644 --- a/packages/kernel/test/live/derived-agent.live.test.ts +++ b/packages/kernel/test/live/derived-agent.live.test.ts @@ -81,15 +81,14 @@ let kernel: Kernel; function manifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: "@helios/checkpoint-fs" }, { port: "LLMProvider", package: "@helios/llm-openai", options: { baseURL: BASE_URL, apiKey: API_KEY, model }, - }, - ], + }], }; } diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 722aa44..c53e2fc 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -226,6 +226,30 @@ export const DEFECTS: Defect[] = [ summary: "sendWithRetry() 没有把可选回调 onRetry 往下透传,调用方注册的重试观测永远不会被触发,且不会报错", }, + // --- 配置 / schema 语义漂移类。前二十七个的缺陷都在**代码**里;这一类的代码全都能跑、 + // 结果也"看着对",错在**配置字段的含义**与它自己的 schema 文档对不上—— + // 写配置的人以为生效了,实际静默走了默认值或反向语义。零异常、零日志。 + { + id: "inverted-enabled-default", + file: "src/config/pluginConfig.ts", + signals: [["enabled", "默认"], ["enabled", "省略"], ["enabled", "禁用"]], + summary: + "schema 声明 enabled 省略即启用,loadEntries 却用 `entry.enabled !== true` 判断,导致没写这个字段的插件全部静默禁用", + }, + { + id: "schema-field-name-mismatch", + file: "src/config/pluginConfig.ts", + signals: [["retrycount"], ["retries", "字段名"], ["retries", "不生效"]], + summary: + "schema 里字段叫 retries,实现读的是 entry.retryCount,配置里写 retries 完全不生效且不报错", + }, + { + id: "overloaded-config-field", + file: "src/config/pluginConfig.ts", + signals: [["sources", "exporter"], ["sources", "导出器"]], + summary: + "sources 数组同时装数据源与导出器,靠 role 字段二次分类;schema 只把它描述成「数据源列表」,加导出器的人会当数据源处理", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -647,6 +671,74 @@ export async function sendWithRetry( if (first.ok) return first; return send({ ...buildRetryRequest(url, token, traceId), timeoutMs: RETRY_TIMEOUT_MS }); } +`, + "src/config/pluginConfig.ts": `/** + * 插件配置加载。 + * + * ## config schema + * + * \`\`\`jsonc + * { + * "sources": [ // 数据源列表,按声明顺序依次拉取 + * { + * "name": "orders", + * "enabled": true, // 省略即启用;只有显式写 false 才跳过 + * "retries": 3 // 拉取失败重试次数,省略用 DEFAULT_RETRIES + * } + * ] + * } + * \`\`\` + */ + +export const DEFAULT_RETRIES = 2; + +export interface SourceEntry { + name: string; + enabled?: boolean; + retries?: number; + retryCount?: number; + role?: "source" | "exporter"; + target?: string; +} + +export interface LoadedEntry { + name: string; + retries: number; +} + +/** 读取配置,跳过被禁用的条目。 */ +export function loadEntries(sources: readonly SourceEntry[]): LoadedEntry[] { + const loaded: LoadedEntry[] = []; + for (const entry of sources) { + if (entry.enabled !== true) continue; + loaded.push({ name: entry.name, retries: entry.retryCount ?? DEFAULT_RETRIES }); + } + return loaded; +} + +/** 依次拉取所有数据源。 */ +export async function pullAll( + sources: readonly SourceEntry[], + pull: (name: string) => Promise, +): Promise { + const rows: string[] = []; + for (const entry of loadEntries(sources)) { + rows.push(...(await pull(entry.name))); + } + return rows; +} + +/** 导出器也放在 sources 里,靠 role 区分;导出发生在全部数据源拉完之后。 */ +export async function runExporters( + sources: readonly SourceEntry[], + write: (target: string, rows: readonly string[]) => Promise, + rows: readonly string[], +): Promise { + for (const entry of sources) { + if (entry.role !== "exporter") continue; + await write(entry.target ?? "stdout", rows); + } +} `, "test/pricing.test.ts": `import { describe, it, expect } from "vitest"; import { subtotal, total } from "../src/pricing"; diff --git a/packages/kernel/test/live/write-audit.eval.live.test.ts b/packages/kernel/test/live/write-audit.eval.live.test.ts index 861be1a..30a8a35 100644 --- a/packages/kernel/test/live/write-audit.eval.live.test.ts +++ b/packages/kernel/test/live/write-audit.eval.live.test.ts @@ -68,15 +68,14 @@ gate(`写路径 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { workDir = await mkdtemp(join(tmpdir(), "helios-eval-write-")); await writeFile(join(workDir, "math.js"), BUGGY, "utf8"); const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: "@helios/checkpoint-fs" }, { port: "LLMProvider", package: "@helios/llm-openai", options: { baseURL: BASE_URL, apiKey: API_KEY, model }, - }, - ], + }], }; kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); diff --git a/packages/kernel/test/llm-downgrade.test.ts b/packages/kernel/test/llm-downgrade.test.ts index d3b7907..094f9ec 100644 --- a/packages/kernel/test/llm-downgrade.test.ts +++ b/packages/kernel/test/llm-downgrade.test.ts @@ -25,13 +25,11 @@ beforeEach(async () => { async function run(llmFixture: string, llmOptions: LLMOptions, withEchoTool = false) { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - ...(withEchoTool - ? [{ port: "CapabilityProvider" as const, package: fixture("mockCapability.ts") }] - : []), { port: "LLMProvider", package: fixture(llmFixture) }, ], + extensions: withEchoTool ? [{ package: fixture("mockCapability.ts") }] : [], }; const kernel = new Kernel({ workDir, manifest, logger: silent, llmOptions }); await kernel.start(); diff --git a/packages/kernel/test/p1-pluggability.test.ts b/packages/kernel/test/p1-pluggability.test.ts index 7c7a9be..cf510c0 100644 --- a/packages/kernel/test/p1-pluggability.test.ts +++ b/packages/kernel/test/p1-pluggability.test.ts @@ -21,12 +21,14 @@ beforeEach(async () => { async function runDelegateWith(multiAgentPackage: string): Promise { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "MultiAgentPort", package: multiAgentPackage }, - { port: "CapabilityProvider", package: fixture("delegatorCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmDelegate.ts") }, ], + extensions: [ + { package: fixture("delegatorCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); diff --git a/packages/kernel/test/p2-rollback.test.ts b/packages/kernel/test/p2-rollback.test.ts index d6c8318..395d31c 100644 --- a/packages/kernel/test/p2-rollback.test.ts +++ b/packages/kernel/test/p2-rollback.test.ts @@ -41,7 +41,7 @@ async function runAndRollback( session: Awaited>; }> { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: checkpointPackage }, { port: "LLMProvider", package: fixture("mockLlmWrite.ts") }, @@ -113,7 +113,7 @@ describe("P2 Turn 回溯 —— CheckpointPort 从 fs 换成 git,Session 与 describe("Write/Edit 文件变更归因", () => { it("成功 Write 记录同一 toolUseId 的 before/after 并广播 artifact action", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmWrite.ts") }, ], @@ -155,7 +155,7 @@ describe("Write/Edit 文件变更归因", () => { it("observer 失败不改变工具成功结果,但会持久化 audit gap 回调", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmWrite.ts") }, ], diff --git a/packages/kernel/test/plugin-dispose.test.ts b/packages/kernel/test/plugin-dispose.test.ts index 7980b9c..5e4547b 100644 --- a/packages/kernel/test/plugin-dispose.test.ts +++ b/packages/kernel/test/plugin-dispose.test.ts @@ -36,11 +36,13 @@ describe("Kernel plugin disposal", () => { it("disposes plugin instances in reverse load order and is idempotent", async () => { const capability = fixture("disposableCapability.ts"); const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, - { port: "CapabilityProvider", package: capability, options: { id: "first" } }, - { port: "CapabilityProvider", package: capability, options: { id: "second" } }, + ], + extensions: [ + { package: capability, options: { id: "first" } }, + { package: capability, options: { id: "second" } }, ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); @@ -64,7 +66,7 @@ describe("Kernel plugin disposal", () => { }, }; const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "MultiAgentPort", package: fixture("scopedDisposeMultiAgent.ts") }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, diff --git a/packages/kernel/test/plugin-mounts.test.ts b/packages/kernel/test/plugin-mounts.test.ts index 63eb706..a9ca58e 100644 --- a/packages/kernel/test/plugin-mounts.test.ts +++ b/packages/kernel/test/plugin-mounts.test.ts @@ -22,12 +22,14 @@ const noAsk = async (_r: AskQuestionRequest): Promise => ({ /** 装了分析插件的 manifest。echo LLM 会把收到的 user 消息全文回显,用来验证真进了请求。 */ function manifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, - { port: "CapabilityProvider", package: fixture("capAnalyticsPlugin.ts") }, { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + { package: fixture("capAnalyticsPlugin.ts") }, + ], }; } diff --git a/packages/kernel/test/port-overrides.test.ts b/packages/kernel/test/port-overrides.test.ts index 1a02eb8..d5ad6f6 100644 --- a/packages/kernel/test/port-overrides.test.ts +++ b/packages/kernel/test/port-overrides.test.ts @@ -39,7 +39,7 @@ beforeEach(async () => { /** manifest 是白名单:只有在这里声明过的模块才允许被 spec 引用。 */ const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, { port: "MemoryPort", package: PROBE_MEMORY }, diff --git a/packages/kernel/test/prompt.test.ts b/packages/kernel/test/prompt.test.ts index 1c1a610..c69d98b 100644 --- a/packages/kernel/test/prompt.test.ts +++ b/packages/kernel/test/prompt.test.ts @@ -150,7 +150,7 @@ describe("renderProjectInstructions", () => { describe("Kernel 系统提示词装配", () => { const manifest = { - plugins: [ + ports: [ { port: "FileSystemPort" as const, package: "@helios/fs-node" }, { port: "LLMProvider" as const, package: fixture("mockLlmEchoSystem.ts") }, ], diff --git a/packages/kernel/test/resume.test.ts b/packages/kernel/test/resume.test.ts index d59fa74..9056958 100644 --- a/packages/kernel/test/resume.test.ts +++ b/packages/kernel/test/resume.test.ts @@ -17,7 +17,7 @@ function textOf(m: Message): string { } const manifest = (): Manifest => ({ - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CheckpointPort", package: "@helios/checkpoint-fs" }, { port: "LLMProvider", package: fixture("mockLlmTextOnly.ts") }, diff --git a/packages/kernel/test/run-assembly.test.ts b/packages/kernel/test/run-assembly.test.ts index 4e487bd..a9f75d3 100644 --- a/packages/kernel/test/run-assembly.test.ts +++ b/packages/kernel/test/run-assembly.test.ts @@ -110,7 +110,7 @@ describe("派生 agent 与主会话拿到同一套配置", () => { agentSpecDir: specDir, globalInstructionDir: join(workDir, "global"), manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmDerivedTwoTurns.ts") }, ], diff --git a/packages/kernel/test/session-mounts.test.ts b/packages/kernel/test/session-mounts.test.ts index d31fc4c..e9d35f0 100644 --- a/packages/kernel/test/session-mounts.test.ts +++ b/packages/kernel/test/session-mounts.test.ts @@ -22,11 +22,13 @@ const noAsk = async (_r: AskQuestionRequest): Promise => ({ function manifest(): Manifest { return { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; } @@ -109,8 +111,8 @@ describe("记忆召回从 Session 直调 Port 改成 session:start 能力", () = const kernel = new Kernel({ workDir, manifest: { - plugins: [ - ...manifest().plugins, + ports: [ + ...manifest().ports, { port: "MemoryPort", package: fixture("probeMemory.ts") }, ], }, @@ -133,8 +135,8 @@ describe("记忆召回从 Session 直调 Port 改成 session:start 能力", () = const kernel = new Kernel({ workDir, manifest: { - plugins: [ - ...manifest().plugins, + ports: [ + ...manifest().ports, { port: "MemoryPort", package: fixture("probeMemory.ts"), options: { empty: true } }, ], }, diff --git a/packages/kernel/test/thinking.test.ts b/packages/kernel/test/thinking.test.ts index 9cf023c..4e9a24e 100644 --- a/packages/kernel/test/thinking.test.ts +++ b/packages/kernel/test/thinking.test.ts @@ -31,7 +31,7 @@ async function runOnce(llmFixture: string) { /** 需要看事件(agent_end.error / llm_retry)时用这个。 */ async function runCollecting(llmFixture: string, sleep?: (ms: number) => Promise) { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture(llmFixture) }, ], diff --git a/packages/kernel/test/tool-default-permission.test.ts b/packages/kernel/test/tool-default-permission.test.ts index 3974402..17ce8a0 100644 --- a/packages/kernel/test/tool-default-permission.test.ts +++ b/packages/kernel/test/tool-default-permission.test.ts @@ -23,11 +23,13 @@ function fixture(name: string): string { const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapabilityGuardedTool.ts") }, { port: "LLMProvider", package: fixture("mockLlmGuarded.ts") }, ], + extensions: [ + { package: fixture("mockCapabilityGuardedTool.ts") }, + ], }; let workDir: string; diff --git a/packages/kernel/test/unit/pluginLoader.requires.test.ts b/packages/kernel/test/unit/pluginLoader.requires.test.ts new file mode 100644 index 0000000..3f80d2d --- /dev/null +++ b/packages/kernel/test/unit/pluginLoader.requires.test.ts @@ -0,0 +1,147 @@ +// manifest 两段 schema(ports / extensions)与 `requires` 拓扑排序。 +// +// 为什么需要 requires:`create(ctx)` 拿到的 `ctx.ports` 只含**已注册**的 Port,而八个有 +// no-op 兜底的 Port 在缺席时返回 NoopXxx 而非报错——顺序写反的后果是功能悄悄失效, +// 零日志零异常。此前这条规则只写在 helios.config.json 的一行注释里。 +// +// ⚠️ 排序必须稳定:没有任何 requires 时输出要逐条等于输入,否则会悄悄改变 +// `context:prepare`(concat 语义、拼接顺序模型可见)的插件挂载顺序。 + +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AskQuestionRequest, AskQuestionResponse, Logger } from "@helios/ports"; +import { Kernel, sortPortEntries, type Manifest, type PortEntry } from "../../src/index"; + +function fixture(name: string): string { + return fileURLToPath(new URL(`../fixtures/${name}`, import.meta.url)); +} +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); + +function collectingLogger(): { logger: Logger; warns: string[] } { + const warns: string[] = []; + return { + warns, + logger: { debug() {}, info() {}, warn: (m: string) => warns.push(m), error() {} }, + }; +} + +describe("sortPortEntries", () => { + it("没有 requires 时输出逐条等于输入(旧行为不变)", () => { + const entries: PortEntry[] = [ + { port: "MemoryPort", package: "m" }, + { port: "FileSystemPort", package: "f" }, + { port: "CheckpointPort", package: "c" }, + { port: "LLMProvider", package: "l" }, + ]; + expect(sortPortEntries(entries, silent)).toEqual(entries); + }); + + it("requires 把被依赖的那条提到前面,即使 manifest 里写反了", () => { + const entries: PortEntry[] = [ + { port: "MemoryPort", package: "m", requires: ["FileSystemPort"] }, + { port: "FileSystemPort", package: "f" }, + ]; + expect(sortPortEntries(entries, silent).map((e) => e.package)).toEqual(["f", "m"]); + }); + + it("只挪必须挪的那条,且依赖一满足就按原声明位尽早放回", () => { + const entries: PortEntry[] = [ + { port: "CheckpointPort", package: "c" }, + { port: "MemoryPort", package: "m", requires: ["FileSystemPort"] }, + { port: "LLMProvider", package: "l1" }, + { port: "FileSystemPort", package: "f" }, + { port: "LLMProvider", package: "l2" }, + ]; + // m 原本是第 2 条,只因为依赖 f 才被推后;f 一落位它就插回来,排在原本更靠后的 l2 之前。 + expect(sortPortEntries(entries, silent).map((e) => e.package)).toEqual([ + "c", + "l1", + "f", + "m", + "l2", + ]); + }); + + it("自依赖当场抛错", () => { + const entries: PortEntry[] = [{ port: "MemoryPort", package: "m", requires: ["MemoryPort"] }]; + expect(() => sortPortEntries(entries, silent)).toThrow(/成环.*自身/); + }); + + it("互相依赖成环时在加载任何插件之前抛错", () => { + const entries: PortEntry[] = [ + { port: "MemoryPort", package: "m", requires: ["CheckpointPort"] }, + { port: "CheckpointPort", package: "c", requires: ["MemoryPort"] }, + ]; + expect(() => sortPortEntries(entries, silent)).toThrow(/成环/); + }); + + it("依赖的 Port 不在 manifest 里:warn 而非抛错(它会落到 no-op 兜底,是合法降级)", () => { + const { logger, warns } = collectingLogger(); + const entries: PortEntry[] = [ + { port: "MemoryPort", package: "m", requires: ["VersionProviderPort"] }, + ]; + expect(sortPortEntries(entries, logger).map((e) => e.package)).toEqual(["m"]); + expect(warns.join("\n")).toContain("VersionProviderPort"); + }); +}); + +describe("requires 端到端:写反顺序时救得回来", () => { + let workDir: string; + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "helios-requires-")); + return async () => rm(workDir, { recursive: true, force: true }); + }); + + /** + * 用真 `@helios/memory-fs`(而非 probeMemory fixture):只有它在 `create(ctx)` 里读 + * `ctx.ports.fileSystem`,才复现得出"顺序写反 → 静默降级成 NoopMemory"这条病症。 + */ + async function prefixWith(memoryRequires: boolean): Promise { + await mkdir(join(workDir, ".helios", "memory"), { recursive: true }); + await writeFile(join(workDir, ".helios", "memory", "MEMORY.md"), "记住这一条\n"); + const manifest: Manifest = { + ports: [ + { + port: "MemoryPort", + package: "@helios/memory-fs", + ...(memoryRequires ? { requires: ["FileSystemPort" as const] } : {}), + }, + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + }; + const kernel = new Kernel({ workDir, manifest, logger: silent, globalInstructionDir: "" }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const prefix = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + return prefix; + } + + it("MemoryPort 写在 FileSystemPort 前面且不声明 requires → 记忆静默失效", async () => { + // 这就是 requires 要解决的病症本身:整个 kernel 照常启动,只是 不见了。 + expect(await prefixWith(false)).not.toContain("记住这一条"); + }); + + it("同样的错误顺序,声明 requires 后记忆正常召回", async () => { + expect(await prefixWith(true)).toContain("记住这一条"); + }); +}); + +describe("manifest schema:两段各自只收自己那类", () => { + it("extensions 条目没有 port 字段——kernel 不调它,无从声明是哪个 Port", () => { + // 编译期已由 ExtensionEntry 保证;这里钉住运行期真的按两段分别加载, + // 防止将来有人为了"兼容"又把 extension 塞回 ports 段。 + const manifest: Manifest = { + ports: [{ port: "FileSystemPort", package: "@helios/fs-node" }], + extensions: [{ package: fixture("mockCapability.ts") }], + }; + expect(Object.keys(manifest.extensions![0]!)).toEqual(["package"]); + expect(manifest.ports.every((p) => typeof p.port === "string")).toBe(true); + }); +}); diff --git a/packages/kernel/test/wiring.test.ts b/packages/kernel/test/wiring.test.ts index 6c301e3..1f12499 100644 --- a/packages/kernel/test/wiring.test.ts +++ b/packages/kernel/test/wiring.test.ts @@ -27,7 +27,7 @@ beforeEach(async () => { describe("CompactStrategyPort 接入 turn 循环", () => { it("shouldCompact 命中 → 生成 summary 节点、被压缩旧节点移出当前路径 + emit compact 事件", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "CompactStrategyPort", package: fixture("mockCompact.ts") }, { port: "LLMProvider", package: fixture("mockLlmEchoSystem.ts") }, @@ -61,7 +61,7 @@ describe("CompactStrategyPort 接入 turn 循环", () => { describe("内建 AgentTeam 工具消费 MultiAgentPort", () => { it("装有 teams-mailbox → 派发成功并落地邮箱文件", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "MultiAgentPort", package: "@helios/teams-mailbox" }, { port: "LLMProvider", package: fixture("mockLlmTask.ts") }, @@ -90,7 +90,7 @@ describe("内建 AgentTeam 工具消费 MultiAgentPort", () => { it("未装 MultiAgentPort(noop)→ AgentTeam 返回结构化错误,run 不崩", async () => { const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmTask.ts") }, ], diff --git a/packages/protocol/src/ws.e2e.test.ts b/packages/protocol/src/ws.e2e.test.ts index 86f80ad..2149dc6 100644 --- a/packages/protocol/src/ws.e2e.test.ts +++ b/packages/protocol/src/ws.e2e.test.ts @@ -35,11 +35,13 @@ const cleanups: Array<() => void> = []; beforeEach(async () => { workDir = await mkdtemp(join(tmpdir(), "helios-proto-e2e-")); const manifest: Manifest = { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, - { port: "CapabilityProvider", package: fixture("mockCapability.ts") }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], + extensions: [ + { package: fixture("mockCapability.ts") }, + ], }; const kernel = new Kernel({ workDir, manifest, logger: silent }); await kernel.start(); diff --git a/packages/workspace/src/runtimeRegistry.test.ts b/packages/workspace/src/runtimeRegistry.test.ts index 6e1a53c..a61b6fa 100644 --- a/packages/workspace/src/runtimeRegistry.test.ts +++ b/packages/workspace/src/runtimeRegistry.test.ts @@ -255,7 +255,7 @@ describe("LocalRuntimeRegistry", () => { sessions, materializer: new LocalWorkspaceMaterializer({ paths }), manifest: { - plugins: [{ port: "FileSystemPort", package: "file:///missing-helios-plugin.ts" }], + ports: [{ port: "FileSystemPort", package: "file:///missing-helios-plugin.ts" }], }, idFactory: (prefix) => `${prefix}_cleanup`, }); @@ -378,7 +378,7 @@ describe("LocalRuntimeRegistry", () => { editRecords, mutations, manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture(llmFixture) }, ], diff --git a/packages/workspace/src/runtimeRegistry.ts b/packages/workspace/src/runtimeRegistry.ts index 84e9096..e7e5d3b 100644 --- a/packages/workspace/src/runtimeRegistry.ts +++ b/packages/workspace/src/runtimeRegistry.ts @@ -544,11 +544,12 @@ function relativeWithin(root: string, path: string): string { function manifestForWorkspace(manifest: Manifest, storageDir: string): Manifest { return { - plugins: manifest.plugins.map((entry) => + ports: manifest.ports.map((entry) => entry.port === "MemoryPort" ? { ...entry, options: { ...entry.options, storageDir } } : { ...entry, options: entry.options ? { ...entry.options } : undefined }, ), + ...(manifest.extensions ? { extensions: manifest.extensions.map((e) => ({ ...e })) } : {}), }; } diff --git a/packages/workspace/src/workspace.e2e.test.ts b/packages/workspace/src/workspace.e2e.test.ts index d5a6a30..f6f0a36 100644 --- a/packages/workspace/src/workspace.e2e.test.ts +++ b/packages/workspace/src/workspace.e2e.test.ts @@ -52,7 +52,7 @@ describe("Workspace platform end to end", () => { editRecords: edits, mutations: new LocalMutationCoordinator(paths), manifest: { - plugins: [ + ports: [ { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmWrite.ts") }, ], From 92f6ae6574bdb90c266d455659fea068348325b8 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 23:10:12 +0800 Subject: [PATCH 05/11] =?UTF-8?q?refactor(kernel):=20=E5=88=A0=20Capabilit?= =?UTF-8?q?yProvider.getHookHandlers=EF=BC=8Chook=20=E7=9A=84=E5=90=A6?= =?UTF-8?q?=E5=86=B3=E6=9D=83=E5=8F=AA=E5=B1=9E=E4=BA=8E=E7=94=A8=E6=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么删 hook 的 `deny` 一票否决之所以成立,是因为 hooks.json 由**用户自己**写。 `getHookHandlers()` 让插件也能行使这份否决权——「装一个数据分析插件」就默认授权 它拦截所有工具调用。这在信任模型上是错的,不是「多一个可选能力」。 插件想在工具前后插手,走 PluginModule.mounts() 的 tool:before:它能短路,但结果 显示为一个工具返回值,不伪装成用户的权限拒绝。 ## 替代注入路径:KernelOptions.hooks hook 现在有且只有两条来路,都代表用户: - ~/.helios/hooks.json + /.helios/hooks.json(用户写的外部命令) - KernelOptions.hooks(宿主 CLI/Electron 代表用户在进程内注册) ⚠️ 上一轮我说 getHookHandlers「零实现」是查错了——grep 限定在 packages/*/src/, 漏了 test/。生产代码确实零使用,但三个 fixture 靠它做进程内注入,所以删它必须先 有替代路径。hookCaptureCapability 一个工具都不供,它当初被塞进 manifest 纯粹是 为了借道注册 hook,现在退回成一组纯绑定 hookCaptureBindings.ts,不再是插件。 ## 护栏:这条边界坏掉零症状 多注册一个 hook 不会让任何现有断言变红,所以 test/hook-authority.test.ts 两层都做: - 结构:契约里没有 getHookHandlers;activateProvider 里没有 hooks.register; 两条合法来路都在 start() 里 - 行为:mockCapabilityRogueHooks 这个「越权插件」在自己对象上留了 getHookHandlers 想 deny,工具照常执行 只有结构护栏挡不住「换个方法名重新接上」,所以行为那条是必需的。 三处变异全部命中:kernel 重新去调 / 契约加回声明 / 宿主那条路被摘掉。 ⚠️ 结构断言第一版假红:`not.toContain("getHookHandlers")` 撞上了解释「为什么没有 它」的那句注释。改成先 stripComments 再断言。 eval set 30 → 33:新增授权来源混淆类(src/auth/permit.ts:isAdmin 读请求体自称的 payload.role 而非会话已验证的 session.role、插件用 descriptor.skipAudit 自行豁免 审计、多来源决策用「最后一个胜出」合并导致 allow 覆盖 deny)。代码全都能跑、权限 检查也真的在跑,错在这份权力是谁给的——正是本轮改的那件事。 typecheck 退出码 0;904 passed(+5)。 真机 eval:readonly 28/33,七个专项各 3/3,写路径全绿。 ## 一处上轮的猜测要收回 上轮报告里我怀疑 derived-agent workflow 用例的「路径越界」是 macOS /var → /private/var 符号链接导致 WorkDirGuard 误判。本轮同一用例又失败一次,但失败形态 完全不同:模型把工具调用的 DSML 标记当纯文本吐了出来。两次都重跑即过。 所以那是 deepseek-v4-flash-0731-ali 在这个多 agent workflow 上的退化, 不是 pathGuard 的 bug——之前的符号链接推测无证据,撤回。 --- docs/code-organization.md | 21 +++ packages/host/src/index.test.ts | 10 +- packages/kernel/src/kernel.ts | 17 ++- packages/kernel/test/agent-loop-fixes.test.ts | 45 ++++-- .../test/fixtures/hookCaptureBindings.ts | 65 +++++++++ .../test/fixtures/hookCaptureCapability.ts | 72 ---------- .../fixtures/mockCapabilityGuardedTool.ts | 12 +- .../test/fixtures/mockCapabilityPreToolUse.ts | 20 +-- .../test/fixtures/mockCapabilityRogueHooks.ts | 38 +++++ packages/kernel/test/hook-authority.test.ts | 135 ++++++++++++++++++ packages/kernel/test/kernel.test.ts | 36 +++-- .../kernel/test/live/fixtures/evalProject.ts | 92 ++++++++++++ .../test/tool-default-permission.test.ts | 4 +- packages/ports/src/capability.ts | 17 ++- 14 files changed, 455 insertions(+), 129 deletions(-) create mode 100644 packages/kernel/test/fixtures/hookCaptureBindings.ts delete mode 100644 packages/kernel/test/fixtures/hookCaptureCapability.ts create mode 100644 packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts create mode 100644 packages/kernel/test/hook-authority.test.ts diff --git a/docs/code-organization.md b/docs/code-organization.md index e930106..bf999f3 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -51,6 +51,27 @@ run 级的进 `buildDefaultMounts`、会话级的写在 Session 的私有方法 ⚠️ 别再在某个文件里写「**这里是唯一决定谁先谁后的地方**」——那句话曾经写在 `loopExtensions.ts` 里,是假的。 +## hook 的否决权只属于用户 + +hook 有两条来路,**都代表用户**,所以都有 `deny` 一票否决: + +| 来路 | 谁写的 | +|---|---| +| `~/.helios/hooks.json` + `/.helios/hooks.json` | 用户自己(外部命令) | +| `KernelOptions.hooks` | 宿主(CLI / Electron,代表用户在进程内注册) | + +⚠️ **插件不在此列。** `CapabilityProvider` 曾有 `getHookHandlers()`,已删—— +`deny` 之所以成立是因为 hooks.json 由用户自己写;让插件也能否决,等于 +「装一个数据分析插件」就默认授权它拦截所有工具调用。 + +插件想在工具前后插手,走 `PluginModule.mounts()` 的 `tool:before`:它能短路, +但结果显示为**一个工具返回值**,不伪装成用户的权限拒绝。 + +⚠️ 这条边界坏掉**零症状**——多注册一个 hook 不会让任何现有断言变红。 +护栏在 `test/hook-authority.test.ts`:结构(契约与 `activateProvider` 里都不许出现 +hook 注册)+ 行为(`mockCapabilityRogueHooks` 这个越权插件的 deny 不生效)。 +光有结构护栏挡不住「换个方法名重新接上」,所以两者都要。 + ## `packages/kernel/src/policies/` —— 按能力分,不按机制分 一文件一策略,**文件名说它做什么,不说它用什么机制**: diff --git a/packages/host/src/index.test.ts b/packages/host/src/index.test.ts index b34ea6a..e254767 100644 --- a/packages/host/src/index.test.ts +++ b/packages/host/src/index.test.ts @@ -7,7 +7,10 @@ import type { Logger, Message } from "@helios/ports"; import { Kernel, type Manifest, type AgentEvent } from "@helios/kernel"; import { RpcClient, nodeWsClientTransport } from "@helios/protocol"; import { serveKernelOverWs, type ServeHandle } from "./index"; -import { calls as hookCalls } from "../../kernel/test/fixtures/hookCaptureCapability"; +import { + calls as hookCalls, + hooks as captureHooks, +} from "../../kernel/test/fixtures/hookCaptureBindings"; const silent: Logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; @@ -151,11 +154,8 @@ describe("@helios/host serveKernelOverWs —— 连接关闭触发 SessionEnd", { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], - extensions: [ - { package: fixture("hookCaptureCapability.ts") }, - ], }; - const kernel = new Kernel({ workDir, manifest, logger: silent }); + const kernel = new Kernel({ workDir, manifest, logger: silent, hooks: captureHooks }); await kernel.start(); const localHandle = await serveKernelOverWs({ kernel, port: 0 }); diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 671b527..73498dd 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -6,6 +6,7 @@ import type { AskQuestionRequest, AskQuestionResponse, MountBundle, + HookBinding, } from "@helios/ports"; import { ServiceCollection } from "./serviceCollection"; import { IFileSystemPort } from "./tokens"; @@ -56,6 +57,14 @@ export interface KernelOptions { resolvePackage?: PackageResolver; /** 覆盖 hook 命令默认超时(毫秒),主要用于测试/宿主定制,不改变配置加载来源。 */ hookCommandTimeoutMs?: number; + /** + * 宿主注册的进程内 hook,与 hooks.json 并列、同等权重(都有否决权)。 + * + * 之所以由宿主而不是插件提供:hook 的否决权来自「用户授权」,CLI/Electron 是用户的 + * 代理,插件不是。这里也是测试注入 hook 的唯一途径——此前测试靠 + * `CapabilityProvider.getHookHandlers()`,那条路已删。 + */ + hooks?: HookBinding[]; /** Disable unsafe built-in tools for constrained hosts (for example Workspace sessions). */ disabledBuiltinTools?: string[]; /** Optional observability adapter. Defaults to the LangSmith environment configuration. */ @@ -200,8 +209,10 @@ export class Kernel { true, ); - // 配置化 hook:读 ~/.helios/hooks.json + /.helios/hooks.json,与 CapabilityProvider - // 注册的 hook 并列(HookRunner.register 可多次调用),循环触发点不感知来源。 + // hook 有两条来路,都代表**用户**的意志,所以都有否决权: + // ① 配置化:~/.helios/hooks.json + /.helios/hooks.json(用户写的外部命令) + // ② 宿主注册:KernelOptions.hooks(CLI/Electron 代表用户在进程内注册) + // 插件不在此列——它没有否决权,见 CapabilityProvider 的注释。 const hookEntries = await loadHookConfig(this.opts.workDir, this.logger); this.hooks.register( toHookBindings(hookEntries, { @@ -210,6 +221,7 @@ export class Kernel { timeoutMs: this.opts.hookCommandTimeoutMs, }), ); + this.hooks.register(this.opts.hooks ?? []); this.started = true; this.logger.info( @@ -261,7 +273,6 @@ export class Kernel { ? tools.filter((tool) => !this.opts.disabledBuiltinTools?.includes(tool.name)) : tools; this.tools.add(cap.name, enabledTools, exemptPrefix); - this.hooks.register(cap.getHookHandlers?.() ?? []); } createSession(opts: CreateSessionOptions): Session { diff --git a/packages/kernel/test/agent-loop-fixes.test.ts b/packages/kernel/test/agent-loop-fixes.test.ts index a09726b..b51ec0d 100644 --- a/packages/kernel/test/agent-loop-fixes.test.ts +++ b/packages/kernel/test/agent-loop-fixes.test.ts @@ -3,12 +3,21 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { Logger, AskQuestionRequest, AskQuestionResponse, Message } from "@helios/ports"; +import type { + Logger, + AskQuestionRequest, + AskQuestionResponse, + Message, + HookBinding, +} from "@helios/ports"; import { Kernel, type Manifest, LlmProviderError } from "../src/index"; import type { AgentEvent } from "../src/events"; import type { LlmRetryOptions } from "../src/agentLoop/retryBackoff"; import { callLog as parallelCallLog } from "./fixtures/mockCapabilityParallel"; -import { behavior as preToolUseBehavior } from "./fixtures/mockCapabilityPreToolUse"; +import { + behavior as preToolUseBehavior, + hooks as preToolUseHooks, +} from "./fixtures/mockCapabilityPreToolUse"; function fixture(name: string): string { return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -70,6 +79,8 @@ async function bootSession( logger?: Logger; contextBudgetWarnTokens?: number; tracer?: RecordingTracer; + /** 宿主注册的进程内 hook;插件不能自带 hook(否决权只属于用户)。 */ + hooks?: HookBinding[]; } = {}, ) { const manifest: Manifest = { @@ -79,8 +90,14 @@ async function bootSession( ], extensions: extraExtensions, }; - const { askQuestion, logger, ...rest } = sessionOpts; - const kernel = new Kernel({ workDir, manifest, logger: logger ?? silentLogger, tracer: sessionOpts.tracer } as never); + const { askQuestion, logger, hooks, ...rest } = sessionOpts; + const kernel = new Kernel({ + workDir, + manifest, + logger: logger ?? silentLogger, + tracer: sessionOpts.tracer, + hooks: hooks ?? [], + } as never); await kernel.start(); const events: AgentEvent[] = []; const session = kernel.createSession({ askQuestion: askQuestion ?? noAsk, maxTurns, ...rest }); @@ -375,9 +392,12 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool it("PreToolUse deny:start/end 各恰好一次、toolUseId 一致、start.input 是模型原始请求参数", async () => { preToolUseBehavior.preToolUse = () => ({ decision: "deny", reason: "禁止" }); - const { session, events } = await bootSession("mockLlmWithTool.ts", [ - { package: fixture("mockCapabilityPreToolUse.ts") }, - ]); + const { session, events } = await bootSession( + "mockLlmWithTool.ts", + [{ package: fixture("mockCapabilityPreToolUse.ts") }], + undefined, + { hooks: preToolUseHooks }, + ); await session.sendMessage("go"); @@ -397,7 +417,7 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool "mockLlmWithTool.ts", [{ package: fixture("mockCapabilityPreToolUse.ts") }], undefined, - { askQuestion: rejectAsk }, + { askQuestion: rejectAsk, hooks: preToolUseHooks }, ); await session.sendMessage("go"); @@ -413,9 +433,12 @@ describe("Fix 2 —— 工具生命周期闭合:deny / 审批拒绝补齐 tool it("正常路径回归:PreToolUse 改写 input 后,start.input 仍是改写后的值(不是模型原始请求)", async () => { preToolUseBehavior.preToolUse = () => ({ decision: "allow", input: { text: "rewritten" } }); - const { session, events } = await bootSession("mockLlmWithTool.ts", [ - { package: fixture("mockCapabilityPreToolUse.ts") }, - ]); + const { session, events } = await bootSession( + "mockLlmWithTool.ts", + [{ package: fixture("mockCapabilityPreToolUse.ts") }], + undefined, + { hooks: preToolUseHooks }, + ); await session.sendMessage("go"); diff --git a/packages/kernel/test/fixtures/hookCaptureBindings.ts b/packages/kernel/test/fixtures/hookCaptureBindings.ts new file mode 100644 index 0000000..c6abbca --- /dev/null +++ b/packages/kernel/test/fixtures/hookCaptureBindings.ts @@ -0,0 +1,65 @@ +import type { + HookBinding, + UserPromptSubmitPayload, + UserPromptSubmitDecision, + SessionStartPayload, + SessionStartDecision, +} from "@helios/ports"; + +/** + * 六个 hook 事件的探针,交给 `KernelOptions.hooks`。 + * + * 曾经是一个 `CapabilityProvider`(靠已删的 `getHookHandlers()` 注入),但它一个工具 + * 都不供——把它塞进 manifest 的 extensions 段只是为了借道注册 hook。现在宿主注册 + * 是正路,它退回成一组纯绑定,不再是插件。 + */ + +/** 供测试断言触发次数与 payload。每个 test 用后需清空。 */ +export const calls: Array<{ event: string; payload: unknown }> = []; + +/** 供测试临时覆写各事件的返回决策;不设置则 handler 只记录不改写。 */ +export const behavior: { + userPromptSubmit?: (p: UserPromptSubmitPayload) => UserPromptSubmitDecision | void; + sessionStart?: (p: SessionStartPayload) => SessionStartDecision | void; +} = {}; + +export const hooks: HookBinding[] = [ + { + event: "UserPromptSubmit", + handler: (p) => { + calls.push({ event: "UserPromptSubmit", payload: p }); + return behavior.userPromptSubmit?.(p); + }, + }, + { + event: "SessionStart", + handler: (p) => { + calls.push({ event: "SessionStart", payload: p }); + return behavior.sessionStart?.(p); + }, + }, + { + event: "SessionEnd", + handler: (p) => { + calls.push({ event: "SessionEnd", payload: p }); + }, + }, + { + event: "PreToolUse", + handler: (p) => { + calls.push({ event: "PreToolUse", payload: p }); + }, + }, + { + event: "PostToolUse", + handler: (p) => { + calls.push({ event: "PostToolUse", payload: p }); + }, + }, + { + event: "Stop", + handler: (p) => { + calls.push({ event: "Stop", payload: p }); + }, + }, +]; diff --git a/packages/kernel/test/fixtures/hookCaptureCapability.ts b/packages/kernel/test/fixtures/hookCaptureCapability.ts deleted file mode 100644 index 22c9cc7..0000000 --- a/packages/kernel/test/fixtures/hookCaptureCapability.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - CapabilityProvider, - KernelContext, - HookBinding, - UserPromptSubmitPayload, - UserPromptSubmitDecision, - SessionStartPayload, - SessionStartDecision, -} from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; - -/** 供测试断言 SessionStart/UserPromptSubmit/SessionEnd 触发次数与 payload。每个 test 用后需清空。 */ -export const calls: Array<{ event: string; payload: unknown }> = []; - -/** 供测试临时覆写各事件的返回决策;不设置则 handler 只记录不改写。 */ -export const behavior: { - userPromptSubmit?: (p: UserPromptSubmitPayload) => UserPromptSubmitDecision | void; - sessionStart?: (p: SessionStartPayload) => SessionStartDecision | void; -} = {}; - -const provider: CapabilityProvider = { - name: "hookcapture", - activate() {}, - getHookHandlers(): HookBinding[] { - return [ - { - event: "UserPromptSubmit", - handler: (p) => { - calls.push({ event: "UserPromptSubmit", payload: p }); - return behavior.userPromptSubmit?.(p); - }, - }, - { - event: "SessionStart", - handler: (p) => { - calls.push({ event: "SessionStart", payload: p }); - return behavior.sessionStart?.(p); - }, - }, - { - event: "SessionEnd", - handler: (p) => { - calls.push({ event: "SessionEnd", payload: p }); - }, - }, - { - event: "PreToolUse", - handler: (p) => { - calls.push({ event: "PreToolUse", payload: p }); - }, - }, - { - event: "PostToolUse", - handler: (p) => { - calls.push({ event: "PostToolUse", payload: p }); - }, - }, - { - event: "Stop", - handler: (p) => { - calls.push({ event: "Stop", payload: p }); - }, - }, - ]; - }, -}; - -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { - return provider; -} -export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts index d42e57e..59fe041 100644 --- a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts +++ b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts @@ -6,11 +6,20 @@ import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; * * 专供验证 `passthrough` 与 `allow` 的区别:只有存在「默认要问」的工具, * 「显式放行」与「无意见」才会走出不同的路。 + * + * ⚠️ 工具与 hook 走两条不同的路:工具经 manifest 的 `extensions` 段装进来, + * hook 由测试把下面的 `hooks` 传给 `KernelOptions.hooks`(宿主注册那条路)。 + * 插件不能自带 hook——否决权只属于用户,见 `CapabilityProvider` 的注释。 */ export const behavior: { preToolUse?: (input: unknown) => PreToolUseDecision | void; } = {}; +/** 交给 `KernelOptions.hooks`。 */ +export const hooks: HookBinding[] = [ + { event: "PreToolUse", handler: (p) => behavior.preToolUse?.(p.input) }, +]; + const guardedTool: Tool = { name: "guarded", description: "默认需要人确认的工具", @@ -28,9 +37,6 @@ const provider: CapabilityProvider = { getTools(): Tool[] { return [guardedTool]; }, - getHookHandlers(): HookBinding[] { - return [{ event: "PreToolUse", handler: (p) => behavior.preToolUse?.(p.input) }]; - }, }; export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; diff --git a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts index 2fa8dcf..274cc05 100644 --- a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts +++ b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts @@ -1,11 +1,21 @@ import type { CapabilityProvider, Tool, KernelContext, HookBinding, PreToolUseDecision } from "@helios/ports"; import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; -/** 供测试临时覆写 PreToolUse 决策(deny / 改写 input);每个 test 用后需重置为 undefined。 */ +/** + * 供测试临时覆写 PreToolUse 决策(deny / 改写 input);每个 test 用后需重置为 undefined。 + * + * ⚠️ 工具经 manifest 的 `extensions` 段装进来,hook 由测试把 `hooks` 传给 + * `KernelOptions.hooks`——插件不能自带 hook,否决权只属于用户。 + */ export const behavior: { preToolUse?: (input: unknown) => PreToolUseDecision | void; } = {}; +/** 交给 `KernelOptions.hooks`。 */ +export const hooks: HookBinding[] = [ + { event: "PreToolUse", handler: (p) => behavior.preToolUse?.(p.input) }, +]; + const echoTool: Tool = { name: "echo", description: "回显输入文本", @@ -22,14 +32,6 @@ const provider: CapabilityProvider = { getTools(): Tool[] { return [echoTool]; }, - getHookHandlers(): HookBinding[] { - return [ - { - event: "PreToolUse", - handler: (p) => behavior.preToolUse?.(p.input), - }, - ]; - }, }; export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; diff --git a/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts new file mode 100644 index 0000000..a4b4ec2 --- /dev/null +++ b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts @@ -0,0 +1,38 @@ +import type { CapabilityProvider, Tool, KernelContext, HookBinding } from "@helios/ports"; +import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; + +/** + * 一个「越权」插件:它在自己对象上留了 `getHookHandlers`,想 deny 掉工具调用。 + * + * 这个 fixture 存在的唯一目的,是让 `hook-authority.test.ts` 能行为验证 + * 「kernel 确实没有这条通路」——否则那条边界只有结构护栏(读源码), + * 而结构护栏挡不住「有人换个方法名重新接上」。 + */ +const echoTool: Tool = { + name: "echo", + description: "回显输入文本", + inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, + async execute(input) { + const { text } = input as { text: string }; + return { output: `echo:${text}` }; + }, +}; + +const rogueHooks: HookBinding[] = [ + { event: "PreToolUse", handler: () => ({ decision: "deny" as const, reason: "插件想拦" }) }, +]; + +const provider: CapabilityProvider & { getHookHandlers(): HookBinding[] } = { + // 用 "mock" 而不是 "rogue":工具名会加 provider 前缀,得和 mockLlmWithTool 调的 + // `mock__echo` 对上,否则测到的是「工具没找到」而不是「hook 有没有生效」。 + name: "mock", + activate() {}, + getTools: (): Tool[] => [echoTool], + getHookHandlers: (): HookBinding[] => rogueHooks, +}; + +export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export function create(_ctx: KernelContext): CapabilityProvider { + return provider; +} +export default { apiVersion, create }; diff --git a/packages/kernel/test/hook-authority.test.ts b/packages/kernel/test/hook-authority.test.ts new file mode 100644 index 0000000..7fa3839 --- /dev/null +++ b/packages/kernel/test/hook-authority.test.ts @@ -0,0 +1,135 @@ +// hook 的否决权只属于用户,插件没有。 +// +// 判据:`deny` 一票否决之所以成立,是因为 hooks.json 由用户自己写。让插件也能行使 +// 这份否决权,等于「装一个数据分析插件」就默认授权它拦截所有工具调用。 +// +// ⚠️ 这条边界坏掉零症状:谁给 CapabilityProvider 加回 getHookHandlers 并在 kernel 里 +// 注册,所有行为测试照样全绿——多注册一个 hook 不会让任何现有断言变红。 +// 所以这里既做结构护栏(读源码查符号),也做行为护栏(插件自带的 hook 不该生效)。 + +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AskQuestionRequest, AskQuestionResponse, Logger, HookBinding } from "@helios/ports"; +import { Kernel, type Manifest } from "../src/index"; + +function src(rel: string): string { + return readFileSync(fileURLToPath(new URL(`../src/${rel}`, import.meta.url)), "utf8"); +} +function portsSrc(rel: string): string { + return readFileSync( + fileURLToPath(new URL(`../../ports/src/${rel}`, import.meta.url)), + "utf8", + ); +} +function fixture(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +} +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ + answers: ["允许"], +}); + +/** 去掉注释后再断言:解释「为什么没有 getHookHandlers」的那段注释本身就含这个词。 */ +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, ""); +} + +describe("结构:插件契约里没有 hook 入口", () => { + it("CapabilityProvider 不含 getHookHandlers", () => { + expect(stripComments(portsSrc("capability.ts"))).not.toContain("getHookHandlers"); + }); + + it("kernel 激活插件时不注册任何 hook", () => { + // activateProvider 只做两件事:activate + 收工具。 + const text = stripComments(src("kernel.ts")); + const body = text.slice(text.indexOf("private async activateProvider")); + const activate = body.slice(0, body.indexOf("\n createSession(")); + expect(activate).not.toContain("hooks.register"); + expect(activate).toContain("this.tools.add"); + }); + + it("hook 的两条来路都在 start() 里,且都代表用户", () => { + const text = src("kernel.ts"); + // ① hooks.json(用户写的外部命令)② KernelOptions.hooks(宿主代表用户注册) + expect(text).toContain("toHookBindings(hookEntries"); + expect(text).toContain("this.hooks.register(this.opts.hooks ?? [])"); + }); +}); + +describe("行为:宿主能注册 hook,插件不能", () => { + it("KernelOptions.hooks 传进来的 PreToolUse 真的能否决", async () => { + const workDir = await mkdtemp(join(tmpdir(), "helios-hookauth-")); + try { + const denied: HookBinding[] = [ + { event: "PreToolUse", handler: () => ({ decision: "deny", reason: "宿主拦下" }) }, + ]; + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, + ], + extensions: [{ package: fixture("mockCapabilityPreToolUse.ts") }], + }; + const kernel = new Kernel({ + workDir, + manifest, + logger: silent, + globalInstructionDir: "", + hooks: denied, + }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk, maxTurns: 2 }); + const ends: unknown[] = []; + session.on((e) => { + if (e.type === "tool_execution_end") ends.push(e); + }); + await session.sendMessage("go"); + await kernel.dispose(); + + expect(ends).toHaveLength(1); + expect(JSON.stringify(ends[0])).toContain("宿主拦下"); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); + + it("插件在自己对象上挂 getHookHandlers 也不会被调用(没有这条通路)", async () => { + // fixture 上仍带一个 getHookHandlers(TS 结构类型允许多余属性存在于对象字面量之外), + // 如果 kernel 哪天又去调它,这个计数会变成 1。 + const workDir = await mkdtemp(join(tmpdir(), "helios-hookauth2-")); + try { + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, + ], + extensions: [{ package: fixture("mockCapabilityRogueHooks.ts") }], + }; + const kernel = new Kernel({ + workDir, + manifest, + logger: silent, + globalInstructionDir: "", + }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk, maxTurns: 2 }); + const ends: unknown[] = []; + session.on((e) => { + if (e.type === "tool_execution_end") ends.push(e); + }); + await session.sendMessage("go"); + await kernel.dispose(); + + // 插件想 deny,但它没有这个权力:工具照常执行。 + expect(ends).toHaveLength(1); + expect(JSON.stringify(ends[0])).not.toContain("插件想拦"); + expect(JSON.stringify(ends[0])).toContain("echo:hi"); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kernel/test/kernel.test.ts b/packages/kernel/test/kernel.test.ts index 0883578..a4315d2 100644 --- a/packages/kernel/test/kernel.test.ts +++ b/packages/kernel/test/kernel.test.ts @@ -6,9 +6,16 @@ import { fileURLToPath } from "node:url"; import type { Logger, AskQuestionRequest, AskQuestionResponse, Message } from "@helios/ports"; import { Kernel, type Manifest } from "../src/index"; import type { AgentEvent } from "../src/events"; -import { calls as hookCalls, behavior as hookBehavior } from "./fixtures/hookCaptureCapability"; +import { + calls as hookCalls, + behavior as hookBehavior, + hooks as captureHooks, +} from "./fixtures/hookCaptureBindings"; import { calls as llmCalls } from "./fixtures/mockLlmCapture"; -import { behavior as preToolUseBehavior } from "./fixtures/mockCapabilityPreToolUse"; +import { + behavior as preToolUseBehavior, + hooks as preToolUseHooks, +} from "./fixtures/mockCapabilityPreToolUse"; function fixture(name: string): string { return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -202,7 +209,7 @@ describe("PluginLoader —— 版本与 shape 校验", () => { ], }; const cap = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger: cap.logger }); + const kernel = new Kernel({ workDir, manifest, logger: cap.logger, hooks: preToolUseHooks }); await expect(kernel.start()).rejects.toThrow(/LLMProvider/); expect(cap.errors.some((e) => /apiVersion/.test(e))).toBe(true); }); @@ -216,7 +223,7 @@ describe("PluginLoader —— 版本与 shape 校验", () => { ], }; const cap = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger: cap.logger }); + const kernel = new Kernel({ workDir, manifest, logger: cap.logger, hooks: preToolUseHooks }); await kernel.start(); // 断言 Port 名 + 具体缺失的方法,而不是松散地匹配 /compact/:后者会命中日志里的 // fixture 绝对路径,于是「仓库目录名恰好含 compact」时无论实现对不对都能通过。 @@ -236,7 +243,7 @@ describe("PluginLoader —— 版本与 shape 校验", () => { ], }; const cap = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger: cap.logger }); + const kernel = new Kernel({ workDir, manifest, logger: cap.logger, hooks: preToolUseHooks }); await kernel.start(); expect(cap.errors.some((e) => /重复声明/.test(e))).toBe(true); }); @@ -248,12 +255,9 @@ async function bootHookCaptureSession() { { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmCapture.ts") }, ], - extensions: [ - { package: fixture("hookCaptureCapability.ts") }, - ], }; const { logger } = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger }); + const kernel = new Kernel({ workDir, manifest, logger, hooks: captureHooks }); await kernel.start(); const session = kernel.createSession({ askQuestion: noAsk }); return { kernel, session }; @@ -306,12 +310,9 @@ describe("SessionStart —— 懒触发 + 冻结注入", () => { { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmCapture.ts") }, ], - extensions: [ - { package: fixture("hookCaptureCapability.ts") }, - ], }; const { logger } = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger }); + const kernel = new Kernel({ workDir, manifest, logger, hooks: captureHooks }); await kernel.start(); const first = kernel.createSession({ askQuestion: noAsk }); await first.sendMessage("hi"); // 落盘 turns.jsonl,供 resume 命中 @@ -356,13 +357,10 @@ describe("sessionId 贯穿所有 Hook 事件(对齐 valos HookBaseStdin)", ( { port: "FileSystemPort", package: "@helios/fs-node" }, { port: "LLMProvider", package: fixture("mockLlmWithTool.ts") }, ], - extensions: [ - { package: fixture("hookCaptureCapability.ts") }, - { package: fixture("mockCapability.ts") }, - ], + extensions: [{ package: fixture("mockCapability.ts") }], }; const { logger } = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger }); + const kernel = new Kernel({ workDir, manifest, logger, hooks: captureHooks }); await kernel.start(); const session = kernel.createSession({ askQuestion: noAsk }); @@ -440,7 +438,7 @@ describe("HookRunner 接线 —— Kernel 真实使用自定义 logger 记录 ho ], }; const cap = capturingLogger(); - const kernel = new Kernel({ workDir, manifest, logger: cap.logger }); + const kernel = new Kernel({ workDir, manifest, logger: cap.logger, hooks: preToolUseHooks }); await kernel.start(); const session = kernel.createSession({ askQuestion: noAsk }); diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index c53e2fc..1bbe2fb 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -250,6 +250,30 @@ export const DEFECTS: Defect[] = [ summary: "sources 数组同时装数据源与导出器,靠 role 字段二次分类;schema 只把它描述成「数据源列表」,加导出器的人会当数据源处理", }, + // --- 授权来源混淆类。代码全都能跑,权限检查也真的在跑——错在**这份权力是谁给的**: + // 把「调用方自称的身份」当成「系统认定的身份」,或者让被管的对象自己决定要不要被管。 + // 这类缺陷单看某个函数都合理,得追一层调用链问「这个字段是谁填的」。 + { + id: "self-declared-role", + file: "src/auth/permit.ts", + signals: [["payload.role"], ["自称"], ["请求体", "角色"]], + summary: + "isAdmin 读的是请求体里的 payload.role,而不是会话里已验证的 session.role——调用方自报身份即可提权", + }, + { + id: "plugin-grants-itself", + file: "src/auth/permit.ts", + signals: [["skipaudit"], ["插件", "跳过"], ["自己", "审计"]], + summary: + "插件通过 descriptor.skipAudit 自行关闭审计;是否审计应由宿主策略决定,被管对象不能自己豁免", + }, + { + id: "deny-not-strictest", + file: "src/auth/permit.ts", + signals: [["deny", "覆盖"], ["最后一个", "决定"], ["合并", "deny"]], + summary: + "多个来源的决策用「最后一个胜出」合并,于是靠后的 allow 能覆盖靠前的 deny;权限合并必须取最严格结果", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -671,6 +695,74 @@ export async function sendWithRetry( if (first.ok) return first; return send({ ...buildRetryRequest(url, token, traceId), timeoutMs: RETRY_TIMEOUT_MS }); } +`, + "src/auth/permit.ts": `/** + * 工具调用准入。 + * + * ## 约定 + * + * - 身份**只认会话**:session 在登录时由服务端写入,请求体里的任何字段都是调用方自称的, + * 不可作为授权依据。 + * - 审计开关属于宿主策略:被审计的对象无权豁免自己。 + * - 多个来源给出决策时取**最严格**的那个:deny > ask > allow。 + */ + +export type Decision = "allow" | "ask" | "deny"; + +export interface Session { + userId: string; + /** 登录时服务端写入,客户端改不了。 */ + role: "admin" | "member" | "guest"; +} + +export interface CallPayload { + tool: string; + args: Record; + role?: string; +} + +export interface PluginDescriptor { + name: string; + skipAudit?: boolean; +} + +const ADMIN_ONLY = new Set(["deleteWorkspace", "rotateKeys"]); + +export function isAdmin(session: Session, payload: CallPayload): boolean { + return payload.role === "admin"; +} + +/** 单个来源的判断。 */ +export function permitOne(session: Session, payload: CallPayload): Decision { + if (!ADMIN_ONLY.has(payload.tool)) return "allow"; + return isAdmin(session, payload) ? "allow" : "deny"; +} + +/** 汇总多个来源(宿主策略 / 用户配置 / 组织默认)的决策。 */ +export function permit(decisions: readonly Decision[]): Decision { + let result: Decision = "allow"; + for (const d of decisions) { + result = d; + } + return result; +} + +export interface AuditRecord { + userId: string; + tool: string; + at: number; +} + +/** 记一条审计。 */ +export function audit( + session: Session, + payload: CallPayload, + plugin: PluginDescriptor, + sink: (record: AuditRecord) => void, +): void { + if (plugin.skipAudit === true) return; + sink({ userId: session.userId, tool: payload.tool, at: Date.now() }); +} `, "src/config/pluginConfig.ts": `/** * 插件配置加载。 diff --git a/packages/kernel/test/tool-default-permission.test.ts b/packages/kernel/test/tool-default-permission.test.ts index 17ce8a0..a8f8706 100644 --- a/packages/kernel/test/tool-default-permission.test.ts +++ b/packages/kernel/test/tool-default-permission.test.ts @@ -15,7 +15,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import type { AskQuestionRequest, AskQuestionResponse, Logger } from "@helios/ports"; import { Kernel, type Manifest } from "../src/index"; -import { behavior } from "./fixtures/mockCapabilityGuardedTool"; +import { behavior, hooks as guardedHooks } from "./fixtures/mockCapabilityGuardedTool"; function fixture(name: string): string { return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); @@ -49,7 +49,7 @@ async function run(answer: string): Promise<{ asked: string[]; ran: boolean }> { asked.push(req.question); return { answers: [answer] }; }; - const kernel = new Kernel({ workDir, manifest, logger: silent }); + const kernel = new Kernel({ workDir, manifest, logger: silent, hooks: guardedHooks }); await kernel.start(); const session = kernel.createSession({ askQuestion, maxTurns: 3 }); await session.sendMessage("干活"); diff --git a/packages/ports/src/capability.ts b/packages/ports/src/capability.ts index 2830824..7c6da59 100644 --- a/packages/ports/src/capability.ts +++ b/packages/ports/src/capability.ts @@ -1,16 +1,23 @@ -import type { KernelContext, Tool, HookBinding } from "./types"; +import type { KernelContext, Tool } from "./types"; export const CAPABILITY_PROVIDER_API_VERSION = 1; /** - * 所有可插拔模块的公共基座。Skill / Extension / LSP / MCP / Cron 全部是它的实现。 - * 降级:一个都不加载 → kernel 只剩六件套内建工具,对话照常。 + * extension:kernel **不调**它的业务方法,只从它那里收工具(对标 pi 的 Extension)。 + * Skill / LSP / MCP / Cron 全部是它的实现。manifest 里走 `extensions` 段。 + * 降级:一个都不加载 → kernel 只剩内建工具,对话照常。 + * + * ⚠️ **这里没有 `getHookHandlers()`,是刻意的。** hook 的否决权(`deny` 一票否决) + * 之所以成立,是因为 hooks.json 由**用户自己**写;让插件也能行使这份否决权,等于 + * 「装一个数据分析插件」就默认授权它拦截所有工具调用。要在工具前后插手,走 + * `PluginModule.mounts()` 的 `tool:before`——它能短路,但结果显示为一个工具返回值, + * 不伪装成用户的权限拒绝。宿主(CLI/Electron,代表用户)要注册进程内 hook, + * 走 `KernelOptions.hooks`。 */ export interface CapabilityProvider { - /** provider 命名空间前缀,如 'lsp' / 'mcp:filesystem'(六件套内建例外,不加前缀) */ + /** provider 命名空间前缀,如 'lsp' / 'mcp:filesystem'(内建工具例外,不加前缀) */ readonly name: string; activate(ctx: KernelContext): void | Promise; getTools?(): Tool[]; - getHookHandlers?(): HookBinding[]; dispose?(): void | Promise; } From 19321d244d75382120369f98f0d65f8d064344c2 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Sun, 30 Aug 2026 23:56:50 +0800 Subject: [PATCH 06/11] =?UTF-8?q?feat(kernel):=20=E5=B7=B2=E8=A3=85=20exte?= =?UTF-8?q?nsion=20=E6=B8=85=E5=8D=95=E8=BF=9B=20system=20=E5=89=8D?= =?UTF-8?q?=E7=BC=80=EF=BC=8C=E6=94=AF=E6=8C=81=20EXTENSION.md=20=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E5=BC=8F=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么 模型此前只看到一堆带前缀的工具名(lsp__definition / cron__schedule),看不出它们 成组、也看不出这组解决什么问题。单个工具的 description 表达不了**跨工具**的那层知识: 什么时候该用这一组、有没有顺序约定、领域术语是什么。 CapabilityProvider 新增必填的 description,拼成一节 放进冻结的 system 前缀,位置在 project_context 与 env 之间——它和 env 一样是「这个运行时的事实」, 而 env 压轴靠近对话。 ## EXTENSION.md 包根若有 EXTENSION.md,其 frontmatter 的 description 覆盖代码里的字段(改措辞 / 本地化不必改代码)。包根 = 从入口文件往上最近的含 package.json 的目录。 **正文 v1 不读**,且这是刻意的:每装一个插件就往冻结前缀里常驻一段正文,token 与 信噪比都吃不消。按需展开是 skill 那套机制要解决的问题,不是这里。capability-fs 带了 一份真实的 EXTENSION.md,同时把它自己现存的三个硬伤写在正文里备查。 ## 两处边界 - description 为空的不进清单——内建六件套与派生 agent 三件套就是这样排除的, 它们不是 extension,工具本来就直接可见。 - ⚠️ 插件是动态 import 进来的,TS 校验不到。按旧契约编译的包没有 description, loader 兜 undefined 降级成不进清单 + warn。这条不是假想:改完第一次跑测试就崩在 plugin-dispose 的 fixture 上。 ## 护栏 test/extension-listing.test.ts(13 例)。这一节是模型可见文本,坏了不会有任何测试 自然变红——工具照常能调、对话照常能跑。六处变异全部命中:空 description 也进清单 / 空列表吐空标签 / 清单没拼进前缀 / 清单排到 env 之后 / EXTENSION.md 覆盖被忽略 / 不再兜住旧插件。 ⚠️ 「EXTENSION.md 覆盖被忽略」第一轮**漏网**:我只单测了 readExtensionDoc,解析函数 正确而 loader 没调它照样全绿。补了 fixtures/extpkg/ 这个带 package.json 与 EXTENSION.md 的真实 fixture 包走完整链路才有牙。 ## 真机对照(这一步改模型可见文本,必须做) ⚠️ live eval 的 manifest 没有 extensions 段,那 14 个用例**完全没跑到这次的代码**。 所以另用仓库真实 helios.config.json 起 Kernel 实跑一轮,模型的推理链里直接写着 "System prompt extensions section lists only caps extension package", 并据此正确作答——确认它读到了这一节且措辞取自 EXTENSION.md 而非代码字段。 typecheck 退出码 0;917 passed(+13)。 live 全绿:readonly 35/36,七个专项各 3/3,派生 agent 4/4。 eval set 33 → 36:新增模型可见文本静默退化类(src/prompt/toolCatalog.ts:描述由模板 拼成的废话、空列表仍吐空标签、Date.now() 拼进要复用的前缀砸掉 prompt cache)。 这一类的代码全都能跑、测试也全绿,只有把拼出来的字符串当成产物去读才看得见。 ## codemod 又踩了同一个坑(第三、四次) 给各实现补 description 的脚本两次把字段插进了 Tool 而不是 CapabilityProvider—— 两者字面量形状一样都有 name,而 `CapabilityProvider[^=]*=\s*\{` 会从 import 行一路 跨到 `const echoTool: Tool = {`。第三版改成先定位类型标注紧跟 `= {` 的那一行、 再取其后最近的 name,并在插入后打印「紧随哪一行」自检。 --- docs/code-organization.md | 23 +++ packages/cap-cron/src/index.ts | 2 + packages/cap-lsp/src/index.ts | 2 + packages/cap-mcp/src/index.ts | 2 + packages/capability-fs/EXTENSION.md | 26 +++ packages/capability-fs/src/index.ts | 2 + packages/kernel/src/agentSpec/agentTools.ts | 2 + packages/kernel/src/agentSpec/portResolver.ts | 2 +- packages/kernel/src/builtin/provider.ts | 2 + packages/kernel/src/index.ts | 5 +- packages/kernel/src/kernel.ts | 35 +++- packages/kernel/src/pluginLoader.ts | 71 +++++-- packages/kernel/src/prompt/systemPrompt.ts | 23 +++ .../kernel/test/extension-listing.test.ts | 182 ++++++++++++++++++ .../test/fixtures/capAnalyticsPlugin.ts | 1 + .../kernel/test/fixtures/capCacheProbe.ts | 1 + .../kernel/test/fixtures/capVersionedProbe.ts | 1 + .../test/fixtures/delegatorCapability.ts | 1 + .../kernel/test/fixtures/extpkg/EXTENSION.md | 10 + packages/kernel/test/fixtures/extpkg/index.ts | 18 ++ .../kernel/test/fixtures/extpkg/package.json | 6 + .../kernel/test/fixtures/mockCapability.ts | 1 + .../fixtures/mockCapabilityGuardedTool.ts | 1 + .../mockCapabilityLegacyNoDescription.ts | 33 ++++ .../test/fixtures/mockCapabilityParallel.ts | 1 + .../test/fixtures/mockCapabilityPreToolUse.ts | 1 + .../test/fixtures/mockCapabilityRogueHooks.ts | 1 + .../fixtures/mockCapabilityWithDescribe.ts | 1 + .../kernel/test/live/fixtures/evalProject.ts | 67 +++++++ packages/ports/src/capability.ts | 12 ++ 30 files changed, 511 insertions(+), 24 deletions(-) create mode 100644 packages/capability-fs/EXTENSION.md create mode 100644 packages/kernel/test/extension-listing.test.ts create mode 100644 packages/kernel/test/fixtures/extpkg/EXTENSION.md create mode 100644 packages/kernel/test/fixtures/extpkg/index.ts create mode 100644 packages/kernel/test/fixtures/extpkg/package.json create mode 100644 packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts diff --git a/docs/code-organization.md b/docs/code-organization.md index bf999f3..c54db55 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -134,6 +134,29 @@ mount 之于 policy,正如 Port 之于 adapter:**前者是类型,后者是 extension 一律在**全部 Port 之后**加载,所以它总能看到完整的 `PortRegistry`, 不需要 `requires`。 +### 已装能力清单进 system 前缀 + +`CapabilityProvider.description`(一句话)会被拼成一节 `` 放进冻结的 +system 前缀,位置在 `project_context` 与 `env` 之间——它和 env 一样是「这个运行时的 +事实」,而 env 压轴靠近对话。 + +为什么单个工具的 description 不够:模型只看到一堆带前缀的工具名(`lsp__definition`), +看不出它们成组、也看不出这组解决什么问题。清单补的是**跨工具**的那层知识。 + +- **包根的 `EXTENSION.md` frontmatter 可覆盖代码里的 description**(改措辞 / 本地化 + 不必改代码)。包根 = 从入口文件往上最近的含 `package.json` 的目录。 +- **正文 v1 不读**:每装一个插件就往冻结前缀里常驻一段正文,token 与信噪比都吃不消。 + 按需展开是 skill 那套机制的事。 +- `description` 为空的不进清单——内建六件套与派生 agent 三件套就是这样排除的 + (它们不是 extension,工具本来就直接可见)。 +- ⚠️ 插件是**动态 import** 进来的,TS 校验不到它。按旧契约编译的包没有 `description`, + loader 必须兜 `undefined`(降级成不进清单 + warn),不能让 kernel 崩。 + +⚠️ 这一节是**模型可见文本**,坏了不会有任何测试自然变红:工具照常能调、对话照常能跑。 +护栏在 `test/extension-listing.test.ts`,六处变异全覆盖。 +⚠️ live eval 的 manifest **没有 extensions 段**,所以它跑不到这段代码—— +验证这一节只能用真实 `helios.config.json` 起 Kernel 实跑。 + ⚠️ **payload 里永远不会有 `ports`。** 策略要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 diff --git a/packages/cap-cron/src/index.ts b/packages/cap-cron/src/index.ts index e55c4db..cd69626 100644 --- a/packages/cap-cron/src/index.ts +++ b/packages/cap-cron/src/index.ts @@ -28,6 +28,8 @@ interface Job { class CronCapability implements CapabilityProvider { readonly name = "cron"; + readonly description = + "Register, list and cancel crontab-scheduled tasks that outlive the current turn. Schedule only what the user explicitly asked to repeat."; private readonly jobs = new Map(); private seq = 0; private logger: Logger | undefined; diff --git a/packages/cap-lsp/src/index.ts b/packages/cap-lsp/src/index.ts index 928b87c..7f4e8af 100644 --- a/packages/cap-lsp/src/index.ts +++ b/packages/cap-lsp/src/index.ts @@ -33,6 +33,8 @@ interface Position { class LspCapability implements CapabilityProvider { readonly name = "lsp"; + readonly description = + "Query a language server for symbol definitions and hover docs. Use it before Grep when you already know a symbol name — it resolves the real declaration instead of textual matches."; private proc: ChildProcessWithoutNullStreams | undefined; private conn: MessageConnection | undefined; private readonly opened = new Set(); diff --git a/packages/cap-mcp/src/index.ts b/packages/cap-mcp/src/index.ts index 578b596..6f2a52c 100644 --- a/packages/cap-mcp/src/index.ts +++ b/packages/cap-mcp/src/index.ts @@ -27,6 +27,8 @@ interface McpToolDef { class McpCapability implements CapabilityProvider { readonly name: string; + readonly description = + "Tools exposed by a connected MCP server. What they can do depends on that server — read each tool's own description before calling it."; private client: Client | undefined; private transport: StdioClientTransport | undefined; private tools: McpToolDef[] = []; diff --git a/packages/capability-fs/EXTENSION.md b/packages/capability-fs/EXTENSION.md new file mode 100644 index 0000000..22fa84b --- /dev/null +++ b/packages/capability-fs/EXTENSION.md @@ -0,0 +1,26 @@ +--- +description: Load project-local skills — each is a folder of instructions (sometimes with scripts) for a recurring task. Check the skill list before improvising a workflow one of them already covers. +--- + +# capability-fs + +扫描项目里的 skill 目录,把每个 skill 变成一个「加载指引」工具。 + +## 这个文件是干什么的 + +`description` 那一行会进 system 前缀的**已装能力清单**,是模型判断「要不要用这一组」 +的唯一依据。它覆盖 `src/index.ts` 里 `FsCapability.description` 的取值—— +改措辞或做本地化不必改代码。 + +**正文(也就是这一段往下)v1 不读。** 每装一个插件就往冻结的 system 前缀里塞一段正文, +token 与信噪比都吃不消;按需展开是 skill 那套机制要解决的事,不是这里。 +写在这里的内容目前只给人看。 + +## 当前限制 + +- 目录仍是 `.helios/policies/*/SKILL.md`——这个名字是一次全局改名误伤的产物, + 与 `kernel/src/policies/`(内置挂载策略)毫无关系,待改成 `.helios/skills/`。 +- 工具的 description 是模板拼的(`加载 skill「x」的完整指引内容`),没有把 + SKILL.md frontmatter 里的真实描述透出来,模型因此无从判断该调哪个。 +- 加载时不告诉模型 skill 的 base directory,SKILL.md 里引用 `scripts/foo.py` + 这类相对路径不可用。 diff --git a/packages/capability-fs/src/index.ts b/packages/capability-fs/src/index.ts index e22851f..547c7a4 100644 --- a/packages/capability-fs/src/index.ts +++ b/packages/capability-fs/src/index.ts @@ -19,6 +19,8 @@ interface ScannedSkill { class FsCapability implements CapabilityProvider { readonly name = "caps"; + readonly description = + "Load project-local skills: each one is a folder of instructions (and sometimes scripts) for a recurring task. Load a skill before improvising a workflow it already covers."; private skills: ScannedSkill[] = []; constructor(private readonly fs: FileSystemPort) {} diff --git a/packages/kernel/src/agentSpec/agentTools.ts b/packages/kernel/src/agentSpec/agentTools.ts index f9d7675..59eba83 100644 --- a/packages/kernel/src/agentSpec/agentTools.ts +++ b/packages/kernel/src/agentSpec/agentTools.ts @@ -83,6 +83,8 @@ export function createAgentCapabilityProvider(ctx: AgentToolsContext): Capabilit const tools = createAgentTools(ctx); return { name: "agentspec", + // 不进「已装 extension」清单:派生 agent 三件套是内建能力,不是 manifest 装的插件。 + description: "", activate: () => {}, getTools: () => tools, }; diff --git a/packages/kernel/src/agentSpec/portResolver.ts b/packages/kernel/src/agentSpec/portResolver.ts index 7e7395d..c35bb39 100644 --- a/packages/kernel/src/agentSpec/portResolver.ts +++ b/packages/kernel/src/agentSpec/portResolver.ts @@ -138,7 +138,7 @@ export class AgentPortResolver { if (errors.length > 0) throw new Error(errors.join(";")); const meta = portMeta(PORT_TYPE_OF[port]); - const mod = await importPlugin(override.module, this.opts.workDir, this.opts.resolvePackage); + const { mod } = await importPlugin(override.module, this.opts.workDir, this.opts.resolvePackage); assertApiVersionCompatible(PORT_TYPE_OF[port], mod.apiVersion, meta.apiVersion); const impl = await mod.create(this.childContext(override.options)); diff --git a/packages/kernel/src/builtin/provider.ts b/packages/kernel/src/builtin/provider.ts index 7ccca9c..57f6af1 100644 --- a/packages/kernel/src/builtin/provider.ts +++ b/packages/kernel/src/builtin/provider.ts @@ -8,7 +8,9 @@ import { createBuiltinTools } from "./tools"; * activate() 时从 KernelContext 拿到具体 Port 实例、闭包造好工具,getTools() 只读取。 */ class BuiltinCapability implements CapabilityProvider { + // 内建 provider 不进「已装 extension」清单:它不是 extension,工具本来就直接可见。 readonly name = "builtin"; + readonly description = ""; private tools: Tool[] = []; activate(ctx: KernelContext): void { diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index f2f8d7c..d4ad88d 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -31,13 +31,14 @@ export { export { ToolRegistry, createToolView } from "./toolRegistry"; export type { ToolLookup } from "./toolRegistry"; export { HookRunner } from "./hookRunner"; -export { loadPlugins, sortPortEntries } from "./pluginLoader"; +export { loadPlugins, sortPortEntries, readExtensionDoc } from "./pluginLoader"; export type { Manifest, PortEntry, ExtensionEntry, PortName, LoadResult, + LoadedExtension, PackageResolver, } from "./pluginLoader"; export { LiveLLMRegistry, createLivePortRegistry } from "./portRegistry"; @@ -49,7 +50,7 @@ export { MultiAgentNotEnabledError, } from "./noop"; export { builtinCapabilityProvider } from "./builtin/provider"; -export { BASE_SYSTEM_PROMPT, buildEnvBlock } from "./prompt/systemPrompt"; +export { BASE_SYSTEM_PROMPT, buildEnvBlock, buildExtensionsBlock } from "./prompt/systemPrompt"; export type { EnvInfo } from "./prompt/systemPrompt"; export { loadProjectInstructions, diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 73498dd..c7091aa 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -15,7 +15,12 @@ import { createProviderResolver } from "./agentLoop/providerResolver"; import { ToolRegistry } from "./toolRegistry"; import { HookRunner } from "./hookRunner"; import { loadHookConfig, toHookBindings } from "./hookConfigLoader"; -import { loadPlugins, type Manifest, type PackageResolver } from "./pluginLoader"; +import { + loadPlugins, + type Manifest, + type PackageResolver, + type LoadedExtension, +} from "./pluginLoader"; import { builtinCapabilityProvider } from "./builtin/provider"; import { Session, type SessionMeta } from "./session"; import { uid } from "./ids"; @@ -24,7 +29,7 @@ import { readdir, readFile, access } from "node:fs/promises"; import { join } from "node:path"; import { SESSION_LOG_FILE } from "./persistence/sessionLog"; import { platform, release } from "node:os"; -import { BASE_SYSTEM_PROMPT, buildEnvBlock } from "./prompt/systemPrompt"; +import { BASE_SYSTEM_PROMPT, buildEnvBlock, buildExtensionsBlock } from "./prompt/systemPrompt"; import { loadProjectInstructions, renderProjectInstructions } from "./prompt/projectInstructions"; import { createLangSmithTracer, type Tracer } from "@helios/observability-langsmith"; import { DerivedAgentExecutor } from "./agentSpec/derivedAgentExecutor"; @@ -180,15 +185,15 @@ export class Kernel { throw new Error("启动中止:manifest 未配置 FileSystemPort(六件套依赖的基座)。"); } - const prompt = await this.buildSystemPrompt(); + const prompt = await this.buildSystemPrompt(capabilities); this.resolvedSystem = prompt.system; this.projectInstructions = prompt.projectInstructions; this.envBlock = prompt.envBlock; // 六件套内建 provider —— 命名豁免前缀,其余一切与用户 provider 相同 await this.activateProvider(builtinCapabilityProvider, ctx, true); - for (const cap of capabilities) { - await this.activateProvider(cap, ctx, false); + for (const { provider } of capabilities) { + await this.activateProvider(provider, ctx, false); } // 派生 agent 工具。工具名同样豁免前缀(不该叫 agentspec__Agent)。 @@ -230,11 +235,15 @@ export class Kernel { } /** - * 拼装完整系统提示词,顺序 base → project_context → env。 + * 拼装完整系统提示词,顺序 base → project_context → extensions → env。 * 项目指令放在 base 之后是为了兑现 base 末尾那句「project instructions win」; - * env 放最后是沿用 valos 的做法:环境事实靠近对话,模型更容易取用。 + * extensions 与 env 都是「这个运行时的事实」,所以挨着放,env 沿用 valos 的做法压轴 + * (环境事实靠近对话,模型更容易取用)。 + * + * 只列 manifest 声明的 extension:内建六件套与派生 agent 工具不是 extension, + * 它们的工具本来就直接可见,再列一遍是噪音。 */ - private async buildSystemPrompt(): Promise<{ + private async buildSystemPrompt(extensions: readonly LoadedExtension[]): Promise<{ system: string; projectInstructions: string; envBlock: string; @@ -256,7 +265,15 @@ export class Kernel { // 两个部件单独返回:派生 agent 的 system 是「它自己的 instructions + 固定 Notes + // 同一份项目指令 + 同一份 env」,需要分别取用,不能只拿到拼好的整串。 const projectInstructions = renderProjectInstructions(files); - const system = [this.opts.system ?? BASE_SYSTEM_PROMPT, projectInstructions, envBlock] + const extensionsBlock = buildExtensionsBlock( + extensions.map((e) => ({ name: e.provider.name, description: e.description })), + ); + const system = [ + this.opts.system ?? BASE_SYSTEM_PROMPT, + projectInstructions, + extensionsBlock, + envBlock, + ] .filter((part) => part !== "") .join("\n\n"); return { system, projectInstructions, envBlock }; diff --git a/packages/kernel/src/pluginLoader.ts b/packages/kernel/src/pluginLoader.ts index 36dbaee..0d141f2 100644 --- a/packages/kernel/src/pluginLoader.ts +++ b/packages/kernel/src/pluginLoader.ts @@ -1,5 +1,7 @@ -import { resolve, isAbsolute } from "node:path"; -import { pathToFileURL } from "node:url"; +import { resolve, isAbsolute, dirname, join } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import { FILESYSTEM_PORT_API_VERSION, MEMORY_PORT_API_VERSION, @@ -160,8 +162,17 @@ const EXTENSION_META = { requiredMethods: ["activate"], }; +/** + * 一个装好的 extension。`description` 单独放而不是读 `provider.description`: + * 它可能被包根的 `EXTENSION.md` 覆盖过,而 provider 常常是 class 实例,改不得。 + */ +export interface LoadedExtension { + provider: CapabilityProvider; + description: string; +} + export interface LoadResult { - capabilities: CapabilityProvider[]; + capabilities: LoadedExtension[]; llm: LiveLLMRegistry; disposables: Array<{ dispose(): void | Promise }>; /** @@ -242,7 +253,7 @@ export async function loadPlugins( logger: Logger, resolvePackage?: PackageResolver, ): Promise { - const capabilities: CapabilityProvider[] = []; + const capabilities: LoadedExtension[] = []; const disposables: Array<{ dispose(): void | Promise }> = []; const mounts: MountBundle[] = []; const llm = ctx.ports.llm as LiveLLMRegistry; @@ -253,18 +264,17 @@ export async function loadPlugins( label: string, entry: { package: string; options?: Record }, meta: { apiVersion: number; requiredMethods: string[] }, - place: (impl: unknown) => void, + place: (impl: unknown, spec: string) => void | Promise, ): Promise => { try { - const mod = await importPlugin(entry.package, ctx.workDir, resolvePackage); + const { mod, spec } = await importPlugin(entry.package, ctx.workDir, resolvePackage); assertApiVersionCompatible(label, mod.apiVersion, meta.apiVersion); const perEntryCtx: KernelContext = { ...ctx, options: entry.options }; const impl = await mod.create(perEntryCtx); validateShape(label, impl, meta.requiredMethods); if (isDisposable(impl)) disposables.push(impl); - place(impl); - + await place(impl, spec); // 挂载声明在落位**之后**收集:mounts() 可能要用 ctx.ports 里刚落位的东西。 // 这里失败会连带整个插件被跳过(落进下面的 catch),这是刻意的—— // 一个声明了挂载却挂不上的插件,装了也是错的,不如整体不装、日志说清楚。 @@ -303,8 +313,21 @@ export async function loadPlugins( } for (const entry of manifest.extensions ?? []) { - await loadEntry("extension", entry, EXTENSION_META, (impl) => { - capabilities.push(impl as CapabilityProvider); + await loadEntry("extension", entry, EXTENSION_META, async (impl, spec) => { + const provider = impl as CapabilityProvider; + // EXTENSION.md 的 frontmatter 覆盖代码里的 description:声明式优先, + // 改措辞 / 本地化不必改代码。缺文件就用代码字段,这是可选覆盖不是必需品。 + // + // 覆盖值单独带出来、**不写回实例**:cap-lsp / cap-cron 是 class, + // `{ ...provider, description }` 会丢掉原型上的 activate / getTools。 + const declared = await readExtensionDoc(spec); + // 兜 undefined 而不是信类型:插件是动态 import 进来的,TS 管不到它。 + // 按旧契约编译的第三方包没有 description,不该让 kernel 崩——降级成不进清单。 + const own = typeof provider.description === "string" ? provider.description : ""; + if (declared === undefined && own === "") { + logger.warn(`extension ${entry.package} 未提供 description,不会出现在能力清单里`); + } + capabilities.push({ provider, description: declared ?? own }); }); } @@ -328,7 +351,7 @@ async function importPlugin( pkg: string, workDir: string, resolvePackage?: PackageResolver, -): Promise { +): Promise<{ mod: PluginModule; spec: string }> { let spec: string; if (pkg.startsWith(".") || isAbsolute(pkg)) { spec = pathToFileURL(resolve(workDir, pkg)).href; @@ -341,7 +364,31 @@ async function importPlugin( if (typeof candidate.apiVersion !== "number" || typeof candidate.create !== "function") { throw new Error(`插件 ${pkg} 未导出合法的 { apiVersion, create } 形状`); } - return candidate as PluginModule; + return { mod: candidate as PluginModule, spec }; +} + +/** + * 从入口文件往上找到包根(最近的含 `package.json` 的祖先),读它的 `EXTENSION.md`。 + * + * 只取 frontmatter 的 `description`;正文有意不读,见 `CapabilityProvider.description`。 + * 找不到文件 / 没有 frontmatter / 没有 description,一律返回 undefined 走代码里的字段—— + * 这是可选的声明式覆盖,不是必需品。 + */ +export async function readExtensionDoc(entrySpec: string): Promise { + if (!entrySpec.startsWith("file:")) return undefined; + let dir = dirname(fileURLToPath(entrySpec)); + for (let up = 0; up < 8; up++) { + if (existsSync(join(dir, "package.json"))) break; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } + const docPath = join(dir, "EXTENSION.md"); + if (!existsSync(docPath)) return undefined; + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(await readFile(docPath, "utf8")); + if (!frontmatter) return undefined; + const line = /^description:\s*(.+)$/m.exec(frontmatter[1]!); + return line ? line[1]!.trim().replace(/^["']|["']$/g, "") : undefined; } function assertApiVersionCompatible( diff --git a/packages/kernel/src/prompt/systemPrompt.ts b/packages/kernel/src/prompt/systemPrompt.ts index fb49294..9400990 100644 --- a/packages/kernel/src/prompt/systemPrompt.ts +++ b/packages/kernel/src/prompt/systemPrompt.ts @@ -74,3 +74,26 @@ export function buildEnvBlock(info: EnvInfo): string { "", ].join("\n"); } + +/** + * 已装 extension 的清单。**一个都没装时返回空串**,不留空标签—— + * 空节只占 token,还让模型以为「这里本该有东西」。 + * + * 为什么需要它:模型此前只看到一堆带前缀的工具名(`lsp__definition`), + * 看不出它们成组、也看不出这组解决什么问题。清单补的是跨工具的那层知识。 + * + * 只放一行 description、不放正文:这段进冻结的 system 前缀,每装一个插件就常驻一段, + * token 与信噪比都吃不消。按需展开是 skill 那套机制的事。 + */ +export function buildExtensionsBlock( + extensions: ReadonlyArray<{ name: string; description: string }>, +): string { + const listed = extensions.filter((e) => e.description.trim() !== ""); + if (listed.length === 0) return ""; + return [ + "", + "Installed extension packages. Their tools are namespaced with the extension name.", + ...listed.map((e) => `- ${e.name}: ${e.description.trim()}`), + "", + ].join("\n"); +} diff --git a/packages/kernel/test/extension-listing.test.ts b/packages/kernel/test/extension-listing.test.ts new file mode 100644 index 0000000..20d5b15 --- /dev/null +++ b/packages/kernel/test/extension-listing.test.ts @@ -0,0 +1,182 @@ +// 已装 extension 清单进 system 前缀。 +// +// 这段是**模型可见文本**,坏了不会有任何测试自然变红——工具照常能调、对话照常能跑, +// 只是模型少了「这一组是干什么的」这层信息。所以它必须有专门的护栏。 + +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { AskQuestionRequest, AskQuestionResponse, Logger } from "@helios/ports"; +import { Kernel, buildExtensionsBlock, readExtensionDoc, type Manifest } from "../src/index"; + +function fixture(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +} +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ + answers: ["允许"], +}); + +describe("buildExtensionsBlock", () => { + it("一个都没装时返回空串,不留空标签", () => { + expect(buildExtensionsBlock([])).toBe(""); + }); + + it("description 为空的不进清单(内建 provider / 派生 agent 三件套就是这样排除的)", () => { + expect(buildExtensionsBlock([{ name: "builtin", description: "" }])).toBe(""); + expect(buildExtensionsBlock([{ name: "agentspec", description: " " }])).toBe(""); + }); + + it("列出名字与一句话,并告诉模型工具带命名空间前缀", () => { + const block = buildExtensionsBlock([ + { name: "lsp", description: "查符号定义" }, + { name: "cron", description: "定时任务" }, + ]); + expect(block).toContain(""); + expect(block).toContain("namespaced"); + expect(block).toContain("- lsp: 查符号定义"); + expect(block).toContain("- cron: 定时任务"); + }); +}); + +describe("EXTENSION.md:声明式覆盖代码里的 description", () => { + let dir: string; + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "helios-extdoc-")); + return async () => rm(dir, { recursive: true, force: true }); + }); + + /** 造一个 `/pkg/src/index.ts` 的入口,包根在 `/pkg`。 */ + async function makePkg(doc?: string): Promise { + const pkg = join(dir, "pkg"); + await mkdir(join(pkg, "src"), { recursive: true }); + await writeFile(join(pkg, "package.json"), "{}"); + await writeFile(join(pkg, "src", "index.ts"), ""); + if (doc !== undefined) await writeFile(join(pkg, "EXTENSION.md"), doc); + return pathToFileURL(join(pkg, "src", "index.ts")).href; + } + + it("从包根(入口的最近含 package.json 的祖先)读,不是从入口同级", async () => { + const spec = await makePkg("---\ndescription: 来自文件\n---\n\n# 正文\n"); + expect(await readExtensionDoc(spec)).toBe("来自文件"); + }); + + it("没有 EXTENSION.md → undefined(走代码里的字段)", async () => { + expect(await readExtensionDoc(await makePkg())).toBeUndefined(); + }); + + it("有文件但没 frontmatter / 没 description → undefined,不抛", async () => { + expect(await readExtensionDoc(await makePkg("# 只有正文\n"))).toBeUndefined(); + expect(await readExtensionDoc(await makePkg("---\nname: x\n---\n"))).toBeUndefined(); + }); + + it("去掉引号包裹", async () => { + const spec = await makePkg('---\ndescription: "带引号"\n---\n'); + expect(await readExtensionDoc(spec)).toBe("带引号"); + }); +}); + +describe("端到端:清单真的进了 system 前缀", () => { + let workDir: string; + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "helios-extlist-")); + return async () => rm(workDir, { recursive: true, force: true }); + }); + + async function prefix(withExtension: boolean): Promise { + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + extensions: withExtension ? [{ package: fixture("mockCapability.ts") }] : [], + }; + const kernel = new Kernel({ workDir, manifest, logger: silent, globalInstructionDir: "" }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + return text; + } + + it("装了 extension → 前缀里有它的名字和描述", async () => { + const text = await prefix(true); + expect(text).toContain(""); + expect(text).toContain("- mock: 测试用:回显工具"); + }); + + it("没装 → 前缀里完全没有 extensions 节(不是空标签)", async () => { + expect(await prefix(false)).not.toContain(""); + }); + + it("内建六件套与派生 agent 三件套不出现在清单里", async () => { + // 它们不是 extension,工具本来就直接可见,再列一遍是噪音。 + const text = await prefix(true); + expect(text).not.toContain("- builtin:"); + expect(text).not.toContain("- agentspec:"); + }); + + it("清单排在 env 之前:两者都是运行时事实,env 压轴靠近对话", async () => { + const text = await prefix(true); + expect(text.indexOf("")).toBeLessThan(text.indexOf("")); + }); + + it("EXTENSION.md 的 frontmatter 覆盖代码里的 description(loader 真的读了文件)", async () => { + // 只单测 readExtensionDoc 不够:解析函数正确、loader 却没调它,照样全绿。 + // 这条走完整链路,fixture 包里代码写「来自代码的描述」、文件写「来自 EXTENSION.md 的描述」。 + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + extensions: [{ package: fixture("extpkg/index.ts") }], + }; + const kernel = new Kernel({ workDir, manifest, logger: silent, globalInstructionDir: "" }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + + expect(text).toContain("- extpkg: 来自 EXTENSION.md 的描述"); + expect(text).not.toContain("来自代码的描述"); + // 正文不进前缀:每装一个插件就常驻一段正文,token 与信噪比都吃不消。 + expect(text).not.toContain("测试用。"); + }); +}); + +describe("边界:按旧契约编译的插件没有 description,不能让 kernel 崩", () => { + it("缺 description 时降级成不进清单,start() 照常完成", async () => { + // 插件是动态 import 进来的,TS 管不到它——这是真实的系统边界。 + const workDir = await mkdtemp(join(tmpdir(), "helios-extlegacy-")); + try { + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + extensions: [{ package: fixture("mockCapabilityLegacyNoDescription.ts") }], + }; + const warns: string[] = []; + const kernel = new Kernel({ + workDir, + manifest, + logger: { ...silent, warn: (m: string) => warns.push(m) }, + globalInstructionDir: "", + }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + + expect(text).not.toContain(""); + expect(warns.join("\n")).toContain("未提供 description"); + } finally { + await rm(workDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/kernel/test/fixtures/capAnalyticsPlugin.ts b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts index 86ed101..9e9bf8e 100644 --- a/packages/kernel/test/fixtures/capAnalyticsPlugin.ts +++ b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts @@ -18,6 +18,7 @@ export function reset(): void { class AnalyticsProvider implements CapabilityProvider { readonly name = "analytics"; + readonly description = "测试用:假的埋点分析插件"; activate(): void {} getTools(): Tool[] { return [ diff --git a/packages/kernel/test/fixtures/capCacheProbe.ts b/packages/kernel/test/fixtures/capCacheProbe.ts index dbb89f0..7071265 100644 --- a/packages/kernel/test/fixtures/capCacheProbe.ts +++ b/packages/kernel/test/fixtures/capCacheProbe.ts @@ -19,6 +19,7 @@ const tool: Tool = { const provider: CapabilityProvider = { name: "probe", + description: "测试用:工具结果缓存探针", activate() {}, getTools() { return [tool]; diff --git a/packages/kernel/test/fixtures/capVersionedProbe.ts b/packages/kernel/test/fixtures/capVersionedProbe.ts index 1b6dde2..f0eec0d 100644 --- a/packages/kernel/test/fixtures/capVersionedProbe.ts +++ b/packages/kernel/test/fixtures/capVersionedProbe.ts @@ -21,6 +21,7 @@ const tool: Tool = { const provider: CapabilityProvider = { name: "vprobe", + description: "测试用:带版本的缓存探针", activate() {}, getTools() { return [tool]; diff --git a/packages/kernel/test/fixtures/delegatorCapability.ts b/packages/kernel/test/fixtures/delegatorCapability.ts index df3ebcd..7b18954 100644 --- a/packages/kernel/test/fixtures/delegatorCapability.ts +++ b/packages/kernel/test/fixtures/delegatorCapability.ts @@ -27,6 +27,7 @@ export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; export function create(ctx: KernelContext): CapabilityProvider { return { name: "delegator", + description: "测试用:把任务转派给队友", activate() {}, getTools(): Tool[] { return [createDelegateTool(ctx.ports.multiAgent)]; diff --git a/packages/kernel/test/fixtures/extpkg/EXTENSION.md b/packages/kernel/test/fixtures/extpkg/EXTENSION.md new file mode 100644 index 0000000..25a8e03 --- /dev/null +++ b/packages/kernel/test/fixtures/extpkg/EXTENSION.md @@ -0,0 +1,10 @@ +--- +description: 来自 EXTENSION.md 的描述 +--- + +# extpkg + +测试用。`index.ts` 里的 `description` 写的是「来自代码的描述」,本文件的 frontmatter +应当覆盖它——用来证明 loader 真的读了这个文件,而不只是有一个能单测通过的解析函数。 + +正文不会进 system 前缀(v1 只读 frontmatter),所以这段文字对模型不可见。 diff --git a/packages/kernel/test/fixtures/extpkg/index.ts b/packages/kernel/test/fixtures/extpkg/index.ts new file mode 100644 index 0000000..7abd3f7 --- /dev/null +++ b/packages/kernel/test/fixtures/extpkg/index.ts @@ -0,0 +1,18 @@ +import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; + +/** 代码里的 description 会被同目录 EXTENSION.md 的 frontmatter 覆盖。 */ +const provider: CapabilityProvider = { + name: "extpkg", + description: "来自代码的描述", + activate() {}, + getTools(): Tool[] { + return []; + }, +}; + +export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export function create(_ctx: KernelContext): CapabilityProvider { + return provider; +} +export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/extpkg/package.json b/packages/kernel/test/fixtures/extpkg/package.json new file mode 100644 index 0000000..abbe374 --- /dev/null +++ b/packages/kernel/test/fixtures/extpkg/package.json @@ -0,0 +1,6 @@ +{ + "name": "@helios-fixture/extpkg", + "private": true, + "version": "0.0.0", + "//": "只为让 readExtensionDoc 把本目录认成包根(它从入口往上找最近的 package.json)。" +} diff --git a/packages/kernel/test/fixtures/mockCapability.ts b/packages/kernel/test/fixtures/mockCapability.ts index 4252ed0..7979ec7 100644 --- a/packages/kernel/test/fixtures/mockCapability.ts +++ b/packages/kernel/test/fixtures/mockCapability.ts @@ -13,6 +13,7 @@ const echoTool: Tool = { const provider: CapabilityProvider = { name: "mock", + description: "测试用:回显工具", activate() {}, getTools(): Tool[] { return [echoTool]; diff --git a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts index 59fe041..aee3ed3 100644 --- a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts +++ b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts @@ -33,6 +33,7 @@ const guardedTool: Tool = { const provider: CapabilityProvider = { name: "mock", + description: "测试用:默认需要确认的工具", activate() {}, getTools(): Tool[] { return [guardedTool]; diff --git a/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts b/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts new file mode 100644 index 0000000..4dcbcb6 --- /dev/null +++ b/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts @@ -0,0 +1,33 @@ +import type { Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; + +/** + * 一个按**旧契约**编译的插件:没有 `description`。 + * + * 插件是动态 import 进来的,TS 校验不到它,所以这是真实的系统边界: + * kernel 必须兜住 undefined 而不是崩。刻意不标注 `CapabilityProvider` 类型, + * 否则编译期就被挡下,测不到运行期那条路。 + */ +const echoTool: Tool = { + name: "echo", + description: "回显输入文本", + inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, + async execute(input) { + const { text } = input as { text: string }; + return { output: `echo:${text}` }; + }, +}; + +const provider = { + name: "legacy", + activate() {}, + getTools(): Tool[] { + return [echoTool]; + }, +}; + +export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export function create(_ctx: KernelContext): unknown { + return provider; +} +export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityParallel.ts b/packages/kernel/test/fixtures/mockCapabilityParallel.ts index e11f01d..f9cb153 100644 --- a/packages/kernel/test/fixtures/mockCapabilityParallel.ts +++ b/packages/kernel/test/fixtures/mockCapabilityParallel.ts @@ -22,6 +22,7 @@ function makeParallelTool(name: string, delayMs: number): Tool { const provider: CapabilityProvider = { name: "par", + description: "测试用:可并行的工具", activate() {}, getTools(): Tool[] { return [makeParallelTool("toolA", 60), makeParallelTool("toolB", 60)]; diff --git a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts index 274cc05..a8f7e05 100644 --- a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts +++ b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts @@ -28,6 +28,7 @@ const echoTool: Tool = { const provider: CapabilityProvider = { name: "mock", + description: "测试用:回显工具(配 PreToolUse 探针)", activate() {}, getTools(): Tool[] { return [echoTool]; diff --git a/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts index a4b4ec2..75ec588 100644 --- a/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts +++ b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts @@ -26,6 +26,7 @@ const provider: CapabilityProvider & { getHookHandlers(): HookBinding[] } = { // 用 "mock" 而不是 "rogue":工具名会加 provider 前缀,得和 mockLlmWithTool 调的 // `mock__echo` 对上,否则测到的是「工具没找到」而不是「hook 有没有生效」。 name: "mock", + description: "测试用:想自带 hook 的越权插件", activate() {}, getTools: (): Tool[] => [echoTool], getHookHandlers: (): HookBinding[] => rogueHooks, diff --git a/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts b/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts index 02f36de..4d3ef58 100644 --- a/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts +++ b/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts @@ -21,6 +21,7 @@ const echoTool: Tool = { const provider: CapabilityProvider = { name: "mock", + description: "测试用:带 describe 的工具", activate() {}, getTools(): Tool[] { return [echoTool]; diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 1bbe2fb..457713f 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -274,6 +274,30 @@ export const DEFECTS: Defect[] = [ summary: "多个来源的决策用「最后一个胜出」合并,于是靠后的 allow 能覆盖靠前的 deny;权限合并必须取最严格结果", }, + // --- 模型可见文本静默退化类。这一类的代码全都能跑、测试也全绿,坏的是**喂给 LLM 的那段字**: + // 少了一节、拼错了顺序、或者描述是模板拼出来的废话。没有任何断言会因此变红, + // 只有把拼出来的字符串当成产物去读才看得见。 + { + id: "template-description-noise", + file: "src/prompt/toolCatalog.ts", + signals: [["描述", "模板"], ["加载工具", "描述"], ["description", "无信息"]], + summary: + "工具描述由 `加载 ${name} 的完整内容` 模板拼出,没有把 manifest 里的真实描述透出来,模型无从判断该调哪个", + }, + { + id: "empty-section-emitted", + file: "src/prompt/toolCatalog.ts", + signals: [["空", "标签"], ["空", "小节"], ["length === 0", "返回"]], + summary: + "列表为空时仍然吐出一对空的 标签,白占 token 且暗示模型「这里本该有东西」", + }, + { + id: "volatile-in-cached-prefix", + file: "src/prompt/toolCatalog.ts", + signals: [["date", "缓存"], ["时间戳", "前缀"], ["now()", "prompt"]], + summary: + "把 Date.now() 拼进每轮都要复用的系统前缀,前缀逐轮不同,prompt cache 整段作废", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -695,6 +719,49 @@ export async function sendWithRetry( if (first.ok) return first; return send({ ...buildRetryRequest(url, token, traceId), timeoutMs: RETRY_TIMEOUT_MS }); } +`, + "src/prompt/toolCatalog.ts": `/** + * 拼装喂给模型的系统前缀。 + * + * ## 约定 + * + * - 前缀要能被 prompt cache 复用,所以**逐轮必须完全一致**:任何每次调用都会变的值 + * (时间、随机数、计数器)都不能进来。 + * - 每个工具的描述来自 manifest 里作者写的那句话——模型靠它判断该调哪个。 + * - 小节为空时不要吐出空标签。 + */ + +export interface ToolEntry { + name: string; + /** manifest 里作者写的一句话说明。 */ + summary: string; +} + +export function renderToolLine(entry: ToolEntry): string { + return \`- \${entry.name}: 加载 \${entry.name} 的完整内容\`; +} + +export function renderToolSection(entries: readonly ToolEntry[]): string { + const lines = entries.map(renderToolLine); + return ["", ...lines, ""].join("\\n"); +} + +export interface PrefixParts { + base: string; + projectContext: string; + tools: readonly ToolEntry[]; +} + +export function buildPrefix(parts: PrefixParts): string { + return [ + parts.base, + parts.projectContext, + renderToolSection(parts.tools), + \`Prepared at \${new Date(Date.now()).toISOString()}\`, + ] + .filter((part) => part !== "") + .join("\\n\\n"); +} `, "src/auth/permit.ts": `/** * 工具调用准入。 diff --git a/packages/ports/src/capability.ts b/packages/ports/src/capability.ts index 7c6da59..db87a90 100644 --- a/packages/ports/src/capability.ts +++ b/packages/ports/src/capability.ts @@ -17,6 +17,18 @@ export const CAPABILITY_PROVIDER_API_VERSION = 1; export interface CapabilityProvider { /** provider 命名空间前缀,如 'lsp' / 'mcp:filesystem'(内建工具例外,不加前缀) */ readonly name: string; + /** + * 一句话说明这一组能力是干什么的,**进 system 前缀的已装能力清单**。 + * + * 为什么单个工具的 description 不够:模型只看到一堆带前缀的工具名 + * (`lsp__definition` / `cron__schedule`),看不出它们成组、也看不出这组解决什么问题。 + * 这里承载的是**跨工具的知识**——什么时候该用这一组、有没有顺序约定、领域术语。 + * + * 包根若有 `EXTENSION.md`,其 frontmatter 的 `description` 覆盖本字段 + * (声明式优先:改措辞不必改代码)。正文 v1 不读——每装一个插件就常驻一段正文, + * token 与信噪比都吃不消;按需展开是 skill 那套机制的事。 + */ + readonly description: string; activate(ctx: KernelContext): void | Promise; getTools?(): Tool[]; dispose?(): void | Promise; From bee99f7f46ac49f52d6279938356fde6b40543fe Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Mon, 31 Aug 2026 01:15:53 +0800 Subject: [PATCH 07/11] =?UTF-8?q?refactor(ports):=20CapabilityProvider=20?= =?UTF-8?q?=E2=86=92=20Capability=EF=BC=8C=E9=92=89=E6=B8=85=20extension?= =?UTF-8?q?=20=E4=B8=8E=20capability=20=E7=9A=84=E5=88=86=E5=B7=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么改名 前三个 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 拦不住——展开后的类型仍然满足接口,只有运行到那个方法才炸。正是本轮避开的坑。 --- docs/[IP]loop-decoupling.md | 6 +- docs/architecture-layers.md | 10 ++-- docs/code-organization.md | 16 ++--- .../2026-08-13-langsmith-observability.md | 2 +- docs/three-client-status.md | 2 +- docs/web-ui-requirements.md | 4 +- packages/cap-cron/src/index.test.ts | 2 +- packages/cap-cron/src/index.ts | 12 ++-- packages/cap-lsp/src/index.ts | 12 ++-- packages/cap-mcp/src/index.ts | 12 ++-- packages/capability-fs/src/index.ts | 12 ++-- packages/kernel/src/agentSpec/agentTools.ts | 8 +-- packages/kernel/src/builtin/capability.ts | 27 +++++++++ packages/kernel/src/builtin/provider.ts | 25 -------- packages/kernel/src/builtin/tools.ts | 2 +- packages/kernel/src/index.ts | 4 +- packages/kernel/src/kernel.ts | 24 ++++---- packages/kernel/src/pluginLoader.ts | 34 ++++++----- packages/kernel/src/toolRegistry.ts | 2 +- .../kernel/test/extension-listing.test.ts | 23 ++++++++ .../test/fixtures/capAnalyticsPlugin.ts | 14 ++--- .../kernel/test/fixtures/capCacheProbe.ts | 10 ++-- .../kernel/test/fixtures/capVersionedProbe.ts | 10 ++-- .../test/fixtures/delegatorCapability.ts | 8 +-- .../test/fixtures/disposableCapability.ts | 4 +- .../test/fixtures/extclass/EXTENSION.md | 8 +++ .../kernel/test/fixtures/extclass/index.ts | 39 +++++++++++++ .../test/fixtures/extclass/package.json | 6 ++ packages/kernel/test/fixtures/extpkg/index.ts | 10 ++-- .../test/fixtures/hookCaptureBindings.ts | 2 +- .../kernel/test/fixtures/mockCapability.ts | 10 ++-- .../fixtures/mockCapabilityGuardedTool.ts | 12 ++-- .../mockCapabilityLegacyNoDescription.ts | 6 +- .../test/fixtures/mockCapabilityParallel.ts | 10 ++-- .../test/fixtures/mockCapabilityPreToolUse.ts | 10 ++-- .../test/fixtures/mockCapabilityRogueHooks.ts | 10 ++-- .../fixtures/mockCapabilityWithDescribe.ts | 10 ++-- packages/kernel/test/hook-authority.test.ts | 8 +-- .../kernel/test/live/fixtures/evalProject.ts | 58 +++++++++++++++++++ packages/ports/src/capability.ts | 6 +- packages/ports/src/mount.ts | 2 +- packages/ports/src/types.ts | 2 +- 42 files changed, 320 insertions(+), 174 deletions(-) create mode 100644 packages/kernel/src/builtin/capability.ts delete mode 100644 packages/kernel/src/builtin/provider.ts create mode 100644 packages/kernel/test/fixtures/extclass/EXTENSION.md create mode 100644 packages/kernel/test/fixtures/extclass/index.ts create mode 100644 packages/kernel/test/fixtures/extclass/package.json diff --git a/docs/[IP]loop-decoupling.md b/docs/[IP]loop-decoupling.md index 7721eab..d9369fe 100644 --- a/docs/[IP]loop-decoupling.md +++ b/docs/[IP]loop-decoupling.md @@ -619,11 +619,15 @@ P2.7 判定「hook 与 mount 是两套机制,不能嵌套」——对,但** 目录 → `kernel/src/policies/`,函数 `xxxCapability` → `xxxPolicy`, 类型 `MountedCapability` → `MountBundle`。 +> 后续修订:`CapabilityProvider` 已改名为 `Capability`——`getHookHandlers` 删掉之后, +> "Provider" 这半截不再指代任何东西。`cap-*` 包名待第 4 步随 `capability-fs` +> 重写一并处理。上面保留当时的原名,是历史记录。 + **b) `PluginModule.mounts(instance, ctx)`。** 任何插件包都能自己声明挂载, kernel 用 `splitPluginMounts()` 按作用域拆进会话级 / run 级两个注册表。 插件挂载**恒定排在内置之后**(`context:prepare` 是 concat,拼接顺序模型可见)。 -放在 `PluginModule` 而不是 `CapabilityProvider` 上,因为想挂载的不只是插件—— +放在 `PluginModule` 而不是 `Capability` 上,因为想挂载的不只是插件—— 判据是**Port 的方法签名有没有把调用时机钉死**。`memoryRecallPolicy` 因此从 kernel **搬进了 `@helios/memory-fs`**:`recall(query)` 里没说何时调、结果去哪, `session:start` + `` 标签那套是 MEMORY.md 这一派的策略,mem0 一条都用不上。 diff --git a/docs/architecture-layers.md b/docs/architecture-layers.md index 1600cf9..d9da3a4 100644 --- a/docs/architecture-layers.md +++ b/docs/architecture-layers.md @@ -1,6 +1,6 @@ # 分层与依赖关系 -本文回答四个反复被问到的问题:Port / CapabilityProvider / Tool / loop 各自是什么、 +本文回答四个反复被问到的问题:Port / Capability / Tool / loop 各自是什么、 per-agent 异构该走哪条通路、Runtime 挂载点和 Hook 有什么不同、进程边界该按什么划。 对照对象是 valos(小红书 VectorX code-agent)与 pi(earendil-works/pi-mono), @@ -18,7 +18,7 @@ helios.config.json → 按声明顺序串行 create() │ ├── 一部分给 loop 自己用:llm / compact / checkpoint / router / costMeter … │ └── 一部分当原料:fileSystem / multiAgent │ ↓ - └─→ CapabilityProvider.activate(ctx.ports) ← 原料在这一刻被加工成产品 + └─→ Capability.activate(ctx.ports) ← 原料在这一刻被加工成产品 builtin → Read/Write/Edit/Glob/Grep(吃 fileSystem)、Task(吃 multiAgent)、Bash(不吃) cap-mcp → mcp__* cap-lsp → lsp__* @@ -48,9 +48,9 @@ checkpoint 字段。 **loop 同时消费 Port 和 Tool,但性质不同:Port 是它自己要用的依赖,Tool 是它转交给 LLM 的选项。** Port → Tool 的加工只发生在 `activate()` 那一刻,之后两者再无关系。 -### CapabilityProvider 解决的唯一问题:Port 是具名单例,工具源是匿名多实例 +### Capability 解决的唯一问题:Port 是具名单例,工具源是匿名多实例 -| | Port | CapabilityProvider | +| | Port | Capability | |---|---|---| | 数量 | 每种一个(`ports.fileSystem`) | 一个 list,可以有 N 个 | | 取法 | 按字段名取 | 遍历,每个吐一批工具 | @@ -244,7 +244,7 @@ teammate 本就是独立会话、独立成本单元,各记各的账才是对 ``` helios(容器一以贯之) - manifest → ServiceCollection → PortRegistry → CapabilityProvider.activate + manifest → ServiceCollection → PortRegistry → Capability.activate → ToolRegistry → Session → Agent → runTurnLoop valos(三层手工递减,容器只管一层) diff --git a/docs/code-organization.md b/docs/code-organization.md index c54db55..b06bb74 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -60,7 +60,7 @@ hook 有两条来路,**都代表用户**,所以都有 `deny` 一票否决: | `~/.helios/hooks.json` + `/.helios/hooks.json` | 用户自己(外部命令) | | `KernelOptions.hooks` | 宿主(CLI / Electron,代表用户在进程内注册) | -⚠️ **插件不在此列。** `CapabilityProvider` 曾有 `getHookHandlers()`,已删—— +⚠️ **插件不在此列。** `Capability` 曾有 `getHookHandlers()`,已删—— `deny` 之所以成立是因为 hooks.json 由用户自己写;让插件也能否决,等于 「装一个数据分析插件」就默认授权它拦截所有工具调用。 @@ -68,7 +68,7 @@ hook 有两条来路,**都代表用户**,所以都有 `deny` 一票否决: 但结果显示为**一个工具返回值**,不伪装成用户的权限拒绝。 ⚠️ 这条边界坏掉**零症状**——多注册一个 hook 不会让任何现有断言变红。 -护栏在 `test/hook-authority.test.ts`:结构(契约与 `activateProvider` 里都不许出现 +护栏在 `test/hook-authority.test.ts`:结构(契约与 `activateCapability` 里都不许出现 hook 注册)+ 行为(`mockCapabilityRogueHooks` 这个越权插件的 deny 不生效)。 光有结构护栏挡不住「换个方法名重新接上」,所以两者都要。 @@ -92,8 +92,8 @@ mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后 `fileAuditPolicy` 曾被塞进 `costAwareMounts.ts`,审计跟成本毫无关系, 只因为它们在同一个 PR 里落地。**目录名是机制的话,任何新东西都"符合"它,于是什么都能塞。** -**为什么也不叫 `capabilities/`**(它曾经叫这个):仓库里已经有一个 -`CapabilityProvider`(插件契约)和一堆 `cap-*` 包,三者共用"capability"一词, +**为什么也不叫 `capabilities/`**(它曾经叫这个):`Capability` 是插件契约的名字, +`cap-*` 是实现它的那些包。目录再叫 `capabilities/` 就是第三个含义, 讨论时永远要先问一句"你说的是哪个 capability"。 ### 四个概念别混 @@ -103,7 +103,7 @@ mount 的契约在 `packages/ports/src/mount.ts`。按机制命名的直接后 | | 契约在哪 | 实现是谁 | kernel 调它吗 | |---|---|---|---| | **Port** | `ports/src/.ts` | adapter 包 | **调**。`LLMProvider` 也在此列——kernel 调它的 `streamMessage`,多实例不改变调用方向 | -| **`CapabilityProvider`**(extension) | `ports/src/capability.ts` | 第三方 / `cap-*` 包 | **不调**。只从它那里收工具(对标 pi 的 Extension) | +| **`Capability`**(extension) | `ports/src/capability.ts` | 第三方 / `cap-*` 包 | **不调**。只从它那里收工具(对标 pi 的 Extension) | | **mount** | `ports/src/mount.ts` 的封闭联合 | policy(内置)+ 插件自带 `mounts()` | 分发它 | | **policy** | —— | 本目录 | 挂在 mount 上的**内置**策略 | @@ -122,7 +122,7 @@ mount 之于 policy,正如 Port 之于 adapter:**前者是类型,后者是 `extensions` 段**没有 `port` 字段**,因为 kernel 不调它,无从声明是哪个 Port。 两者曾共用一个 `plugins` 数组和同一个 `PortName` 联合,于是 -`"port": "CapabilityProvider"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 +`"port": "Capability"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 才看得出差别——**`kind` 这个字段的存在本身就是"这里塞了两类东西"的证据**。 ⚠️ **`requires` 解决的是静默失效,不是崩溃。** 八个有 no-op 兜底的 Port @@ -136,7 +136,7 @@ extension 一律在**全部 Port 之后**加载,所以它总能看到完整的 ### 已装能力清单进 system 前缀 -`CapabilityProvider.description`(一句话)会被拼成一节 `` 放进冻结的 +`Capability.description`(一句话)会被拼成一节 `` 放进冻结的 system 前缀,位置在 `project_context` 与 `env` 之间——它和 env 一样是「这个运行时的 事实」,而 env 压轴靠近对话。 @@ -162,7 +162,7 @@ system 前缀,位置在 `project_context` 与 `env` 之间——它和 env 一 ### 第三方怎么挂上去:`PluginModule.mounts()` -任何插件包(不限于 `CapabilityProvider`)都能在模块上导出 `mounts(instance, ctx)` +任何插件包(不限于 `Capability`)都能在模块上导出 `mounts(instance, ctx)` 交出 `MountBundle[]`,kernel 按作用域拆进两个注册表。所以一个「带自定义工具的领域插件」 一个包就够:`getTools()` 给工具、`mounts()` 挂时机,manifest 加一行即可, **核心代码一行不动**。 diff --git a/docs/superpowers/plans/2026-08-13-langsmith-observability.md b/docs/superpowers/plans/2026-08-13-langsmith-observability.md index 9f49650..5b4eab4 100644 --- a/docs/superpowers/plans/2026-08-13-langsmith-observability.md +++ b/docs/superpowers/plans/2026-08-13-langsmith-observability.md @@ -308,7 +308,7 @@ it("records each tool invocation beneath its agent turn", async () => { const tracer = new RecordingTracer(); const { session } = await bootSession( "mockLlmParallel.ts", - [{ port: "CapabilityProvider", package: fixture("mockCapabilityParallel.ts") }], + [{ port: "Capability", package: fixture("mockCapabilityParallel.ts") }], undefined, { tracer }, ); diff --git a/docs/three-client-status.md b/docs/three-client-status.md index b7044c7..ae34604 100644 --- a/docs/three-client-status.md +++ b/docs/three-client-status.md @@ -75,7 +75,7 @@ transport.onClose(() => unbind()); // 断开必须解绑,否则重连累积监 两端 UI(`@helios/ui-chat` 的 `useChat`)优先用事件自带的 `descriptor`,没有才落回本地通用兜底—— 新增一个工具的专属渲染样式,只需在该工具上实现 `describe()`,`apps/web`/`apps/electron` 零改动同步生效。 -> 早期这里是一张独立的 `ToolRenderer` 注册表(`CapabilityProvider.getRenderers?()` + `kernel.getRenderer()`), +> 早期这里是一张独立的 `ToolRenderer` 注册表(`Capability.getRenderers?()` + `kernel.getRenderer()`), > 后来塌缩进 `Tool.describe?()`:两者本就来自同一个 provider,拆两份还得靠工具名字符串对齐。 ## 端到端跑法(apps/web,需真实模型配置) diff --git a/docs/web-ui-requirements.md b/docs/web-ui-requirements.md index e6416f9..ad1282b 100644 --- a/docs/web-ui-requirements.md +++ b/docs/web-ui-requirements.md @@ -119,8 +119,8 @@ fork/switchBranch 本身对 cache 友好(回旧 node,`root→node` 前缀逐 **⚠️ 运行时 link 为什么是 v2(不是画个市场页那么简单)**: - 现状:`PORT_META` + `PortName` 是**编译期封闭集合**;实现由启动时 manifest 一次性装配后冻结(见 pluginLoader.ts)。 - "运行时 link 一个 port 进正在跑的 agent" 需要贯穿 **UI → protocol → host → kernel** 的纵切: - 1. kernel 新增"启动后动态 activate 一个 CapabilityProvider"的能力(现在 start() 后冻结)。 - 2. **范围必须限定在 CapabilityProvider(加工具/能力)**,不含运行时替换单实例 Port(memory/checkpoint 替换会破坏进行中会话状态,已排除)。 + 1. kernel 新增"启动后动态 activate 一个 Capability"的能力(现在 start() 后冻结)。 + 2. **范围必须限定在 Capability(加工具/能力)**,不含运行时替换单实例 Port(memory/checkpoint 替换会破坏进行中会话状态,已排除)。 3. link 的物理形式 = host(node)侧 `import()` 一个本地 port 目录(`importPlugin` 已支持 `./path` 本地路径)——**只能在 host 端做,浏览器不能 import 本地/跑 node 实现**。 4. 新 RPC:`ports.link({path})` / `ports.toggle(name)`,host 转调 kernel 动态激活。 - 结论:本期只读;运行时 link 单独立项做 v2,UI 先把只读列表和"link"按钮(置灰/提示 v2)画出来。 diff --git a/packages/cap-cron/src/index.test.ts b/packages/cap-cron/src/index.test.ts index 0236a78..77b9488 100644 --- a/packages/cap-cron/src/index.test.ts +++ b/packages/cap-cron/src/index.test.ts @@ -11,7 +11,7 @@ function toolMap(tools: Tool[]): Record { const noCtx = {} as unknown as Parameters[1]; -describe("cap-cron CapabilityProvider", () => { +describe("cap-cron Capability", () => { it("schedule → list → cancel 生命周期", async () => { const provider = create(ctx); provider.activate(ctx); diff --git a/packages/cap-cron/src/index.ts b/packages/cap-cron/src/index.ts index cd69626..2443c84 100644 --- a/packages/cap-cron/src/index.ts +++ b/packages/cap-cron/src/index.ts @@ -1,14 +1,14 @@ import cron, { type ScheduledTask } from "node-cron"; import type { - CapabilityProvider, + Capability, Tool, KernelContext, Logger, } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** - * @helios/cap-cron —— CapabilityProvider 的定时任务实现(P2)。 + * @helios/cap-cron —— Capability 的定时任务实现(P2)。 * * 暴露三个工具(带 `cron` 前缀由 ToolRegistry 统一加): * - cron_schedule:按 crontab 表达式登记一个定时任务 @@ -26,7 +26,7 @@ interface Job { fireCount: number; } -class CronCapability implements CapabilityProvider { +class CronCapability implements Capability { readonly name = "cron"; readonly description = "Register, list and cancel crontab-scheduled tasks that outlive the current turn. Schedule only what the user explicitly asked to repeat."; @@ -114,9 +114,9 @@ class CronCapability implements CapabilityProvider { } } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export function create(_ctx: KernelContext): Capability { return new CronCapability(); } diff --git a/packages/cap-lsp/src/index.ts b/packages/cap-lsp/src/index.ts index 7f4e8af..4970b59 100644 --- a/packages/cap-lsp/src/index.ts +++ b/packages/cap-lsp/src/index.ts @@ -8,11 +8,11 @@ import { StreamMessageWriter, type MessageConnection, } from "vscode-jsonrpc/node"; -import type { CapabilityProvider, Tool, KernelContext, Logger } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, Logger } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** - * @helios/cap-lsp —— CapabilityProvider 的 LSP 实现(P2)。 + * @helios/cap-lsp —— Capability 的 LSP 实现(P2)。 * * spawn `typescript-language-server --stdio`,用 vscode-jsonrpc 建 LSP 连接, * 暴露 lsp_definition / lsp_hover 两个工具。file_path 相对 workDir 解析。 @@ -31,7 +31,7 @@ interface Position { character: number; } -class LspCapability implements CapabilityProvider { +class LspCapability implements Capability { readonly name = "lsp"; readonly description = "Query a language server for symbol definitions and hover docs. Use it before Grep when you already know a symbol name — it resolves the real declaration instead of textual matches."; @@ -131,9 +131,9 @@ class LspCapability implements CapabilityProvider { } } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; -export function create(ctx: KernelContext): CapabilityProvider { +export function create(ctx: KernelContext): Capability { return new LspCapability((ctx.options ?? {}) as LspOptions); } diff --git a/packages/cap-mcp/src/index.ts b/packages/cap-mcp/src/index.ts index 6f2a52c..c0e0017 100644 --- a/packages/cap-mcp/src/index.ts +++ b/packages/cap-mcp/src/index.ts @@ -1,10 +1,10 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import type { CapabilityProvider, Tool, KernelContext, Logger } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, Logger } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** - * @helios/cap-mcp —— CapabilityProvider 的 MCP 客户端实现(P2)。 + * @helios/cap-mcp —— Capability 的 MCP 客户端实现(P2)。 * * 通过 stdio 连接一个 MCP server,把它 list 出来的工具原样映射成 helios Tool, * 由 ToolRegistry 统一加上 `mcp:` 前缀暴露给 agent。 @@ -25,7 +25,7 @@ interface McpToolDef { inputSchema?: unknown; } -class McpCapability implements CapabilityProvider { +class McpCapability implements Capability { readonly name: string; readonly description = "Tools exposed by a connected MCP server. What they can do depends on that server — read each tool's own description before calling it."; @@ -99,9 +99,9 @@ export function flattenContent(content: unknown): string { .join("\n"); } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; -export function create(ctx: KernelContext): CapabilityProvider { +export function create(ctx: KernelContext): Capability { return new McpCapability((ctx.options ?? {}) as unknown as McpOptions); } diff --git a/packages/capability-fs/src/index.ts b/packages/capability-fs/src/index.ts index 547c7a4..d890763 100644 --- a/packages/capability-fs/src/index.ts +++ b/packages/capability-fs/src/index.ts @@ -1,12 +1,12 @@ import type { - CapabilityProvider, + Capability, Tool, KernelContext, FileSystemPort, } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; -// @helios/capability-fs —— CapabilityProvider 官方文件扫描实现(替代原 Skill+Extension)。 +// @helios/capability-fs —— Capability 官方文件扫描实现(替代原 Skill+Extension)。 // 扫描 .helios/policies//SKILL.md,每个目录产出一个工具:调用即返回该 skill 全文, // 供 Agent 按需加载领域指引。是否做"分层解锁"由本实现自行决定,不属接口契约。 @@ -17,7 +17,7 @@ interface ScannedSkill { path: string; } -class FsCapability implements CapabilityProvider { +class FsCapability implements Capability { readonly name = "caps"; readonly description = "Load project-local skills: each one is a folder of instructions (and sometimes scripts) for a recurring task. Load a skill before improvising a workflow it already covers."; @@ -58,9 +58,9 @@ class FsCapability implements CapabilityProvider { } } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; -export function create(ctx: KernelContext): CapabilityProvider { +export function create(ctx: KernelContext): Capability { return new FsCapability(ctx.ports.fileSystem); } diff --git a/packages/kernel/src/agentSpec/agentTools.ts b/packages/kernel/src/agentSpec/agentTools.ts index 59eba83..4c998fc 100644 --- a/packages/kernel/src/agentSpec/agentTools.ts +++ b/packages/kernel/src/agentSpec/agentTools.ts @@ -1,12 +1,12 @@ // ============================================================================ // packages/kernel/src/agentSpec/agentTools.ts -// 主 agent 用来派生 / 观察 / 生成 agent 的三个工具,以及承载它们的内部 CapabilityProvider。 +// 主 agent 用来派生 / 观察 / 生成 agent 的三个工具,以及承载它们的内部 Capability。 // ============================================================================ import type { AgentDefinition, AgentSpec, - CapabilityProvider, + Capability, LLMOptions, PortRegistry, Tool, @@ -76,10 +76,10 @@ export function createAgentTools(ctx: AgentToolsContext): Tool[] { } /** - * 内部 CapabilityProvider。之所以不塞进 builtin 六件套:那批工具只从 `KernelContext.ports` + * 内部 Capability。之所以不塞进 builtin 六件套:那批工具只从 `KernelContext.ports` * 取依赖,而这三个还需要执行器与工具池,扩 `KernelContext` 会波及所有第三方 provider。 */ -export function createAgentCapabilityProvider(ctx: AgentToolsContext): CapabilityProvider { +export function createAgentCapability(ctx: AgentToolsContext): Capability { const tools = createAgentTools(ctx); return { name: "agentspec", diff --git a/packages/kernel/src/builtin/capability.ts b/packages/kernel/src/builtin/capability.ts new file mode 100644 index 0000000..15bb4cd --- /dev/null +++ b/packages/kernel/src/builtin/capability.ts @@ -0,0 +1,27 @@ +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { createBuiltinTools } from "./tools"; + +/** + * 六件套内建 Capability。它与 manifest 装进来的 extension 走完全相同的注册路径, + * 唯一区别是 kernel 注册它时豁免工具名前缀(见 Kernel.start)。 + * + * 与 `capability-fs` 的 `create(ctx) → new FsCapability(ctx.ports.fileSystem)` 同一模式: + * activate() 时从 KernelContext 拿到具体 Port 实例、闭包造好工具,getTools() 只读取。 + */ +class BuiltinCapability implements Capability { + // description 留空 = 不进「已装 extension」清单。它不是 extension(没有 manifest + // 条目、没有包),工具本来就直接可见,再列一遍是噪音。 + readonly name = "builtin"; + readonly description = ""; + private tools: Tool[] = []; + + activate(ctx: KernelContext): void { + this.tools = createBuiltinTools(ctx.ports); + } + + getTools(): Tool[] { + return this.tools; + } +} + +export const builtinCapability: Capability = new BuiltinCapability(); diff --git a/packages/kernel/src/builtin/provider.ts b/packages/kernel/src/builtin/provider.ts deleted file mode 100644 index 57f6af1..0000000 --- a/packages/kernel/src/builtin/provider.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { createBuiltinTools } from "./tools"; - -/** - * 六件套内建 CapabilityProvider 参考实现。它与用户自写 provider 走完全相同的 - * 注册路径,唯一区别是 kernel 注册它时豁免工具名前缀(见 Kernel.start)。 - * 与 `capability-fs` 的 `create(ctx) → new FsCapability(ctx.ports.fileSystem)` 同一模式: - * activate() 时从 KernelContext 拿到具体 Port 实例、闭包造好工具,getTools() 只读取。 - */ -class BuiltinCapability implements CapabilityProvider { - // 内建 provider 不进「已装 extension」清单:它不是 extension,工具本来就直接可见。 - readonly name = "builtin"; - readonly description = ""; - private tools: Tool[] = []; - - activate(ctx: KernelContext): void { - this.tools = createBuiltinTools(ctx.ports); - } - - getTools(): Tool[] { - return this.tools; - } -} - -export const builtinCapabilityProvider: CapabilityProvider = new BuiltinCapability(); diff --git a/packages/kernel/src/builtin/tools.ts b/packages/kernel/src/builtin/tools.ts index 72c052f..8351c70 100644 --- a/packages/kernel/src/builtin/tools.ts +++ b/packages/kernel/src/builtin/tools.ts @@ -3,7 +3,7 @@ import { isIP } from "node:net"; import { convert as htmlToText } from "html-to-text"; import type { Tool, ToolContext, FileSystemPort, MultiAgentPort, PortRegistry } from "@helios/ports"; -// 六件套 + WebFetch + AskUserQuestion。均通过 CapabilityProvider 注册路径接入, +// 六件套 + WebFetch + AskUserQuestion。均通过 Capability 注册路径接入, // 仅在命名上豁免 provider 前缀(证明官方实现不走特权通道,只享命名豁免)。 // // 工具都是工厂函数:只有真正要用 Port 的工具(Read/Write/Edit/Glob/Grep 用 fileSystem, diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index d4ad88d..30ef69a 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -49,7 +49,7 @@ export { NoopCheckpoint, MultiAgentNotEnabledError, } from "./noop"; -export { builtinCapabilityProvider } from "./builtin/provider"; +export { builtinCapability } from "./builtin/capability"; export { BASE_SYSTEM_PROMPT, buildEnvBlock, buildExtensionsBlock } from "./prompt/systemPrompt"; export type { EnvInfo } from "./prompt/systemPrompt"; export { @@ -99,7 +99,7 @@ export type { ForegroundResult, MergePayload, } from "./agentSpec/derivedAgentExecutor"; -export { createAgentTools, createAgentCapabilityProvider } from "./agentSpec/agentTools"; +export { createAgentTools, createAgentCapability } from "./agentSpec/agentTools"; export type { AgentToolsContext } from "./agentSpec/agentTools"; export type { RunLogEntry, MergeLogEntry, RunAgentMeta } from "./persistence/sessionLog"; export type { AgentEvent, AgentEventListener, ToolResultRecord } from "./events"; diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index c7091aa..2c4f354 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -21,7 +21,7 @@ import { type PackageResolver, type LoadedExtension, } from "./pluginLoader"; -import { builtinCapabilityProvider } from "./builtin/provider"; +import { builtinCapability } from "./builtin/capability"; import { Session, type SessionMeta } from "./session"; import { uid } from "./ids"; import type { LlmRetryOptions } from "./agentLoop/retryBackoff"; @@ -33,7 +33,7 @@ import { BASE_SYSTEM_PROMPT, buildEnvBlock, buildExtensionsBlock } from "./promp import { loadProjectInstructions, renderProjectInstructions } from "./prompt/projectInstructions"; import { createLangSmithTracer, type Tracer } from "@helios/observability-langsmith"; import { DerivedAgentExecutor } from "./agentSpec/derivedAgentExecutor"; -import { createAgentCapabilityProvider } from "./agentSpec/agentTools"; +import { createAgentCapability } from "./agentSpec/agentTools"; import { AgentPortResolver } from "./agentSpec/portResolver"; import { buildDefaultMounts, splitPluginMounts } from "./policies"; @@ -67,7 +67,7 @@ export interface KernelOptions { * * 之所以由宿主而不是插件提供:hook 的否决权来自「用户授权」,CLI/Electron 是用户的 * 代理,插件不是。这里也是测试注入 hook 的唯一途径——此前测试靠 - * `CapabilityProvider.getHookHandlers()`,那条路已删。 + * `Capability.getHookHandlers()`,那条路已删。 */ hooks?: HookBinding[]; /** Disable unsafe built-in tools for constrained hosts (for example Workspace sessions). */ @@ -191,16 +191,16 @@ export class Kernel { this.envBlock = prompt.envBlock; // 六件套内建 provider —— 命名豁免前缀,其余一切与用户 provider 相同 - await this.activateProvider(builtinCapabilityProvider, ctx, true); - for (const { provider } of capabilities) { - await this.activateProvider(provider, ctx, false); + await this.activateCapability(builtinCapability, ctx, true); + for (const { capability } of capabilities) { + await this.activateCapability(capability, ctx, false); } // 派生 agent 工具。工具名同样豁免前缀(不该叫 agentspec__Agent)。 // 它拿全量工具池用的是惰性闭包 `() => this.tools.list()`,所以放在最后激活也不影响 // 子能拿到的工具集 —— 顺序无关是刻意的,将来插入新 provider 不会改变行为。 - await this.activateProvider( - createAgentCapabilityProvider({ + await this.activateCapability( + createAgentCapability({ executorFor: (sessionId) => this.derivedAgents.get(sessionId), portResolverFor: (sessionId) => this.portResolvers.get(sessionId), allTools: () => this.tools.list(), @@ -217,7 +217,7 @@ export class Kernel { // hook 有两条来路,都代表**用户**的意志,所以都有否决权: // ① 配置化:~/.helios/hooks.json + /.helios/hooks.json(用户写的外部命令) // ② 宿主注册:KernelOptions.hooks(CLI/Electron 代表用户在进程内注册) - // 插件不在此列——它没有否决权,见 CapabilityProvider 的注释。 + // 插件不在此列——它没有否决权,见 Capability 的注释。 const hookEntries = await loadHookConfig(this.opts.workDir, this.logger); this.hooks.register( toHookBindings(hookEntries, { @@ -266,7 +266,7 @@ export class Kernel { // 同一份项目指令 + 同一份 env」,需要分别取用,不能只拿到拼好的整串。 const projectInstructions = renderProjectInstructions(files); const extensionsBlock = buildExtensionsBlock( - extensions.map((e) => ({ name: e.provider.name, description: e.description })), + extensions.map((e) => ({ name: e.capability.name, description: e.description })), ); const system = [ this.opts.system ?? BASE_SYSTEM_PROMPT, @@ -279,8 +279,8 @@ export class Kernel { return { system, projectInstructions, envBlock }; } - private async activateProvider( - cap: import("@helios/ports").CapabilityProvider, + private async activateCapability( + cap: import("@helios/ports").Capability, ctx: KernelContext, exemptPrefix: boolean, ): Promise { diff --git a/packages/kernel/src/pluginLoader.ts b/packages/kernel/src/pluginLoader.ts index 0d141f2..35d8850 100644 --- a/packages/kernel/src/pluginLoader.ts +++ b/packages/kernel/src/pluginLoader.ts @@ -9,7 +9,7 @@ import { COMPACT_STRATEGY_PORT_API_VERSION, CHECKPOINT_PORT_API_VERSION, LLM_PROVIDER_API_VERSION, - CAPABILITY_PROVIDER_API_VERSION, + CAPABILITY_API_VERSION, MODEL_ROUTER_PORT_API_VERSION, COST_METER_PORT_API_VERSION, TOOL_RESULT_CACHE_PORT_API_VERSION, @@ -19,7 +19,7 @@ import type { KernelContext, Logger, PluginModule, - CapabilityProvider, + Capability, LLMProvider, MountBundle, } from "@helios/ports"; @@ -41,9 +41,9 @@ import { LiveLLMRegistry } from "./portRegistry"; * kernel **主动调用**其方法的那一类实现。判据就是这一句:kernel 调不调你。 * `LLMProvider` 在列是因为 kernel 调它的 `streamMessage`——多实例不改变调用方向。 * - * 反过来 extension(原 `CapabilityProvider`)不在这里:kernel 从不调它的业务方法, + * 反过来 extension(原 `Capability`)不在这里:kernel 从不调它的业务方法, * 只从它那里收工具。两者曾共用本联合与 manifest 的 `port` 字段,于是 - * `"port": "CapabilityProvider"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 + * `"port": "Capability"` 这行字面自相矛盾,且要靠 `PORT_META.kind` 二次分类 * 才能看出差别——`kind` 这个字段的存在本身就是"这里塞了两类东西"的证据。 */ export type PortName = @@ -73,7 +73,7 @@ export interface PortEntry { requires?: PortName[]; } -/** extension(原 `CapabilityProvider`):kernel 不调它,只从它那里收工具。 */ +/** extension(原 `Capability`):kernel 不调它,只从它那里收工具。 */ export interface ExtensionEntry { package: string; options?: Record; @@ -158,16 +158,22 @@ const PORT_META: Record = { }; const EXTENSION_META = { - apiVersion: CAPABILITY_PROVIDER_API_VERSION, + apiVersion: CAPABILITY_API_VERSION, requiredMethods: ["activate"], }; /** - * 一个装好的 extension。`description` 单独放而不是读 `provider.description`: - * 它可能被包根的 `EXTENSION.md` 覆盖过,而 provider 常常是 class 实例,改不得。 + * 一个装好的 extension。 + * + * **extension 与 capability 不是一回事**:extension 是部署单元(manifest 里那一行、 + * 你装的那个包),capability 是它带进来的运行时对象。和 Port(接口)与 adapter(包) + * 是同一种关系——所以 `EXTENSION.md` 属于包,`Capability.description` 属于对象。 + * + * `description` 单独放而不是读 `capability.description`:它可能被包根的 + * `EXTENSION.md` 覆盖过,而 capability 常常是 class 实例,改不得。 */ export interface LoadedExtension { - provider: CapabilityProvider; + capability: Capability; description: string; } @@ -314,20 +320,20 @@ export async function loadPlugins( for (const entry of manifest.extensions ?? []) { await loadEntry("extension", entry, EXTENSION_META, async (impl, spec) => { - const provider = impl as CapabilityProvider; + const capability = impl as Capability; // EXTENSION.md 的 frontmatter 覆盖代码里的 description:声明式优先, // 改措辞 / 本地化不必改代码。缺文件就用代码字段,这是可选覆盖不是必需品。 // // 覆盖值单独带出来、**不写回实例**:cap-lsp / cap-cron 是 class, - // `{ ...provider, description }` 会丢掉原型上的 activate / getTools。 + // `{ ...capability, description }` 会丢掉原型上的 activate / getTools。 const declared = await readExtensionDoc(spec); // 兜 undefined 而不是信类型:插件是动态 import 进来的,TS 管不到它。 // 按旧契约编译的第三方包没有 description,不该让 kernel 崩——降级成不进清单。 - const own = typeof provider.description === "string" ? provider.description : ""; + const own = typeof capability.description === "string" ? capability.description : ""; if (declared === undefined && own === "") { logger.warn(`extension ${entry.package} 未提供 description,不会出现在能力清单里`); } - capabilities.push({ provider, description: declared ?? own }); + capabilities.push({ capability, description: declared ?? own }); }); } @@ -370,7 +376,7 @@ async function importPlugin( /** * 从入口文件往上找到包根(最近的含 `package.json` 的祖先),读它的 `EXTENSION.md`。 * - * 只取 frontmatter 的 `description`;正文有意不读,见 `CapabilityProvider.description`。 + * 只取 frontmatter 的 `description`;正文有意不读,见 `Capability.description`。 * 找不到文件 / 没有 frontmatter / 没有 description,一律返回 undefined 走代码里的字段—— * 这是可选的声明式覆盖,不是必需品。 */ diff --git a/packages/kernel/src/toolRegistry.ts b/packages/kernel/src/toolRegistry.ts index ba05f3d..e5e1c36 100644 --- a/packages/kernel/src/toolRegistry.ts +++ b/packages/kernel/src/toolRegistry.ts @@ -25,7 +25,7 @@ export function createToolView(tools: Tool[]): ToolLookup { } /** - * 工具表。冲突解决用显式 namespace:多实例 CapabilityProvider 产出的工具 + * 工具表。冲突解决用显式 namespace:多实例 Capability 产出的工具 * 一律加 `__` 前缀;六件套内建工具是唯一例外(命名豁免)。 */ export class ToolRegistry { diff --git a/packages/kernel/test/extension-listing.test.ts b/packages/kernel/test/extension-listing.test.ts index 20d5b15..e154af4 100644 --- a/packages/kernel/test/extension-listing.test.ts +++ b/packages/kernel/test/extension-listing.test.ts @@ -146,6 +146,29 @@ describe("端到端:清单真的进了 system 前缀", () => { // 正文不进前缀:每装一个插件就常驻一段正文,token 与信噪比都吃不消。 expect(text).not.toContain("测试用。"); }); + + it("capability 是 class 且 description 被覆盖时,原型上的方法不能丢", async () => { + // `{ ...capability, description }` 看起来无害,但 cap-lsp / cap-cron 都是 class, + // 方法在原型上,展开就全丢了。上一条用的 extpkg 是对象字面量,**测不出**这一点。 + const manifest: Manifest = { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + extensions: [{ package: fixture("extclass/index.ts") }], + }; + const kernel = new Kernel({ workDir, manifest, logger: silent, globalInstructionDir: "" }); + await kernel.start(); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + const toolNames = kernel.listTools(); + await kernel.dispose(); + + expect(text).toContain("- extclass: 来自 EXTENSION.md 的描述(class 版)"); + // activate() 与 getTools() 都在原型上——被展开过就注册不出这个工具。 + expect(toolNames).toContain("extclass__ping"); + }); }); describe("边界:按旧契约编译的插件没有 description,不能让 kernel 崩", () => { diff --git a/packages/kernel/test/fixtures/capAnalyticsPlugin.ts b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts index 9e9bf8e..3137f70 100644 --- a/packages/kernel/test/fixtures/capAnalyticsPlugin.ts +++ b/packages/kernel/test/fixtures/capAnalyticsPlugin.ts @@ -1,13 +1,13 @@ // 一个「大而全」的第三方插件:既给工具、又自己声明挂载点。 -// 这是 CapabilityProvider + PluginModule.mounts() 组合起来能做到的完整形态—— +// 这是 Capability + PluginModule.mounts() 组合起来能做到的完整形态—— // 第三方装它只需要在 manifest 里加一行,kernel 一行不动。 import type { - CapabilityProvider, + Capability, KernelContext, MountBundle, Tool, } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** 挂载点实际跑到时往里记,测试据此断言分发真的发生了。 */ export const seen: string[] = []; @@ -16,7 +16,7 @@ export function reset(): void { seen.length = 0; } -class AnalyticsProvider implements CapabilityProvider { +class AnalyticsProvider implements Capability { readonly name = "analytics"; readonly description = "测试用:假的埋点分析插件"; activate(): void {} @@ -32,9 +32,9 @@ class AnalyticsProvider implements CapabilityProvider { } } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export function create(_ctx: KernelContext): Capability { return new AnalyticsProvider(); } @@ -42,7 +42,7 @@ export function create(_ctx: KernelContext): CapabilityProvider { * 同一个 bundle 里同时有会话级与 run 级挂载,验证 kernel 会按作用域拆开 * 分别送进两个注册表(会话级那份跨 run 只建一次,run 级那份每 run 重建)。 */ -export function mounts(_instance: CapabilityProvider, _ctx: KernelContext): MountBundle[] { +export function mounts(_instance: Capability, _ctx: KernelContext): MountBundle[] { return [ { name: "analytics:context", diff --git a/packages/kernel/test/fixtures/capCacheProbe.ts b/packages/kernel/test/fixtures/capCacheProbe.ts index 7071265..606c60a 100644 --- a/packages/kernel/test/fixtures/capCacheProbe.ts +++ b/packages/kernel/test/fixtures/capCacheProbe.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; // 可缓存工具:每次真正执行时自增计数器,返回当前计数。session scope + 无 version。 // 用于验证 ToolResultCache:同一 session、同参 → 第二个 run 命中缓存、不再执行(计数不变)。 @@ -17,7 +17,7 @@ const tool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "probe", description: "测试用:工具结果缓存探针", activate() {}, @@ -26,8 +26,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/capVersionedProbe.ts b/packages/kernel/test/fixtures/capVersionedProbe.ts index f0eec0d..0b61df4 100644 --- a/packages/kernel/test/fixtures/capVersionedProbe.ts +++ b/packages/kernel/test/fixtures/capVersionedProbe.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; // 与 capCacheProbe 的唯一差别:声明了 cacheVersionKind,于是 buildCacheKey 会去问 // VersionProvider 要版本串。用于验证「版本变了 → key 变了 → 缓存自然 miss」这条链路。 @@ -19,7 +19,7 @@ const tool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "vprobe", description: "测试用:带版本的缓存探针", activate() {}, @@ -28,8 +28,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/delegatorCapability.ts b/packages/kernel/test/fixtures/delegatorCapability.ts index 7b18954..f0e9672 100644 --- a/packages/kernel/test/fixtures/delegatorCapability.ts +++ b/packages/kernel/test/fixtures/delegatorCapability.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext, MultiAgentPort } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, MultiAgentPort } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; // 一个只依赖 MultiAgentPort 抽象的工具,不关心背后是文件邮箱还是内存直调。 // multiAgent 在 create(ctx) 时闭包进来,execute() 不再摸 ctx(ToolContext 不再带 ports)。 @@ -23,8 +23,8 @@ function createDelegateTool(multiAgent: MultiAgentPort): Tool { }; } -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(ctx: KernelContext): Capability { return { name: "delegator", description: "测试用:把任务转派给队友", diff --git a/packages/kernel/test/fixtures/disposableCapability.ts b/packages/kernel/test/fixtures/disposableCapability.ts index 4eb78eb..9f8cb44 100644 --- a/packages/kernel/test/fixtures/disposableCapability.ts +++ b/packages/kernel/test/fixtures/disposableCapability.ts @@ -1,6 +1,6 @@ -import { CAPABILITY_PROVIDER_API_VERSION, type KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION, type KernelContext } from "@helios/ports"; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; export const disposed: string[] = []; export function reset(): void { diff --git a/packages/kernel/test/fixtures/extclass/EXTENSION.md b/packages/kernel/test/fixtures/extclass/EXTENSION.md new file mode 100644 index 0000000..b36eb8b --- /dev/null +++ b/packages/kernel/test/fixtures/extclass/EXTENSION.md @@ -0,0 +1,8 @@ +--- +description: 来自 EXTENSION.md 的描述(class 版) +--- + +# extclass + +测试用。存在的意义是:capability 是 class 实例、同时又有 EXTENSION.md 覆盖 description, +这是最容易诱使人写出 `{ ...capability, description }` 的组合,而那样会丢掉原型上的方法。 diff --git a/packages/kernel/test/fixtures/extclass/index.ts b/packages/kernel/test/fixtures/extclass/index.ts new file mode 100644 index 0000000..7b59683 --- /dev/null +++ b/packages/kernel/test/fixtures/extclass/index.ts @@ -0,0 +1,39 @@ +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; + +/** + * **class 形态**的 capability,且带 EXTENSION.md 覆盖 description。 + * + * 存在的唯一目的:钉住「loader 不得把 capability 展开重建」。 + * `{ ...capability, description }` 看起来无害,但 `cap-lsp` / `cap-cron` 都是 class, + * 方法在原型上,展开就全丢了——而普通对象字面量的 fixture **测不出**这一点。 + * 这里的 activate / getTools 都定义在原型上,被展开过就注册不出工具。 + */ +class ClassCapability implements Capability { + readonly name = "extclass"; + readonly description = "来自代码的描述"; + private tools: Tool[] = []; + + activate(_ctx: KernelContext): void { + this.tools = [ + { + name: "ping", + description: "回一个 pong", + inputSchema: { type: "object", properties: {} }, + async execute() { + return { output: "pong" }; + }, + }, + ]; + } + + getTools(): Tool[] { + return this.tools; + } +} + +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { + return new ClassCapability(); +} +export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/extclass/package.json b/packages/kernel/test/fixtures/extclass/package.json new file mode 100644 index 0000000..89c839c --- /dev/null +++ b/packages/kernel/test/fixtures/extclass/package.json @@ -0,0 +1,6 @@ +{ + "name": "@helios-fixture/extclass", + "private": true, + "version": "0.0.0", + "//": "让 readExtensionDoc 把本目录认成包根。" +} diff --git a/packages/kernel/test/fixtures/extpkg/index.ts b/packages/kernel/test/fixtures/extpkg/index.ts index 7abd3f7..56663ef 100644 --- a/packages/kernel/test/fixtures/extpkg/index.ts +++ b/packages/kernel/test/fixtures/extpkg/index.ts @@ -1,8 +1,8 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** 代码里的 description 会被同目录 EXTENSION.md 的 frontmatter 覆盖。 */ -const provider: CapabilityProvider = { +const provider: Capability = { name: "extpkg", description: "来自代码的描述", activate() {}, @@ -11,8 +11,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/hookCaptureBindings.ts b/packages/kernel/test/fixtures/hookCaptureBindings.ts index c6abbca..a6dd708 100644 --- a/packages/kernel/test/fixtures/hookCaptureBindings.ts +++ b/packages/kernel/test/fixtures/hookCaptureBindings.ts @@ -9,7 +9,7 @@ import type { /** * 六个 hook 事件的探针,交给 `KernelOptions.hooks`。 * - * 曾经是一个 `CapabilityProvider`(靠已删的 `getHookHandlers()` 注入),但它一个工具 + * 曾经是一个 `Capability`(靠已删的 `getHookHandlers()` 注入),但它一个工具 * 都不供——把它塞进 manifest 的 extensions 段只是为了借道注册 hook。现在宿主注册 * 是正路,它退回成一组纯绑定,不再是插件。 */ diff --git a/packages/kernel/test/fixtures/mockCapability.ts b/packages/kernel/test/fixtures/mockCapability.ts index 7979ec7..ea004ba 100644 --- a/packages/kernel/test/fixtures/mockCapability.ts +++ b/packages/kernel/test/fixtures/mockCapability.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; const echoTool: Tool = { name: "echo", @@ -11,7 +11,7 @@ const echoTool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "mock", description: "测试用:回显工具", activate() {}, @@ -20,8 +20,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts index aee3ed3..dca24af 100644 --- a/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts +++ b/packages/kernel/test/fixtures/mockCapabilityGuardedTool.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext, HookBinding, PreToolUseDecision } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, HookBinding, PreToolUseDecision } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** * 声明 `defaultPermission: "ask"` 的工具 + 可覆写的 PreToolUse handler。 @@ -9,7 +9,7 @@ import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; * * ⚠️ 工具与 hook 走两条不同的路:工具经 manifest 的 `extensions` 段装进来, * hook 由测试把下面的 `hooks` 传给 `KernelOptions.hooks`(宿主注册那条路)。 - * 插件不能自带 hook——否决权只属于用户,见 `CapabilityProvider` 的注释。 + * 插件不能自带 hook——否决权只属于用户,见 `Capability` 的注释。 */ export const behavior: { preToolUse?: (input: unknown) => PreToolUseDecision | void; @@ -31,7 +31,7 @@ const guardedTool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "mock", description: "测试用:默认需要确认的工具", activate() {}, @@ -40,8 +40,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts b/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts index 4dcbcb6..6f6d24c 100644 --- a/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts +++ b/packages/kernel/test/fixtures/mockCapabilityLegacyNoDescription.ts @@ -1,11 +1,11 @@ import type { Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** * 一个按**旧契约**编译的插件:没有 `description`。 * * 插件是动态 import 进来的,TS 校验不到它,所以这是真实的系统边界: - * kernel 必须兜住 undefined 而不是崩。刻意不标注 `CapabilityProvider` 类型, + * kernel 必须兜住 undefined 而不是崩。刻意不标注 `Capability` 类型, * 否则编译期就被挡下,测不到运行期那条路。 */ const echoTool: Tool = { @@ -26,7 +26,7 @@ const provider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; +export const apiVersion = CAPABILITY_API_VERSION; export function create(_ctx: KernelContext): unknown { return provider; } diff --git a/packages/kernel/test/fixtures/mockCapabilityParallel.ts b/packages/kernel/test/fixtures/mockCapabilityParallel.ts index f9cb153..509077d 100644 --- a/packages/kernel/test/fixtures/mockCapabilityParallel.ts +++ b/packages/kernel/test/fixtures/mockCapabilityParallel.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** 供测试断言并发重叠:每次 execute 记下 [start, end] 时间戳。 */ export const callLog: { name: string; start: number; end: number }[] = []; @@ -20,7 +20,7 @@ function makeParallelTool(name: string, delayMs: number): Tool { }; } -const provider: CapabilityProvider = { +const provider: Capability = { name: "par", description: "测试用:可并行的工具", activate() {}, @@ -29,8 +29,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts index a8f7e05..6cfecd6 100644 --- a/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts +++ b/packages/kernel/test/fixtures/mockCapabilityPreToolUse.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext, HookBinding, PreToolUseDecision } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, HookBinding, PreToolUseDecision } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** * 供测试临时覆写 PreToolUse 决策(deny / 改写 input);每个 test 用后需重置为 undefined。 @@ -26,7 +26,7 @@ const echoTool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "mock", description: "测试用:回显工具(配 PreToolUse 探针)", activate() {}, @@ -35,8 +35,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts index 75ec588..04cb9cb 100644 --- a/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts +++ b/packages/kernel/test/fixtures/mockCapabilityRogueHooks.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext, HookBinding } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext, HookBinding } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; /** * 一个「越权」插件:它在自己对象上留了 `getHookHandlers`,想 deny 掉工具调用。 @@ -22,7 +22,7 @@ const rogueHooks: HookBinding[] = [ { event: "PreToolUse", handler: () => ({ decision: "deny" as const, reason: "插件想拦" }) }, ]; -const provider: CapabilityProvider & { getHookHandlers(): HookBinding[] } = { +const provider: Capability & { getHookHandlers(): HookBinding[] } = { // 用 "mock" 而不是 "rogue":工具名会加 provider 前缀,得和 mockLlmWithTool 调的 // `mock__echo` 对上,否则测到的是「工具没找到」而不是「hook 有没有生效」。 name: "mock", @@ -32,8 +32,8 @@ const provider: CapabilityProvider & { getHookHandlers(): HookBinding[] } = { getHookHandlers: (): HookBinding[] => rogueHooks, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts b/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts index 4d3ef58..e4bd1c3 100644 --- a/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts +++ b/packages/kernel/test/fixtures/mockCapabilityWithDescribe.ts @@ -1,5 +1,5 @@ -import type { CapabilityProvider, Tool, KernelContext } from "@helios/ports"; -import { CAPABILITY_PROVIDER_API_VERSION } from "@helios/ports"; +import type { Capability, Tool, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; // 给 @helios/host bindSession 测试用的固定装置:一个自带 describe 的工具。 const echoTool: Tool = { @@ -19,7 +19,7 @@ const echoTool: Tool = { }, }; -const provider: CapabilityProvider = { +const provider: Capability = { name: "mock", description: "测试用:带 describe 的工具", activate() {}, @@ -28,8 +28,8 @@ const provider: CapabilityProvider = { }, }; -export const apiVersion = CAPABILITY_PROVIDER_API_VERSION; -export function create(_ctx: KernelContext): CapabilityProvider { +export const apiVersion = CAPABILITY_API_VERSION; +export function create(_ctx: KernelContext): Capability { return provider; } export default { apiVersion, create }; diff --git a/packages/kernel/test/hook-authority.test.ts b/packages/kernel/test/hook-authority.test.ts index 7fa3839..9d196fe 100644 --- a/packages/kernel/test/hook-authority.test.ts +++ b/packages/kernel/test/hook-authority.test.ts @@ -3,7 +3,7 @@ // 判据:`deny` 一票否决之所以成立,是因为 hooks.json 由用户自己写。让插件也能行使 // 这份否决权,等于「装一个数据分析插件」就默认授权它拦截所有工具调用。 // -// ⚠️ 这条边界坏掉零症状:谁给 CapabilityProvider 加回 getHookHandlers 并在 kernel 里 +// ⚠️ 这条边界坏掉零症状:谁给 Capability 加回 getHookHandlers 并在 kernel 里 // 注册,所有行为测试照样全绿——多注册一个 hook 不会让任何现有断言变红。 // 所以这里既做结构护栏(读源码查符号),也做行为护栏(插件自带的 hook 不该生效)。 @@ -39,14 +39,14 @@ function stripComments(text: string): string { } describe("结构:插件契约里没有 hook 入口", () => { - it("CapabilityProvider 不含 getHookHandlers", () => { + it("Capability 不含 getHookHandlers", () => { expect(stripComments(portsSrc("capability.ts"))).not.toContain("getHookHandlers"); }); it("kernel 激活插件时不注册任何 hook", () => { - // activateProvider 只做两件事:activate + 收工具。 + // activateCapability 只做两件事:activate + 收工具。 const text = stripComments(src("kernel.ts")); - const body = text.slice(text.indexOf("private async activateProvider")); + const body = text.slice(text.indexOf("private async activateCapability")); const activate = body.slice(0, body.indexOf("\n createSession(")); expect(activate).not.toContain("hooks.register"); expect(activate).toContain("this.tools.add"); diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 457713f..925d02d 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -298,6 +298,23 @@ export const DEFECTS: Defect[] = [ summary: "把 Date.now() 拼进每轮都要复用的系统前缀,前缀逐轮不同,prompt cache 整段作废", }, + // --- 对象身份 / 原型丢失类。这一类共同的形状是:一句「看起来只是复制一份再改个字段」的代码, + // 对普通对象完全正确,对 class 实例就把原型上的方法整个丢掉。TS 也拦不住—— + // 展开后的类型仍然满足接口,只有运行到那个方法才炸。 + { + id: "spread-drops-prototype", + file: "src/registry/decorate.ts", + signals: [["展开", "原型"], ["spread", "prototype"], ["...", "class", "方法"]], + summary: + "withLabel 用 `{ ...plugin, label }` 复制插件,plugin 是 class 实例时原型上的 run() 全部丢失", + }, + { + id: "identity-compare-after-copy", + file: "src/registry/decorate.ts", + signals: [["indexof", "复制"], ["引用相等", "失效"], ["===", "副本"]], + summary: + "register 里用 indexOf 做去重,但存进去的是 withLabel 产出的副本,引用不再相等,同一个插件能被重复注册", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -719,6 +736,47 @@ export async function sendWithRetry( if (first.ok) return first; return send({ ...buildRetryRequest(url, token, traceId), timeoutMs: RETRY_TIMEOUT_MS }); } +`, + "src/registry/decorate.ts": `/** + * 插件注册表。 + * + * ## 约定 + * + * - 插件可以是对象字面量,也可以是 class 实例(\`AnalyticsPlugin\` 就是), + * 注册表不得假设它是哪一种。 + * - 同一个插件重复注册应当被忽略。 + */ + +export interface Plugin { + name: string; + label?: string; + run(input: string): string; +} + +/** class 形态的插件,方法在原型上。 */ +export class AnalyticsPlugin implements Plugin { + name = "analytics"; + run(input: string): string { + return \`analytics:\${input}\`; + } +} + +export function withLabel(plugin: Plugin, label: string): Plugin { + return { ...plugin, label }; +} + +export class Registry { + private readonly plugins: Plugin[] = []; + + register(plugin: Plugin, label: string): void { + if (this.plugins.indexOf(plugin) >= 0) return; + this.plugins.push(withLabel(plugin, label)); + } + + runAll(input: string): string[] { + return this.plugins.map((p) => p.run(input)); + } +} `, "src/prompt/toolCatalog.ts": `/** * 拼装喂给模型的系统前缀。 diff --git a/packages/ports/src/capability.ts b/packages/ports/src/capability.ts index db87a90..f61cdeb 100644 --- a/packages/ports/src/capability.ts +++ b/packages/ports/src/capability.ts @@ -1,6 +1,6 @@ import type { KernelContext, Tool } from "./types"; -export const CAPABILITY_PROVIDER_API_VERSION = 1; +export const CAPABILITY_API_VERSION = 1; /** * extension:kernel **不调**它的业务方法,只从它那里收工具(对标 pi 的 Extension)。 @@ -14,8 +14,8 @@ export const CAPABILITY_PROVIDER_API_VERSION = 1; * 不伪装成用户的权限拒绝。宿主(CLI/Electron,代表用户)要注册进程内 hook, * 走 `KernelOptions.hooks`。 */ -export interface CapabilityProvider { - /** provider 命名空间前缀,如 'lsp' / 'mcp:filesystem'(内建工具例外,不加前缀) */ +export interface Capability { + /** 命名空间前缀,如 'lsp' / 'mcp:filesystem'——它的工具会加上这个前缀(内建工具例外) */ readonly name: string; /** * 一句话说明这一组能力是干什么的,**进 system 前缀的已装能力清单**。 diff --git a/packages/ports/src/mount.ts b/packages/ports/src/mount.ts index ba5ebbd..0666239 100644 --- a/packages/ports/src/mount.ts +++ b/packages/ports/src/mount.ts @@ -272,7 +272,7 @@ export type AnyMount = { [A in MountPoint]: Mount }[MountPoint]; /** * 一组挂载声明,由某个策略打包交出。`name` 只用于日志与排障。 * - * ⚠️ 别跟 `./capability.ts` 的 `CapabilityProvider` 混:那是**插件** + * ⚠️ 别跟 `./capability.ts` 的 `Capability` 混:那是**插件** * (供工具 + hook 订阅,对标 pi 的 Extension),这是**挂载声明的载体**。 * 曾经这个类型叫 `MountedCapability`,与前者共用"capability"一词, * 讨论时永远要先问一句"你说的是哪个 capability"——所以改掉了。 diff --git a/packages/ports/src/types.ts b/packages/ports/src/types.ts index 04aefdb..e66c233 100644 --- a/packages/ports/src/types.ts +++ b/packages/ports/src/types.ts @@ -503,7 +503,7 @@ export interface PluginModule { /** * 可选:声明本插件要挂在哪些时机上。 * - * **放在 `PluginModule` 而不是 `CapabilityProvider` 上,因为想挂载的不只是插件。** + * **放在 `PluginModule` 而不是 `Capability` 上,因为想挂载的不只是插件。** * 判据:**Port 的方法签名有没有把调用时机钉死?** * * - 钉死了(`CostMeterPort.onLLMCall`、`ModelRouterPort.route(payload)`……)→ From 85d3dcd484dde6259a113da55844da6bf572c986 Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Mon, 31 Aug 2026 16:08:30 +0800 Subject: [PATCH 08/11] =?UTF-8?q?feat(ports,kernel):=20AgentSkill=20?= =?UTF-8?q?=E5=A5=91=E7=BA=A6=20+=20=E4=B8=89=E6=9D=A5=E6=BA=90=E6=B1=87?= =?UTF-8?q?=E5=90=88=EF=BC=8Ccapability-fs=20=E9=87=8D=E5=86=99=E4=B8=BA?= =?UTF-8?q?=20cap-skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skill 是一个**目录**:SKILL.md 是说明书,旁边可以躺 scripts/*.{mjs,py}。模型读说明书、 用 Bash 跑脚本,脚本本身永不进上下文——这是它与 清单的分工:清单常驻一句话, skill 按需展开一整页。 ## 三来源汇合,渲染只有一个出口 | 来源 | 谁扫 | 读法 | |---|---|---| | 用户级 ~/.helios/skills/*/SKILL.md | kernel(skills/scan.ts) | node fs 直读(目录在 workDir 外,过 WorkDirGuard 必被拒;与 hooks.json、全局 AGENTS.md 同一条先例) | | 项目级 /.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: ` 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 为空,故不进 清单: 它不是装出来的包,是 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 --- apps/cli/package.json | 2 +- apps/electron/package.json | 2 +- apps/web/package.json | 2 +- docs/PROJECT_INST.md | 2 +- docs/[IP]loop-decoupling.md | 4 +- docs/code-organization.md | 34 ++- helios.config.json | 2 +- packages/cap-skills/EXTENSION.md | 36 +++ .../package.json | 2 +- packages/cap-skills/src/index.ts | 85 ++++++ .../tsconfig.json | 0 packages/capability-fs/EXTENSION.md | 26 -- packages/capability-fs/src/index.ts | 67 ----- packages/kernel/src/builtin/capability.ts | 4 +- packages/kernel/src/builtin/tools.ts | 2 +- packages/kernel/src/index.ts | 2 + packages/kernel/src/kernel.ts | 57 +++- packages/kernel/src/pluginLoader.ts | 10 +- packages/kernel/src/prompt/systemPrompt.ts | 9 +- packages/kernel/src/skills/scan.ts | 57 ++++ packages/kernel/src/skills/tools.ts | 49 ++++ packages/kernel/test/agent-skills.test.ts | 275 ++++++++++++++++++ .../kernel/test/fixtures/capSkillsThrows.ts | 19 ++ .../kernel/test/fixtures/capWithSkills.ts | 28 ++ .../test/live/code-review.eval.live.test.ts | 25 ++ .../kernel/test/live/fixtures/evalProject.ts | 81 ++++++ .../test/live/skills.verify.live.test.ts | 226 ++++++++++++++ packages/kernel/test/p1-impls.test.ts | 59 +++- packages/kernel/test/prompt.test.ts | 6 +- packages/ports/src/capability.ts | 10 + packages/ports/src/index.ts | 1 + packages/ports/src/skill.ts | 65 +++++ packages/ports/src/types.ts | 4 +- pnpm-lock.yaml | 14 +- tsconfig.base.json | 2 +- vitest.config.ts | 4 +- 36 files changed, 1130 insertions(+), 143 deletions(-) create mode 100644 packages/cap-skills/EXTENSION.md rename packages/{capability-fs => cap-skills}/package.json (86%) create mode 100644 packages/cap-skills/src/index.ts rename packages/{capability-fs => cap-skills}/tsconfig.json (100%) delete mode 100644 packages/capability-fs/EXTENSION.md delete mode 100644 packages/capability-fs/src/index.ts create mode 100644 packages/kernel/src/skills/scan.ts create mode 100644 packages/kernel/src/skills/tools.ts create mode 100644 packages/kernel/test/agent-skills.test.ts create mode 100644 packages/kernel/test/fixtures/capSkillsThrows.ts create mode 100644 packages/kernel/test/fixtures/capWithSkills.ts create mode 100644 packages/kernel/test/live/skills.verify.live.test.ts create mode 100644 packages/ports/src/skill.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index d4a78bb..a851a42 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,7 +21,7 @@ "@helios/checkpoint-fs": "workspace:*", "@helios/compact-default": "workspace:*", "@helios/teams-mailbox": "workspace:*", - "@helios/capability-fs": "workspace:*", + "@helios/cap-skills": "workspace:*", "@helios/costmeter-default": "workspace:*", "@helios/tui": "workspace:*" } diff --git a/apps/electron/package.json b/apps/electron/package.json index 17d9f78..59e91bf 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -27,7 +27,7 @@ "@helios/checkpoint-fs": "workspace:*", "@helios/compact-default": "workspace:*", "@helios/teams-mailbox": "workspace:*", - "@helios/capability-fs": "workspace:*", + "@helios/cap-skills": "workspace:*", "@helios/costmeter-default": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/apps/web/package.json b/apps/web/package.json index 7890e8a..18b4889 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,7 @@ "@helios/checkpoint-fs": "workspace:*", "@helios/compact-default": "workspace:*", "@helios/teams-mailbox": "workspace:*", - "@helios/capability-fs": "workspace:*", + "@helios/cap-skills": "workspace:*", "@helios/costmeter-default": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/docs/PROJECT_INST.md b/docs/PROJECT_INST.md index 748fc1b..5a6d2d3 100644 --- a/docs/PROJECT_INST.md +++ b/docs/PROJECT_INST.md @@ -48,7 +48,7 @@ Default/adaptor packages: Capability and model packages: -- `packages/capability-fs` +- `packages/cap-skills` - `packages/cap-cron` - `packages/cap-lsp` - `packages/cap-mcp` diff --git a/docs/[IP]loop-decoupling.md b/docs/[IP]loop-decoupling.md index d9369fe..9da5b1b 100644 --- a/docs/[IP]loop-decoupling.md +++ b/docs/[IP]loop-decoupling.md @@ -620,8 +620,8 @@ P2.7 判定「hook 与 mount 是两套机制,不能嵌套」——对,但** 类型 `MountedCapability` → `MountBundle`。 > 后续修订:`CapabilityProvider` 已改名为 `Capability`——`getHookHandlers` 删掉之后, -> "Provider" 这半截不再指代任何东西。`cap-*` 包名待第 4 步随 `capability-fs` -> 重写一并处理。上面保留当时的原名,是历史记录。 +> "Provider" 这半截不再指代任何东西。`capability-fs` 已随 `AgentSkill` 一并重写为 +> `@helios/cap-skills`(只交出 skill、不再自己渲染工具)。上面保留当时的原名,是历史记录。 **b) `PluginModule.mounts(instance, ctx)`。** 任何插件包都能自己声明挂载, kernel 用 `splitPluginMounts()` 按作用域拆进会话级 / run 级两个注册表。 diff --git a/docs/code-organization.md b/docs/code-organization.md index b06bb74..3e6b8f0 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -116,7 +116,7 @@ mount 之于 policy,正如 Port 之于 adapter:**前者是类型,后者是 ```jsonc { "ports": [{ "port": "MemoryPort", "package": "…", "requires": ["FileSystemPort"] }], - "extensions": [{ "package": "@helios/capability-fs" }] + "extensions": [{ "package": "@helios/cap-skills" }] } ``` @@ -157,6 +157,38 @@ system 前缀,位置在 `project_context` 与 `env` 之间——它和 env 一 ⚠️ live eval 的 manifest **没有 extensions 段**,所以它跑不到这段代码—— 验证这一节只能用真实 `helios.config.json` 起 Kernel 实跑。 +### skill:按需展开的第三条腿 + +skill 是**一个目录**:`SKILL.md` 是说明书,旁边可以躺 `scripts/*.{mjs,py}` 与资源。 +模型读说明书,再用 Bash 去跑脚本——**脚本本身永不进上下文**,这是它省 token 的原理。 +所以它与 `` 清单是分工而非重复:清单常驻一句话,skill 按需展开一整页。 + +三个来源汇合,**渲染成工具只有 `kernel/src/skills/tools.ts` 的 `toSkillTools` 一个出口**: + +| 来源 | 谁扫 | 读法 | +|---|---|---| +| 用户级 `~/.helios/skills/*/SKILL.md` | kernel(`skills/scan.ts`) | node fs 直读——目录在 workDir 外,过 `WorkDirGuard` 必被拒。与 `hooks.json`、全局 `AGENTS.md` 同一条先例 | +| 项目级 `/.helios/skills/*/SKILL.md` | `@helios/cap-skills` 的 `getSkills()` | `FileSystemPort`(在 workDir 内,理应受守卫约束) | +| extension 自带 | 各 capability 的 `getSkills()` | 生产者自己说了算——SKILL.md 躺在它自己的 npm 包里,根本不在 workDir 内 | + +`AgentSkill` 因此带一个 `load()` 闭包而不是一个路径:**谁能读什么由生产者决定**。 + +- **`description` 必填非空,缺失即跳过并 warn**,不用目录名兜一个:描述是模型判断 + 「该不该翻开这一页」的唯一依据,凑出来的等于挂了个永远不会被正确选中的工具。 +- **加载时必须把 base directory 前置**,否则 SKILL.md 里 `运行 scripts/analyze.py` + 这类相对路径无处可解,带脚本的 skill 直接不可用。 +- **工具名豁免命名空间前缀**(不叫 `skills__foo`):名字是作者在 frontmatter 里起的。 + 推论:base prompt 讲 skill 那条**不能靠命名规律**(曾写成「名为 `caps__*` 的工具」), + 只能靠 description。 +- **同名冲突后来者不覆盖 + warn**:valos 那边静默覆盖(`nameMap.set`)的教训。 +- `SKILL.md` 与 `EXTENSION.md` 是同一种 frontmatter 格式,共用 `ports` 的 + `parseFrontmatter`——各写一份必然漂移(曾经真的各有一对正则)。 +- 合成的 `skills` capability `description` 为空,故**不进 `` 清单**: + 它不是装出来的包,是 kernel 内部的汇合点。 + +⚠️ 这一节同样是**模型可见文本**(工具 description、前置的 base directory), +护栏在 `test/agent-skills.test.ts`,13 处变异全覆盖。 + ⚠️ **payload 里永远不会有 `ports`。** 策略要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 diff --git a/helios.config.json b/helios.config.json index c9299e0..e876f2b 100644 --- a/helios.config.json +++ b/helios.config.json @@ -36,5 +36,5 @@ } } ], - "extensions": [{ "package": "@helios/capability-fs" }] + "extensions": [{ "package": "@helios/cap-skills" }] } diff --git a/packages/cap-skills/EXTENSION.md b/packages/cap-skills/EXTENSION.md new file mode 100644 index 0000000..11fd064 --- /dev/null +++ b/packages/cap-skills/EXTENSION.md @@ -0,0 +1,36 @@ +--- +description: Load project-local skills — each is a folder of instructions (sometimes with scripts) for a recurring task. Check the skill list before improvising a workflow one of them already covers. +--- + +# cap-skills + +扫 `/.helios/skills//SKILL.md`,把每个目录交成一个 `AgentSkill`。 + +## 它不渲染工具 + +`getSkills()` 只交出描述,渲染成工具由 kernel 的 `toSkillTools` 独占。三种来源 +(用户级 `~/.helios/skills/`、项目级本包、extension 自带)因此形状一致——曾经各拼各的, +结果 base directory 有的告诉有的不告诉,模型行为随来源而飘。 + +## 这个文件是干什么的 + +`description` 那一行会进 system 前缀的**已装能力清单**,是模型判断「要不要用这一组」 +的唯一依据。它覆盖 `src/index.ts` 里 `SkillsCapability.description` 的取值—— +改措辞或做本地化不必改代码。 + +**正文(也就是这一段往下)v1 不读。** 每装一个插件就往冻结的 system 前缀里塞一段正文, +token 与信噪比都吃不消;按需展开正是 skill 这套机制要解决的事,不是这里。 + +## SKILL.md 的形状 + +```markdown +--- +name: db-migrate # 可选,缺省用目录名 +description: 一句话说清它覆盖哪件事 # 必填,缺失则整个 skill 跳过 +--- + +正文:怎么做这件事。要跑脚本就写相对路径,加载时会前置告诉模型 base directory。 +``` + +`description` 缺失时**跳过**,不用目录名兜一个:描述是模型判断「该不该翻开这一页」的 +唯一依据,凑出来的等于挂了个永远不会被正确选中的工具。 diff --git a/packages/capability-fs/package.json b/packages/cap-skills/package.json similarity index 86% rename from packages/capability-fs/package.json rename to packages/cap-skills/package.json index 65acfae..e94210d 100644 --- a/packages/capability-fs/package.json +++ b/packages/cap-skills/package.json @@ -1,5 +1,5 @@ { - "name": "@helios/capability-fs", + "name": "@helios/cap-skills", "version": "0.0.0", "private": true, "type": "module", diff --git a/packages/cap-skills/src/index.ts b/packages/cap-skills/src/index.ts new file mode 100644 index 0000000..858b051 --- /dev/null +++ b/packages/cap-skills/src/index.ts @@ -0,0 +1,85 @@ +import type { + AgentSkill, + Capability, + KernelContext, + FileSystemPort, +} from "@helios/ports"; +import { CAPABILITY_API_VERSION, parseFrontmatter } from "@helios/ports"; + +// @helios/cap-skills —— 项目级 skill 的生产者。 +// +// 扫 `/.helios/skills//SKILL.md`,交出 AgentSkill 描述,**不自己渲染工具**: +// 渲染只有 kernel 的 toSkillTools 一个出口,否则三种来源(用户级 / 项目级 / extension 自带) +// 各拼一遍,模型看到的形状会随来源而飘。 +// +// 走 FileSystemPort 而不是 node fs:这批文件在 workDir 内,理应受 WorkDirGuard 与实现替换 +// 的约束(用户级目录在 workDir 外,才由 kernel 用 node fs 直读)。 + +const SKILL_GLOB = ".helios/skills/*/SKILL.md"; + +class SkillsCapability implements Capability { + readonly name = "skills-project"; + readonly description = + "Load project-local skills: each one is a folder of instructions (and sometimes scripts) for a recurring task. Load a skill before improvising a workflow it already covers."; + + constructor( + private readonly fs: FileSystemPort, + private readonly workDir: string, + ) {} + + activate(): void { + // 无需预热:getSkills() 每次现扫,新增一个 skill 目录不必重启 kernel。 + } + + async getSkills(): Promise { + const files = await this.fs.glob(SKILL_GLOB); + const skills: AgentSkill[] = []; + for (const path of files) { + const skill = await this.readSkill(path); + if (skill) skills.push(skill); + } + return skills; + } + + private async readSkill(path: string): Promise { + let text: string; + try { + text = await this.fs.readFile(path); + } catch { + return undefined; // glob 命中但读不到(权限 / 竞态删除):跳过而非阻断启动 + } + const { attrs, body } = parseFrontmatter(text); + const description = attrs.description ?? ""; + // description 缺失即跳过,不用目录名兜一个:描述是模型判断「该不该翻开这一页」的 + // 唯一依据,凑出来的描述等于挂了个永远不会被正确选中的工具。 + if (description === "") return undefined; + + const dirName = path.split("/").at(-2) ?? "skill"; + return { + name: (attrs.name ?? dirName).replace(/[^\w.-]/g, "_"), + description, + // 绝对路径:SKILL.md 里写「运行 scripts/analyze.py」时,模型要靠它解相对路径。 + baseDir: joinPath(this.workDir, dirOf(path)), + source: { kind: "project", name: dirName }, + load: async () => body, + }; + } +} + +function dirOf(path: string): string { + const at = path.lastIndexOf("/"); + return at === -1 ? "" : path.slice(0, at); +} + +function joinPath(base: string, rel: string): string { + if (rel === "") return base; + return `${base.replace(/\/$/, "")}/${rel}`; +} + +export const apiVersion = CAPABILITY_API_VERSION; + +export function create(ctx: KernelContext): Capability { + return new SkillsCapability(ctx.ports.fileSystem, ctx.workDir); +} + +export default { apiVersion, create }; diff --git a/packages/capability-fs/tsconfig.json b/packages/cap-skills/tsconfig.json similarity index 100% rename from packages/capability-fs/tsconfig.json rename to packages/cap-skills/tsconfig.json diff --git a/packages/capability-fs/EXTENSION.md b/packages/capability-fs/EXTENSION.md deleted file mode 100644 index 22fa84b..0000000 --- a/packages/capability-fs/EXTENSION.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -description: Load project-local skills — each is a folder of instructions (sometimes with scripts) for a recurring task. Check the skill list before improvising a workflow one of them already covers. ---- - -# capability-fs - -扫描项目里的 skill 目录,把每个 skill 变成一个「加载指引」工具。 - -## 这个文件是干什么的 - -`description` 那一行会进 system 前缀的**已装能力清单**,是模型判断「要不要用这一组」 -的唯一依据。它覆盖 `src/index.ts` 里 `FsCapability.description` 的取值—— -改措辞或做本地化不必改代码。 - -**正文(也就是这一段往下)v1 不读。** 每装一个插件就往冻结的 system 前缀里塞一段正文, -token 与信噪比都吃不消;按需展开是 skill 那套机制要解决的事,不是这里。 -写在这里的内容目前只给人看。 - -## 当前限制 - -- 目录仍是 `.helios/policies/*/SKILL.md`——这个名字是一次全局改名误伤的产物, - 与 `kernel/src/policies/`(内置挂载策略)毫无关系,待改成 `.helios/skills/`。 -- 工具的 description 是模板拼的(`加载 skill「x」的完整指引内容`),没有把 - SKILL.md frontmatter 里的真实描述透出来,模型因此无从判断该调哪个。 -- 加载时不告诉模型 skill 的 base directory,SKILL.md 里引用 `scripts/foo.py` - 这类相对路径不可用。 diff --git a/packages/capability-fs/src/index.ts b/packages/capability-fs/src/index.ts deleted file mode 100644 index d890763..0000000 --- a/packages/capability-fs/src/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { - Capability, - Tool, - KernelContext, - FileSystemPort, -} from "@helios/ports"; -import { CAPABILITY_API_VERSION } from "@helios/ports"; - -// @helios/capability-fs —— Capability 官方文件扫描实现(替代原 Skill+Extension)。 -// 扫描 .helios/policies//SKILL.md,每个目录产出一个工具:调用即返回该 skill 全文, -// 供 Agent 按需加载领域指引。是否做"分层解锁"由本实现自行决定,不属接口契约。 - -const GLOB = ".helios/policies/*/SKILL.md"; - -interface ScannedSkill { - name: string; - path: string; -} - -class FsCapability implements Capability { - readonly name = "caps"; - readonly description = - "Load project-local skills: each one is a folder of instructions (and sometimes scripts) for a recurring task. Load a skill before improvising a workflow it already covers."; - private skills: ScannedSkill[] = []; - constructor(private readonly fs: FileSystemPort) {} - - async activate(): Promise { - const files = await this.fs.glob(GLOB); - this.skills = files.map((path) => { - const parts = path.split("/"); - const dirName = parts[parts.length - 2] ?? "skill"; - return { name: dirName.replace(/[^\w.-]/g, "_"), path }; - }); - } - - getTools(): Tool[] { - return this.skills.map((skill) => this.toTool(skill)); - } - - private toTool(skill: ScannedSkill): Tool { - const fs = this.fs; - return { - name: skill.name, - description: `加载 skill「${skill.name}」的完整指引内容`, - inputSchema: { type: "object", properties: {} }, - async execute() { - try { - const content = await fs.readFile(skill.path); - return { output: content }; - } catch (err) { - return { - output: err instanceof Error ? err.message : String(err), - isError: true, - }; - } - }, - }; - } -} - -export const apiVersion = CAPABILITY_API_VERSION; - -export function create(ctx: KernelContext): Capability { - return new FsCapability(ctx.ports.fileSystem); -} - -export default { apiVersion, create }; diff --git a/packages/kernel/src/builtin/capability.ts b/packages/kernel/src/builtin/capability.ts index 15bb4cd..92048ff 100644 --- a/packages/kernel/src/builtin/capability.ts +++ b/packages/kernel/src/builtin/capability.ts @@ -5,8 +5,8 @@ import { createBuiltinTools } from "./tools"; * 六件套内建 Capability。它与 manifest 装进来的 extension 走完全相同的注册路径, * 唯一区别是 kernel 注册它时豁免工具名前缀(见 Kernel.start)。 * - * 与 `capability-fs` 的 `create(ctx) → new FsCapability(ctx.ports.fileSystem)` 同一模式: - * activate() 时从 KernelContext 拿到具体 Port 实例、闭包造好工具,getTools() 只读取。 + * 与 `cap-skills` 的 `create(ctx) → new SkillsCapability(ctx.ports.fileSystem, …)` 同一模式: + * 构造期从 KernelContext 拿到自己真正要用的那个 Port(不是整个注册表),闭包持有。 */ class BuiltinCapability implements Capability { // description 留空 = 不进「已装 extension」清单。它不是 extension(没有 manifest diff --git a/packages/kernel/src/builtin/tools.ts b/packages/kernel/src/builtin/tools.ts index 8351c70..7dbe8c6 100644 --- a/packages/kernel/src/builtin/tools.ts +++ b/packages/kernel/src/builtin/tools.ts @@ -10,7 +10,7 @@ import type { Tool, ToolContext, FileSystemPort, MultiAgentPort, PortRegistry } // AgentTeam 用 multiAgent)在被造出来的那一刻拿到具体实例、闭包持有;不需要 Port 的工具 // (Bash/WebFetch/AskUserQuestion)构造函数不接收任何 Port 参数。工具的 execute() 因此 // 物理上摸不到自己没被给的能力——不是"声明了就信任"的运行时校验,是结构上不存在 -// (对齐 valos CodeAgent.initBuildinTools() / 本仓 capability-fs 的既有模式)。 +// (对齐 valos CodeAgent.initBuildinTools() / 本仓 cap-skills 的既有模式)。 const BASH_TIMEOUT_DEFAULT = 120_000; const BASH_TIMEOUT_MAX = 600_000; // 硬上限,防 LLM 传超大 timeout 挂死 diff --git a/packages/kernel/src/index.ts b/packages/kernel/src/index.ts index 30ef69a..e91eb84 100644 --- a/packages/kernel/src/index.ts +++ b/packages/kernel/src/index.ts @@ -61,6 +61,8 @@ export type { ProjectInstructionFile, LoadProjectInstructionsOptions, } from "./prompt/projectInstructions"; +export { scanSkillRoot } from "./skills/scan"; +export { toSkillTools } from "./skills/tools"; export { createBuiltinTools, createBashTool, diff --git a/packages/kernel/src/kernel.ts b/packages/kernel/src/kernel.ts index 2c4f354..d682783 100644 --- a/packages/kernel/src/kernel.ts +++ b/packages/kernel/src/kernel.ts @@ -7,6 +7,8 @@ import type { AskQuestionResponse, MountBundle, HookBinding, + Capability, + AgentSkill, } from "@helios/ports"; import { ServiceCollection } from "./serviceCollection"; import { IFileSystemPort } from "./tokens"; @@ -30,7 +32,13 @@ import { join } from "node:path"; import { SESSION_LOG_FILE } from "./persistence/sessionLog"; import { platform, release } from "node:os"; import { BASE_SYSTEM_PROMPT, buildEnvBlock, buildExtensionsBlock } from "./prompt/systemPrompt"; -import { loadProjectInstructions, renderProjectInstructions } from "./prompt/projectInstructions"; +import { + loadProjectInstructions, + renderProjectInstructions, + resolveGlobalInstructionDir, +} from "./prompt/projectInstructions"; +import { scanSkillRoot } from "./skills/scan"; +import { toSkillTools } from "./skills/tools"; import { createLangSmithTracer, type Tracer } from "@helios/observability-langsmith"; import { DerivedAgentExecutor } from "./agentSpec/derivedAgentExecutor"; import { createAgentCapability } from "./agentSpec/agentTools"; @@ -190,12 +198,18 @@ export class Kernel { this.projectInstructions = prompt.projectInstructions; this.envBlock = prompt.envBlock; - // 六件套内建 provider —— 命名豁免前缀,其余一切与用户 provider 相同 + // 六件套内建 Capability —— 命名豁免前缀,其余一切与 extension 相同 await this.activateCapability(builtinCapability, ctx, true); for (const { capability } of capabilities) { await this.activateCapability(capability, ctx, false); } + // skill 三来源汇总:用户级目录由 kernel 自己扫(与 hooks.json、全局 AGENTS.md 同一条 + // 先例),项目级与 extension 自带的由各 capability 的 getSkills() 交出来——所以必须 + // 排在它们 activate 之后。渲染成工具只有 toSkillTools 一个出口,保证模型看到的形状 + // 不随来源而变。工具名豁免前缀:skill 名字是作者起的,不该被套上 skills__。 + await this.activateCapability(await this.buildSkillCapability(capabilities), ctx, true); + // 派生 agent 工具。工具名同样豁免前缀(不该叫 agentspec__Agent)。 // 它拿全量工具池用的是惰性闭包 `() => this.tools.list()`,所以放在最后激活也不影响 // 子能拿到的工具集 —— 顺序无关是刻意的,将来插入新 provider 不会改变行为。 @@ -243,6 +257,45 @@ export class Kernel { * 只列 manifest 声明的 extension:内建六件套与派生 agent 工具不是 extension, * 它们的工具本来就直接可见,再列一遍是噪音。 */ + /** + * 把三来源的 skill 收成一个合成 Capability。 + * + * 做成 Capability 而不是直接往 ToolRegistry 里塞:这样它与其他能力共用同一条 + * 注册路径(前缀豁免、disabledBuiltinTools 过滤都自动生效),不必在 kernel 里 + * 另开一条只服务 skill 的支路。 + */ + private async buildSkillCapability(extensions: readonly LoadedExtension[]): Promise { + const skills: AgentSkill[] = []; + + // 与项目指令、hooks.json 同一条口径:显式传空串即关闭(测试用来避开开发者本机的真实目录)。 + const userRoot = this.opts.globalInstructionDir ?? resolveGlobalInstructionDir(); + if (userRoot) { + skills.push( + ...(await scanSkillRoot(join(userRoot, "skills"), { kind: "user", name: userRoot }, this.logger)), + ); + } + + for (const { capability } of extensions) { + if (typeof capability.getSkills !== "function") continue; + try { + skills.push(...(await capability.getSkills())); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`capability ${capability.name} 的 getSkills() 失败:${msg}`); + } + } + + const tools = toSkillTools(skills, this.logger); + return { + name: "skills", + // 留空 = 不进「已装 extension」清单。skill 不是 extension,它们各自的 + // description 已经在工具列表里逐条可见了。 + description: "", + activate: () => {}, + getTools: () => tools, + }; + } + private async buildSystemPrompt(extensions: readonly LoadedExtension[]): Promise<{ system: string; projectInstructions: string; diff --git a/packages/kernel/src/pluginLoader.ts b/packages/kernel/src/pluginLoader.ts index 35d8850..db4c0f0 100644 --- a/packages/kernel/src/pluginLoader.ts +++ b/packages/kernel/src/pluginLoader.ts @@ -14,6 +14,7 @@ import { COST_METER_PORT_API_VERSION, TOOL_RESULT_CACHE_PORT_API_VERSION, VERSION_PROVIDER_PORT_API_VERSION, + parseFrontmatter, } from "@helios/ports"; import type { KernelContext, @@ -391,10 +392,11 @@ export async function readExtensionDoc(entrySpec: string): Promise//SKILL.md`,一层,不递归。 + * + * 用 node fs 而不是 `FileSystemPort`:用户级根目录(`~/.helios/skills/`)在 workDir + * 之外,过 `WorkDirGuard` 必被拒。这与 `hookConfigLoader` 读 `~/.helios/hooks.json`、 + * `projectInstructions` 读全局 AGENTS.md 是同一条先例。 + * + * **description 缺失的目录会被跳过并 warn**,不是用文件名兜个默认值: + * 描述是模型判断「该不该翻开这一页」的唯一依据,凑一个出来等于挂了个永远不会被 + * 正确选中的工具,比不挂更糟。 + */ +export async function scanSkillRoot( + root: string, + source: SkillSource, + logger: Logger, +): Promise { + let entries: string[]; + try { + entries = (await readdir(root, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + } catch { + return []; // 目录不存在是常态,不是错误 + } + + const skills: AgentSkill[] = []; + for (const dirName of entries) { + const baseDir = join(root, dirName); + const entry = join(baseDir, "SKILL.md"); + let text: string; + try { + text = await readFile(entry, "utf8"); + } catch { + continue; // 目录里没有 SKILL.md:不是 skill,静默跳过 + } + const { attrs, body } = parseFrontmatter(text); + const description = attrs.description ?? ""; + if (description === "") { + logger.warn(`skill ${entry} 的 frontmatter 缺 description,跳过`); + continue; + } + skills.push({ + name: (attrs.name ?? dirName).replace(/[^\w.-]/g, "_"), + description, + baseDir, + source, + load: async () => body, + }); + } + return skills; +} diff --git a/packages/kernel/src/skills/tools.ts b/packages/kernel/src/skills/tools.ts new file mode 100644 index 0000000..273acfc --- /dev/null +++ b/packages/kernel/src/skills/tools.ts @@ -0,0 +1,49 @@ +import type { AgentSkill, Logger, Tool } from "@helios/ports"; + +/** + * 把 skill 渲染成工具。**唯一的渲染点**——三种来源(用户级目录 / 项目级目录 / + * extension 自带)在这里汇合,保证模型看到的形状一致。 + * + * 曾经每个来源各拼各的,结果 base directory 有的告诉有的不告诉,模型行为随来源而飘。 + */ +export function toSkillTools(skills: readonly AgentSkill[], logger: Logger): Tool[] { + const taken = new Map(); + const tools: Tool[] = []; + + for (const skill of skills) { + const clash = taken.get(skill.name); + if (clash) { + // 后来者不覆盖:静默覆盖过一次同名 Skill 的教训在 valos 那边吃过, + // 这里宁可少装一个也要说清楚是谁撞了谁。 + logger.warn( + `skill 名冲突:${skill.source.kind}/${skill.source.name} 的「${skill.name}」` + + `与已注册的 ${clash.source.kind}/${clash.source.name} 同名,跳过后者`, + ); + continue; + } + taken.set(skill.name, skill); + tools.push(toTool(skill)); + } + return tools; +} + +function toTool(skill: AgentSkill): Tool { + return { + name: skill.name, + description: skill.description, + inputSchema: { type: "object", properties: {} }, + async execute() { + try { + const body = await skill.load(); + // base directory 必须前置:SKILL.md 里写「运行 scripts/analyze.py」时, + // 模型没有这一行就无处解这个相对路径,带脚本的 skill 直接不可用。 + return { output: `Base directory for this skill: ${skill.baseDir}\n\n${body.trim()}` }; + } catch (err) { + return { + output: err instanceof Error ? err.message : String(err), + isError: true, + }; + } + }, + }; +} diff --git a/packages/kernel/test/agent-skills.test.ts b/packages/kernel/test/agent-skills.test.ts new file mode 100644 index 0000000..d395e35 --- /dev/null +++ b/packages/kernel/test/agent-skills.test.ts @@ -0,0 +1,275 @@ +// AgentSkill:三来源汇合,渲染只有 toSkillTools 一个出口。 +// +// 这里大半是**模型可见文本**(工具的 description、加载时前置的 base directory), +// 坏了不会有任何测试自然变红:工具照常能调、对话照常能跑,只是模型判断不出该调哪个、 +// 或者解不开 SKILL.md 里的相对路径。所以每一条都要显式断言,不能靠"跑通了"。 + +import { describe, it, expect, beforeEach } from "vitest"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentSkill, AskQuestionRequest, AskQuestionResponse, Logger } from "@helios/ports"; +import { parseFrontmatter } from "@helios/ports"; +import { Kernel, scanSkillRoot, toSkillTools, type Manifest } from "../src/index"; + +function fixture(name: string): string { + return fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)); +} +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ + answers: ["允许"], +}); + +function collectingLogger(): { logger: Logger; warns: string[] } { + const warns: string[] = []; + return { + warns, + logger: { debug() {}, info() {}, warn: (m) => warns.push(String(m)), error() {} }, + }; +} + +function skill(over: Partial = {}): AgentSkill { + return { + name: "demo", + description: "干某件事", + baseDir: "/base/demo", + source: { kind: "user", name: "/base" }, + load: async () => "正文", + ...over, + }; +} + +describe("parseFrontmatter", () => { + it("解析 `---` 包起来的 key: value,正文不含 frontmatter", () => { + const { attrs, body } = parseFrontmatter("---\nname: a\ndescription: b\n---\nbody\n"); + expect(attrs).toEqual({ name: "a", description: "b" }); + expect(body).toBe("body\n"); + }); + + it("没有 frontmatter 时整段都是正文,attrs 为空", () => { + const { attrs, body } = parseFrontmatter("# 只有正文"); + expect(attrs).toEqual({}); + expect(body).toBe("# 只有正文"); + }); + + it("去掉引号包裹;不识别的行忽略", () => { + const { attrs } = parseFrontmatter("---\ndescription: \"带引号\"\n- 列表项\n---\n"); + expect(attrs.description).toBe("带引号"); + expect(Object.keys(attrs)).toEqual(["description"]); + }); +}); + +describe("toSkillTools", () => { + it("加载时把 base directory 前置:SKILL.md 里的相对路径要靠它才解得开", async () => { + const [tool] = toSkillTools([skill({ baseDir: "/x/y", load: async () => "跑 scripts/a.py" })], silent); + const res = await tool!.execute({}, { + workDir: "/x", + sessionId: "s", + logger: silent, + askQuestion: noAsk, + }); + expect(res.output).toContain("/x/y"); + expect(res.output).toContain("跑 scripts/a.py"); + }); + + it("工具的 description 就是 frontmatter 里那句,不是模板拼的", () => { + const [tool] = toSkillTools([skill({ description: "迁移数据库" })], silent); + expect(tool!.description).toBe("迁移数据库"); + expect(tool!.description).not.toContain("加载"); + }); + + it("同名冲突:后来者不覆盖,并 warn 说清谁撞了谁", () => { + const { logger, warns } = collectingLogger(); + const tools = toSkillTools( + [ + skill({ description: "先注册的", source: { kind: "user", name: "~/.helios" } }), + skill({ description: "后来的", source: { kind: "project", name: "demo" } }), + ], + logger, + ); + expect(tools).toHaveLength(1); + expect(tools[0]!.description).toBe("先注册的"); + expect(warns).toHaveLength(1); + expect(warns[0]).toContain("demo"); + expect(warns[0]).toContain("~/.helios"); + }); + + it("load() 抛错时返回 isError,不让整轮对话炸掉", async () => { + const [tool] = toSkillTools( + [skill({ load: async () => { throw new Error("读不到"); } })], + silent, + ); + const res = await tool!.execute({}, { + workDir: "/x", + sessionId: "s", + logger: silent, + askQuestion: noAsk, + }); + expect(res.isError).toBe(true); + expect(res.output).toContain("读不到"); + }); +}); + +describe("scanSkillRoot", () => { + let root: string; + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "helios-skillroot-")); + return async () => rm(root, { recursive: true, force: true }); + }); + + async function write(rel: string, text: string): Promise { + await mkdir(join(root, rel.slice(0, rel.lastIndexOf("/"))), { recursive: true }); + await writeFile(join(root, rel), text, "utf8"); + } + + it("扫 //SKILL.md,baseDir 是那个目录的绝对路径", async () => { + await write("alpha/SKILL.md", "---\ndescription: 甲\n---\n正文甲"); + const skills = await scanSkillRoot(root, { kind: "user", name: root }, silent); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("alpha"); + expect(skills[0]!.description).toBe("甲"); + expect(skills[0]!.baseDir).toBe(join(root, "alpha")); + expect(skills[0]!.source).toEqual({ kind: "user", name: root }); + expect(await skills[0]!.load()).toBe("正文甲"); + }); + + it("目录不存在返回空数组:没建过 ~/.helios/skills 是常态,不是错误", async () => { + expect(await scanSkillRoot(join(root, "nope"), { kind: "user", name: "x" }, silent)).toEqual([]); + }); + + it("缺 description 的目录跳过并 warn,不用目录名兜一个默认描述", async () => { + await write("nodesc/SKILL.md", "# 没有 frontmatter"); + await write("ok/SKILL.md", "---\ndescription: 有\n---\n"); + const { logger, warns } = collectingLogger(); + const skills = await scanSkillRoot(root, { kind: "user", name: root }, logger); + expect(skills.map((s) => s.name)).toEqual(["ok"]); + expect(warns.join("\n")).toContain("nodesc"); + }); + + it("没有 SKILL.md 的目录静默跳过(不是 skill,不该 warn)", async () => { + await mkdir(join(root, "just-a-dir"), { recursive: true }); + const { logger, warns } = collectingLogger(); + expect(await scanSkillRoot(root, { kind: "user", name: root }, logger)).toEqual([]); + expect(warns).toEqual([]); + }); + + it("只扫一层,不递归", async () => { + await write("outer/inner/SKILL.md", "---\ndescription: 深处\n---\n"); + expect(await scanSkillRoot(root, { kind: "user", name: root }, silent)).toEqual([]); + }); + + it("frontmatter 的 name 覆盖目录名;非法字符换成下划线", async () => { + await write("dir/SKILL.md", "---\nname: my skill!\ndescription: d\n---\n"); + const skills = await scanSkillRoot(root, { kind: "user", name: root }, silent); + expect(skills[0]!.name).toBe("my_skill_"); + }); +}); + +describe("端到端:三来源汇合成工具", () => { + let workDir: string; + let userDir: string; + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "helios-skills-work-")); + userDir = await mkdtemp(join(tmpdir(), "helios-skills-user-")); + return async () => { + await rm(workDir, { recursive: true, force: true }); + await rm(userDir, { recursive: true, force: true }); + }; + }); + + function manifestWith(extensions: Manifest["extensions"]): Manifest { + return { + ports: [ + { port: "FileSystemPort", package: "@helios/fs-node" }, + { port: "LLMProvider", package: fixture("mockLlmEchoUserPath.ts") }, + ], + extensions, + }; + } + + async function startKernel( + extensions: Manifest["extensions"], + logger: Logger = silent, + ): Promise { + const kernel = new Kernel({ + workDir, + manifest: manifestWith(extensions), + logger, + globalInstructionDir: userDir, + }); + await kernel.start(); + return kernel; + } + + it("用户级目录 + extension 自带的两路 skill 都变成工具,且工具名不带命名空间前缀", async () => { + await mkdir(join(userDir, "skills", "user-one"), { recursive: true }); + await writeFile( + join(userDir, "skills", "user-one", "SKILL.md"), + "---\ndescription: 用户级的\n---\n正文", + "utf8", + ); + const kernel = await startKernel([{ package: fixture("capWithSkills.ts") }]); + const names = kernel.listTools(); + await kernel.dispose(); + // 名字是作者起的,不该被套上 skills__ 前缀。 + expect(names).toContain("user-one"); + expect(names).toContain("bundled-skill"); + expect(names.some((n) => n.startsWith("skills__"))).toBe(false); + }); + + it("项目级 cap-skills 扫 /.helios/skills(不是 .helios/policies)", async () => { + await mkdir(join(workDir, ".helios", "skills", "proj-one"), { recursive: true }); + await writeFile( + join(workDir, ".helios", "skills", "proj-one", "SKILL.md"), + "---\ndescription: 项目级的\n---\n正文", + "utf8", + ); + const kernel = await startKernel([{ package: "@helios/cap-skills" }]); + const tool = kernel.getTool("proj-one"); + await kernel.dispose(); + expect(tool?.description).toBe("项目级的"); + }); + + it("某个 extension 的 getSkills() 抛错时,其余 skill 照常装上", async () => { + await mkdir(join(userDir, "skills", "survivor"), { recursive: true }); + await writeFile( + join(userDir, "skills", "survivor", "SKILL.md"), + "---\ndescription: 活下来了\n---\n", + "utf8", + ); + const errors: string[] = []; + const kernel = await startKernel([{ package: fixture("capSkillsThrows.ts") }], { + debug() {}, + info() {}, + warn() {}, + error: (m) => errors.push(String(m)), + }); + const names = kernel.listTools(); + await kernel.dispose(); + expect(names).toContain("survivor"); + expect(errors.join("\n")).toContain("broken"); + }); + + it("合成的 skills capability 不进 清单", async () => { + // 它不是一个装出来的包,是 kernel 内部的汇合点;列进清单是骗人。 + await mkdir(join(userDir, "skills", "s1"), { recursive: true }); + await writeFile(join(userDir, "skills", "s1", "SKILL.md"), "---\ndescription: d\n---\n", "utf8"); + const kernel = await startKernel([]); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + expect(text).not.toContain("- skills:"); + }); + + it("base prompt 讲 skill 那条不靠工具名前缀(skill 的名字由作者定,没有前缀可依赖)", async () => { + const kernel = await startKernel([]); + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("干活"); + const text = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + await kernel.dispose(); + expect(text).toContain("Some tools are skills"); + expect(text).not.toContain("caps__"); + }); +}); diff --git a/packages/kernel/test/fixtures/capSkillsThrows.ts b/packages/kernel/test/fixtures/capSkillsThrows.ts new file mode 100644 index 0000000..9b397b6 --- /dev/null +++ b/packages/kernel/test/fixtures/capSkillsThrows.ts @@ -0,0 +1,19 @@ +import type { Capability, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; + +// getSkills() 抛错的 extension:一个插件扫盘失败不该拖垮整个 kernel 启动。 + +export const apiVersion = CAPABILITY_API_VERSION; + +export function create(_ctx: KernelContext): Capability { + return { + name: "broken", + description: "测试用:getSkills() 抛错", + activate() {}, + getSkills() { + throw new Error("扫盘炸了"); + }, + }; +} + +export default { apiVersion, create }; diff --git a/packages/kernel/test/fixtures/capWithSkills.ts b/packages/kernel/test/fixtures/capWithSkills.ts new file mode 100644 index 0000000..84b4374 --- /dev/null +++ b/packages/kernel/test/fixtures/capWithSkills.ts @@ -0,0 +1,28 @@ +import type { AgentSkill, Capability, KernelContext } from "@helios/ports"; +import { CAPABILITY_API_VERSION } from "@helios/ports"; + +// extension 自带 skill:它的 SKILL.md 躺在自己的 npm 包里,**不在 workDir 内**, +// 所以既扫不到也读不到——这正是 AgentSkill 用 load() 闭包而不是路径的原因。 + +export const apiVersion = CAPABILITY_API_VERSION; + +export function create(_ctx: KernelContext): Capability { + return { + name: "shipped", + description: "测试用:自带一个 skill 的 extension", + activate() {}, + getSkills(): AgentSkill[] { + return [ + { + name: "bundled-skill", + description: "随 extension 一起发的 skill", + baseDir: "/pkg/shipped/skills/bundled", + source: { kind: "extension", name: "shipped" }, + load: async () => "跑 scripts/run.mjs", + }, + ]; + }, + }; +} + +export default { apiVersion, create }; diff --git a/packages/kernel/test/live/code-review.eval.live.test.ts b/packages/kernel/test/live/code-review.eval.live.test.ts index 3079153..763735a 100644 --- a/packages/kernel/test/live/code-review.eval.live.test.ts +++ b/packages/kernel/test/live/code-review.eval.live.test.ts @@ -36,6 +36,8 @@ const NUMERIC_DEFECTS = ["inclusive-end-window", "float-money-round", "utc-days- const LAYERING_DEFECTS = ["core-imports-adapter", "core-does-io", "rule-in-adapter"]; /** 第八类:每段单独看都对,错在两段本该一致的代码各自演化后不一致了。 */ const DUPLICATION_DEFECTS = ["duplicated-request-builder", "drifted-default", "silent-optional-drop"]; +/** 第九类:加载器漏掉了解释产物所必需的上下文,或在多来源汇合时静默丢弃一方。 */ +const LOADER_DEFECTS = ["missing-base-dir", "silent-source-override", "fabricated-description"]; const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); @@ -304,6 +306,29 @@ gate(`代码审查 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { 600_000, ); + it( + "按需资源加载专项:产物本身看不出问题,缺的是解释它所必需的上下文", + async () => { + // 第九种阅读方式。前八类的判据都落在被审的那段代码上;这一类的判据在**消费方**—— + // loadGuide 返回的字符串自身完全正常,只有追问「拿到这段字的人怎么解开 scripts/run.mjs」 + // 才看得出漏了根路径。collect 的 map.set 同理:单看是最自然的写法, + // 问题只在「使用者不知道生效的是哪一份」这个后果上。 + const session = kernel.createSession({ askQuestion: noAsk }); + const out = await session.sendMessage( + [ + "只看 src/skills/loader.ts。文件头 JSDoc 写了三条约定。", + "站在消费方的角度检查:拿到 loadGuide 的返回值后,能不能解开正文里的相对脚本路径?", + "多来源汇合时同名条目的处理是否符合约定?描述缺失时的处理是否符合约定?", + "逐条给出函数名和原因。只读,不要改任何文件。", + ].join(""), + ); + const found = detectedDefects(reviewText(out)); + report("loader-context-review", found); + expect(found.filter((id) => LOADER_DEFECTS.includes(id)).length).toBeGreaterThanOrEqual(1); + }, + 600_000, + ); + it( "派生 agent + fork_context:子带着父的上下文开工,且不产生悬空 tool_use", async () => { diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 925d02d..99fe5f3 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -315,6 +315,30 @@ export const DEFECTS: Defect[] = [ summary: "register 里用 indexOf 做去重,但存进去的是 withLabel 产出的副本,引用不再相等,同一个插件能被重复注册", }, + // --- 按需资源加载类。共同的形状是:加载器把「一包资源」压成一段文本交给消费方,却漏掉了 + // 解释这段文本所必需的上下文(根路径),或者在多来源汇合时静默丢弃一方。 + // 两者都不会抛错、不会有测试变红,只会让消费方在运行时无从下手或拿到错的那一份。 + { + id: "missing-base-dir", + file: "src/skills/loader.ts", + signals: [["base", "目录"], ["根", "路径"], ["相对路径", "解"], ["basedir"]], + summary: + "loadGuide 只返回正文,不告诉调用方这包资源的根目录,正文里写的 scripts/run.mjs 这类相对路径无处可解", + }, + { + id: "silent-source-override", + file: "src/skills/loader.ts", + signals: [["静默", "覆盖"], ["同名", "覆盖"], ["后", "覆盖", "前"], ["set", "覆盖"]], + summary: + "collect 用 map.set 汇合多来源,同名条目后扫到的静默覆盖先扫到的,用户完全不知道生效的是哪一份", + }, + { + id: "fabricated-description", + file: "src/skills/loader.ts", + signals: [["描述", "缺失", "兜"], ["目录名", "描述"], ["兜底", "描述"]], + summary: + "描述缺失时用目录名兜一个,等于挂了个永远不会被正确选中的条目,比直接跳过更糟", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -777,6 +801,63 @@ export class Registry { return this.plugins.map((p) => p.run(input)); } } +`, + "src/skills/loader.ts": `/** + * 指引包(guide pack)加载器。 + * + * 一个指引包是**一个目录**:\`GUIDE.md\` 是说明书,旁边可以躺 \`scripts/*.mjs\` 等脚本。 + * 消费方读说明书,再去跑脚本。 + * + * ## 约定 + * + * - 说明书正文里引用脚本一律写相对路径(如 \`scripts/run.mjs\`),所以交付正文时 + * 必须同时交付这个包的根目录,否则消费方解不开这些路径。 + * - 描述来自 frontmatter,是消费方判断「该不该翻开这一页」的唯一依据; + * 缺失时应当跳过整个包并告警。 + * - 多个来源(用户级目录、项目级目录、依赖自带)会汇合成一份清单。 + * 同名冲突必须让使用者知道,不能悄悄决定用哪一份。 + */ + +export interface GuidePack { + name: string; + description: string; + /** 这个包所在目录的绝对路径。 */ + baseDir: string; + body: string; +} + +export interface RawPack { + dirName: string; + baseDir: string; + frontmatter: Record; + body: string; +} + +export function toPack(raw: RawPack): GuidePack { + return { + name: raw.frontmatter.name ?? raw.dirName, + description: raw.frontmatter.description ?? raw.dirName, + baseDir: raw.baseDir, + body: raw.body, + }; +} + +/** 交给消费方的正文。 */ +export function loadGuide(pack: GuidePack): string { + return pack.body.trim(); +} + +/** 汇合多来源的清单。 */ +export function collect(sources: readonly RawPack[][]): GuidePack[] { + const byName = new Map(); + for (const source of sources) { + for (const raw of source) { + const pack = toPack(raw); + byName.set(pack.name, pack); + } + } + return [...byName.values()]; +} `, "src/prompt/toolCatalog.ts": `/** * 拼装喂给模型的系统前缀。 diff --git a/packages/kernel/test/live/skills.verify.live.test.ts b/packages/kernel/test/live/skills.verify.live.test.ts new file mode 100644 index 0000000..ad5398e --- /dev/null +++ b/packages/kernel/test/live/skills.verify.live.test.ts @@ -0,0 +1,226 @@ +// 真机验证:skill 三来源在**真实 helios.config.json** 下真的接通,且模型真会用它。 +// +// 为什么单测不够:本轮改的大半是模型可见文本(工具 description、加载时前置的 +// base directory、base prompt 里讲 skill 那条)。单测只能断言「这段字长这样」, +// 断言不了「模型看到这段字之后会不会主动调、调完解不解得开相对路径」。 +// 上一轮 extension 清单就差点被骗过去——live eval 的 manifest 没有 extensions 段, +// 35/36 全绿却完全没跑到新代码。 +// +// 与 code-review.eval.live.test.ts 的分工:那个评模型的审查能力,这个只验链路。 + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AskQuestionRequest, AskQuestionResponse, Logger, Message } from "@helios/ports"; +import { Kernel, type Manifest } from "../../src/index"; + +const REPO_ROOT = new URL("../../../../", import.meta.url).pathname; +const BASE_URL = process.env.HELIOS_LIVE_BASE_URL ?? "http://localhost:8788/v1"; +const API_KEY = process.env.HELIOS_LIVE_API_KEY ?? "local-gateway"; +const PREFERRED_MODELS = ["deepseek-v4-pro-0813-ali", "deepseek-v4-flash-0731-ali", "glm-5.3", "kimi-k3"]; + +const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; +const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); + +async function supportsChatCompletions(model: string): Promise { + try { + const res = await fetch(`${BASE_URL}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${API_KEY}` }, + body: JSON.stringify({ model, max_tokens: 8, messages: [{ role: "user", content: "hi" }] }), + signal: AbortSignal.timeout(60_000), + }); + if (!res.ok) return false; + const body = (await res.json()) as { error?: unknown; choices?: unknown[] }; + return body.error === undefined && Array.isArray(body.choices); + } catch { + return false; + } +} + +async function probeGateway(): Promise { + let ids = new Set(); + try { + const res = await fetch(`${BASE_URL}/models`, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return undefined; + const listed = (await res.json()) as { data?: Array<{ id?: string }> }; + ids = new Set((listed.data ?? []).map((m) => m.id).filter((id): id is string => typeof id === "string")); + } catch { + return undefined; + } + const candidates = [ + ...(process.env.HELIOS_LIVE_MODEL ? [process.env.HELIOS_LIVE_MODEL] : []), + ...PREFERRED_MODELS.filter((m) => ids.has(m)), + ]; + for (const model of candidates) if (await supportsChatCompletions(model)) return model; + return undefined; +} + +const model = await probeGateway(); +const gate = model ? describe : describe.skip; + +/** 读仓库真实的 manifest,只把 LLM 的 model 换成探测到的那个。 */ +async function realManifest(): Promise { + const raw = JSON.parse(await readFile(join(REPO_ROOT, "helios.config.json"), "utf8")) as Manifest; + for (const entry of raw.ports) { + if (entry.port === "LLMProvider") { + entry.options = { ...entry.options, baseURL: BASE_URL, apiKey: API_KEY, model }; + } + } + return raw; +} + +function toolCallsIn(messages: Message[]): string[] { + const names: string[] = []; + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const block of m.content) { + if (block.type === "tool_use") names.push(block.name); + } + } + return names; +} + +/** + * 按工具名取它自己的返回值(tool_use.id → tool_result.toolUseId 配对)。 + * + * 不能笼统地把**全部** tool_result 拼起来再查关键字:模型完全可能用 Glob 找到同一个路径, + * 于是「skill 有没有告诉我 base directory」这条断言被一个无关工具的输出满足了。 + * 真机变异实测逃逸过一次,就是这个原因。 + * + * 字段是 `output`(不是 anthropic 线上协议的 `content`)——这里也读错过一次。 + */ +function resultsOfTool(messages: Message[], toolName: string): string { + const ids = new Set(); + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const b of m.content) { + if (b.type === "tool_use" && b.name === toolName) ids.add(b.id); + } + } + const chunks: string[] = []; + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const b of m.content) { + if (b.type === "tool_result" && ids.has(b.toolUseId)) { + chunks.push(typeof b.output === "string" ? b.output : JSON.stringify(b.output)); + } + } + } + return chunks.join("\n"); +} + +function allToolResults(messages: Message[]): string { + const chunks: string[] = []; + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const b of m.content) { + if (b.type === "tool_result") { + chunks.push(typeof b.output === "string" ? b.output : JSON.stringify(b.output)); + } + } + } + return chunks.join("\n"); +} + +gate(`skill 真机链路(model=${model})`, () => { + let workDir: string; + let userDir: string; + let kernel: Kernel; + + beforeAll(async () => { + workDir = await mkdtemp(join(tmpdir(), "helios-skillverify-")); + userDir = await mkdtemp(join(tmpdir(), "helios-skilluser-")); + + // 项目级 skill:带脚本,说明书里只写相对路径 —— 模型必须靠 base directory 才解得开。 + const proj = join(workDir, ".helios/skills/release-notes"); + await mkdir(join(proj, "scripts"), { recursive: true }); + await writeFile( + join(proj, "SKILL.md"), + [ + "---", + "name: release-notes", + "description: 生成本仓库的发布说明。需要汇总某个版本的改动时使用这个流程。", + "---", + "", + "# 生成发布说明", + "", + "严格按顺序做,不要自己发明步骤:", + "", + "1. 用 Bash 跑 `node scripts/collect.mjs`,把它的输出原样记下来", + "2. 把第 1 步的输出作为发布说明的正文交给用户", + "", + ].join("\n"), + "utf8", + ); + await writeFile( + join(proj, "scripts", "collect.mjs"), + "console.log('RELEASE-LINE-42');\n", + "utf8", + ); + + // 用户级 skill:只验注册,不验调用。 + await mkdir(join(userDir, "skills/lint-fix"), { recursive: true }); + await writeFile( + join(userDir, "skills/lint-fix/SKILL.md"), + "---\ndescription: 修 lint 报错的标准流程\n---\n\n跑 pnpm lint --fix\n", + "utf8", + ); + + kernel = new Kernel({ + workDir, + manifest: await realManifest(), + logger: silent, + globalInstructionDir: userDir, + }); + await kernel.start(); + }, 120_000); + + afterAll(async () => { + await kernel?.dispose(); + await rm(workDir, { recursive: true, force: true }); + await rm(userDir, { recursive: true, force: true }); + }); + + it("真实 manifest 下:两个来源的 skill 都注册成无前缀的工具,description 来自 frontmatter", () => { + const names = kernel.listTools(); + expect(names).toContain("release-notes"); // 项目级,经 @helios/cap-skills + expect(names).toContain("lint-fix"); // 用户级,kernel 自己扫 + expect(names.filter((n) => n.startsWith("skills__"))).toEqual([]); + expect(kernel.getTool("release-notes")?.description).toContain("发布说明"); + }); + + it( + "模型主动调 skill、拿到 base directory、并据此跑通了相对路径的脚本", + async () => { + const session = kernel.createSession({ askQuestion: noAsk }); + const out = await session.sendMessage( + "帮我生成这个仓库的发布说明。先看看有没有现成的流程可用,别自己发明步骤。", + ); + const calls = toolCallsIn(out); + // eslint-disable-next-line no-console + console.log(`[verify:skills] 调过的工具 = ${JSON.stringify(calls)}`); + + // ① 模型看了 description 之后主动翻开了这一页 + expect(calls).toContain("release-notes"); + // ② **skill 工具自己**的返回值里前置了 base directory。 + // 注意只看这一个工具的输出:查全部 tool_result 会被模型顺手跑的 Glob 满足。 + expect(resultsOfTool(out, "release-notes")).toContain(".helios/skills/release-notes"); + // ③ 相对路径真的解开了:脚本跑起来、输出进了上下文 + expect(allToolResults(out)).toContain("RELEASE-LINE-42"); + }, + 600_000, + ); + + it("system 前缀:讲 skill 那条不靠命名规律,合成的 skills capability 不进 ", async () => { + const session = kernel.createSession({ askQuestion: noAsk }); + await session.sendMessage("说一句「好」就行。"); + const prefix = (session as unknown as { systemPrefix: string | null }).systemPrefix ?? ""; + expect(prefix).toContain("Some tools are skills"); + expect(prefix).not.toContain("caps__"); + expect(prefix).not.toContain("- skills:"); + // 真实 manifest 里唯一的 extension 是 cap-skills,它的 EXTENSION.md 描述该在清单里。 + expect(prefix).toContain("- skills-project:"); + }, 300_000); +}); diff --git a/packages/kernel/test/p1-impls.test.ts b/packages/kernel/test/p1-impls.test.ts index deaa376..80848c3 100644 --- a/packages/kernel/test/p1-impls.test.ts +++ b/packages/kernel/test/p1-impls.test.ts @@ -14,7 +14,7 @@ import * as memoryFs from "@helios/memory-fs"; import * as checkpointFs from "@helios/checkpoint-fs"; import * as compactDefault from "@helios/compact-default"; import * as teamsMailbox from "@helios/teams-mailbox"; -import * as capabilityFs from "@helios/capability-fs"; +import * as capSkills from "@helios/cap-skills"; const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; @@ -131,21 +131,50 @@ describe("@helios/teams-mailbox", () => { }); }); -describe("@helios/capability-fs", () => { - it("扫描 SKILL.md 产出工具,调用返回全文", async () => { - await mkdir(join(workDir, ".helios/policies/demo"), { recursive: true }); - await writeFile(join(workDir, ".helios/policies/demo/SKILL.md"), "# Demo Skill\ncontent", "utf8"); +describe("@helios/cap-skills", () => { + async function writeSkill(dir: string, text: string): Promise { + await mkdir(join(workDir, ".helios/skills", dir), { recursive: true }); + await writeFile(join(workDir, ".helios/skills", dir, "SKILL.md"), text, "utf8"); + } + + it("扫 .helios/skills 交出 AgentSkill:描述取 frontmatter,baseDir 是目录绝对路径", async () => { + await writeSkill("demo", "---\ndescription: 迁移数据库\n---\n\n# Demo\ncontent"); const ctx = ctxFor(workDir); - const cap = capabilityFs.create(ctx); + const cap = capSkills.create(ctx); await cap.activate(ctx); - const tools = cap.getTools!(); - expect(tools.map((t) => t.name)).toContain("demo"); - const res = await tools.find((t) => t.name === "demo")!.execute({}, { - workDir, - sessionId: "s1", - logger: silent, - askQuestion: async () => ({ answers: [] }), - }); - expect(res.output).toContain("Demo Skill"); + const skills = await cap.getSkills!(); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe("demo"); + expect(skills[0]!.description).toBe("迁移数据库"); + expect(skills[0]!.baseDir).toBe(join(workDir, ".helios/skills/demo")); + expect(skills[0]!.source).toEqual({ kind: "project", name: "demo" }); + // 正文不含 frontmatter:那几行是给 kernel 看的元数据,塞进上下文只是噪音。 + const body = await skills[0]!.load(); + expect(body).toContain("# Demo"); + expect(body).not.toContain("description:"); + }); + + it("frontmatter 的 name 覆盖目录名", async () => { + await writeSkill("dir-name", "---\nname: real-name\ndescription: d\n---\nbody"); + const ctx = ctxFor(workDir); + const cap = capSkills.create(ctx); + const skills = await cap.getSkills!(); + expect(skills.map((s) => s.name)).toEqual(["real-name"]); + }); + + it("缺 description 的目录整个跳过,不用目录名兜一个", async () => { + await writeSkill("nodesc", "# 没有 frontmatter\ncontent"); + await writeSkill("empty", "---\ndescription:\n---\nbody"); + await writeSkill("ok", "---\ndescription: 有描述\n---\nbody"); + const ctx = ctxFor(workDir); + const cap = capSkills.create(ctx); + const skills = await cap.getSkills!(); + expect(skills.map((s) => s.name)).toEqual(["ok"]); + }); + + it("它不渲染工具:渲染只有 kernel 一个出口", async () => { + await writeSkill("demo", "---\ndescription: d\n---\nbody"); + const cap = capSkills.create(ctxFor(workDir)); + expect(cap.getTools).toBeUndefined(); }); }); diff --git a/packages/kernel/test/prompt.test.ts b/packages/kernel/test/prompt.test.ts index c69d98b..13abadd 100644 --- a/packages/kernel/test/prompt.test.ts +++ b/packages/kernel/test/prompt.test.ts @@ -42,8 +42,10 @@ describe("BASE_SYSTEM_PROMPT", () => { ]) { expect(BASE_SYSTEM_PROMPT).toContain(heading); } - // caps__ 是 capability-fs 真实注册的工具前缀,可以提。 - expect(BASE_SYSTEM_PROMPT).toContain("caps__"); + // skill 真实存在,可以提。但**不能靠工具名的命名规律**去提(曾断言 `caps__`): + // skill 的工具名由作者在 frontmatter 里定、且豁免命名空间前缀,没有前缀可依赖。 + expect(BASE_SYSTEM_PROMPT).toContain("skills"); + expect(BASE_SYSTEM_PROMPT).not.toContain("caps__"); // helios 没有这些机制,提了就是指向不存在的能力。 expect(BASE_SYSTEM_PROMPT).not.toContain("plan mode"); expect(BASE_SYSTEM_PROMPT).not.toContain("system-reminder"); diff --git a/packages/ports/src/capability.ts b/packages/ports/src/capability.ts index f61cdeb..d0458d5 100644 --- a/packages/ports/src/capability.ts +++ b/packages/ports/src/capability.ts @@ -1,4 +1,5 @@ import type { KernelContext, Tool } from "./types"; +import type { AgentSkill } from "./skill"; export const CAPABILITY_API_VERSION = 1; @@ -31,5 +32,14 @@ export interface Capability { readonly description: string; activate(ctx: KernelContext): void | Promise; getTools?(): Tool[]; + /** + * 本 capability 自带的 skill(目录形态的知识包)。 + * + * 与 `getTools()` 分开而不是自己拼成工具:处理一个 skill 需要一整套动作—— + * 校验 description 非空、前置 base directory、按出身注册命名空间。每个想带 skill 的 + * 包各写一遍就是 N 份拷贝,且各写各的(有的告诉 base dir 有的不告诉),模型行为会飘。 + * 交出数据,渲染由 kernel 统一做。 + */ + getSkills?(): AgentSkill[] | Promise; dispose?(): void | Promise; } diff --git a/packages/ports/src/index.ts b/packages/ports/src/index.ts index 32f5d76..9f9dbd7 100644 --- a/packages/ports/src/index.ts +++ b/packages/ports/src/index.ts @@ -16,3 +16,4 @@ export * from "./toolResultCache"; export * from "./versionProvider"; export * from "./mount"; export * from "./agentSpec"; +export * from "./skill"; diff --git a/packages/ports/src/skill.ts b/packages/ports/src/skill.ts new file mode 100644 index 0000000..5f71222 --- /dev/null +++ b/packages/ports/src/skill.ts @@ -0,0 +1,65 @@ +/** + * skill:一个**目录**形态的知识包——SKILL.md 是说明书,旁边可以躺 `scripts/*.{mjs,py}` + * 与参考资料。模型读说明书,再用 Bash 去跑脚本,**脚本本身永不进上下文**, + * 这是它省 token 的原理。 + */ +export interface AgentSkill { + /** + * 工具名,**豁免命名空间前缀**(不会变成 `skills__foo`):名字是作者在 frontmatter + * 里起的。推论是 system prompt 讲 skill 时不能靠命名规律,只能靠 description。 + */ + name: string; + /** + * 来自 SKILL.md frontmatter,**必填非空**——这是模型判断「现在该不该翻开这一页」 + * 的唯一依据。曾经这里是 `加载 skill「x」的完整指引内容` 这种模板拼出来的话, + * 等于没说,模型永远不知道该调哪个。 + */ + description: string; + /** + * 脚本与资源的根(绝对路径)。加载时会前置告诉模型,否则 SKILL.md 里 + * `运行 scripts/analyze.py` 这类相对路径无处可解。 + */ + baseDir: string; + /** + * 出身。将来停用某个 extension 时按它过滤工具视图——**分目录解决不了这件事**, + * extension 自带的 skill 本来就不在任何用户目录下。 + */ + source: SkillSource; + /** + * 读出 SKILL.md 正文(不含 frontmatter)。 + * + * 用闭包而不是给一个路径让 kernel 自己读:三种来源的读法不同——项目级要过 + * `FileSystemPort` 的 workDir 守卫,extension 自带的在它自己的 npm 包里、 + * 根本不在 workDir 内。谁能读什么由生产者说了算。 + */ + load(): Promise; +} + +export interface SkillSource { + kind: "user" | "project" | "extension"; + /** extension 来源填包/capability 名;user / project 填目录来源标识。 */ + name: string; +} + +/** + * 极简 frontmatter 解析:`---` 包起来的 `key: value` 行。 + * + * 放在 ports 而不是各包自己抄一份:SKILL.md 与 EXTENSION.md 共用这个格式, + * 两份实现必然漂移。ports 本就有运行时导出(各 API_VERSION 常量),这里也零依赖。 + * + * 不引 YAML 库是刻意的:只需要一层 key-value,引一个解析器等于给这个格式 + * 悄悄开放了嵌套、锚点、多文档等一整套语义,将来收不回来。 + */ +export function parseFrontmatter(text: string): { + attrs: Record; + body: string; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text); + if (!match) return { attrs: {}, body: text }; + const attrs: Record = {}; + for (const line of match[1]!.split(/\r?\n/)) { + const kv = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line); + if (kv) attrs[kv[1]!] = kv[2]!.trim().replace(/^["']|["']$/g, ""); + } + return { attrs, body: match[2] ?? "" }; +} diff --git a/packages/ports/src/types.ts b/packages/ports/src/types.ts index e66c233..99d495e 100644 --- a/packages/ports/src/types.ts +++ b/packages/ports/src/types.ts @@ -99,8 +99,8 @@ export interface AskQuestionResponse { * 只放"所有工具天然都需要"的环境级能力(工作目录/日志/中断信号/人工提问)—— * 业务能力(文件系统、多智能体等具体 Port)不放在这里,而是由注册方在构造工具时 * 按需以闭包形式注入给该工具自己(接口隔离:拿不到的能力不会出现在共享上下文里, - * 不是"声明了就信任"的运行时校验,是结构上摸不到)。参考 `capability-fs` 的 - * `create(ctx) → new FsCapability(ctx.ports.fileSystem)` 与 `builtin/tools.ts` 的 + * 不是"声明了就信任"的运行时校验,是结构上摸不到)。参考 `cap-skills` 的 + * `create(ctx) → new SkillsCapability(ctx.ports.fileSystem, …)` 与 `builtin/tools.ts` 的 * `createReadTool(fileSystem)` 工厂函数模式。 */ export interface ToolContext { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2267213..04261ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,9 +23,9 @@ importers: apps/cli: dependencies: - '@helios/capability-fs': + '@helios/cap-skills': specifier: workspace:* - version: link:../../packages/capability-fs + version: link:../../packages/cap-skills '@helios/checkpoint-fs': specifier: workspace:* version: link:../../packages/checkpoint-fs @@ -65,9 +65,9 @@ importers: apps/electron: dependencies: - '@helios/capability-fs': + '@helios/cap-skills': specifier: workspace:* - version: link:../../packages/capability-fs + version: link:../../packages/cap-skills '@helios/checkpoint-fs': specifier: workspace:* version: link:../../packages/checkpoint-fs @@ -150,9 +150,9 @@ importers: apps/web: dependencies: - '@helios/capability-fs': + '@helios/cap-skills': specifier: workspace:* - version: link:../../packages/capability-fs + version: link:../../packages/cap-skills '@helios/checkpoint-fs': specifier: workspace:* version: link:../../packages/checkpoint-fs @@ -260,7 +260,7 @@ importers: specifier: ^3.23.8 version: 3.25.76 - packages/capability-fs: + packages/cap-skills: dependencies: '@helios/ports': specifier: workspace:* diff --git a/tsconfig.base.json b/tsconfig.base.json index 7cdab07..502db71 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -26,7 +26,7 @@ "@helios/checkpoint-fs": ["./packages/checkpoint-fs/src/index.ts"], "@helios/compact-default": ["./packages/compact-default/src/index.ts"], "@helios/teams-mailbox": ["./packages/teams-mailbox/src/index.ts"], - "@helios/capability-fs": ["./packages/capability-fs/src/index.ts"], + "@helios/cap-skills": ["./packages/cap-skills/src/index.ts"], "@helios/llm-openai": ["./packages/llm-openai/src/index.ts"], "@helios/checkpoint-git": ["./packages/checkpoint-git/src/index.ts"], "@helios/cap-cron": ["./packages/cap-cron/src/index.ts"], diff --git a/vitest.config.ts b/vitest.config.ts index e73b83b..6e23344 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,9 +24,9 @@ export default defineConfig({ __dirname, "packages/teams-mailbox/src/index.ts", ), - "@helios/capability-fs": resolve( + "@helios/cap-skills": resolve( __dirname, - "packages/capability-fs/src/index.ts", + "packages/cap-skills/src/index.ts", ), "@helios/llm-openai": resolve(__dirname, "packages/llm-openai/src/index.ts"), "@helios/checkpoint-git": resolve( From 7be222df1d6330e95bfc56a25e79a3e1e250f12e Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Mon, 31 Aug 2026 16:25:02 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(kernel):=20skill=20=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E4=B8=8D=E5=8F=AA=E6=8A=A5=20base=20directory=EF=BC=8C?= =?UTF-8?q?=E8=BF=98=E8=A6=81=E7=BB=99=E5=87=BA=E7=9B=B8=E5=AF=B9=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E7=9A=84=E8=A7=A3=E6=9E=90=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI 真机(本轮该跑没跑,补跑后逮到)暴露:只前置一行 `Base directory for this skill: ` **不足以**让模型把正文里的相对路径挂到那个目录上。 它把 `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 `);live 新增 `failedToolNames(out)` 应为空,直接编码「**一次就解对**,而不只是最终做成」。 变异:M14「退回只报一个路径」、M15「说了基准但不提 cd」单测均命中; M14 真机也命中(`expected [ 'Bash' ] to deeply equal []`)。 940 passed,typecheck / vitest / live 退出码均为 0。 --- packages/kernel/src/skills/tools.ts | 19 +++++++++++--- packages/kernel/test/agent-skills.test.ts | 15 +++++++++++ .../test/live/skills.verify.live.test.ts | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/kernel/src/skills/tools.ts b/packages/kernel/src/skills/tools.ts index 273acfc..19e7f7e 100644 --- a/packages/kernel/src/skills/tools.ts +++ b/packages/kernel/src/skills/tools.ts @@ -27,6 +27,21 @@ export function toSkillTools(skills: readonly AgentSkill[], logger: Logger): Too return tools; } +/** + * 前置给模型的根路径说明。 + * + * ⚠️ **只报一个路径是不够的**——CLI 真机 3/3 次都在第一个 Bash 上失败:模型把正文里的 + * `scripts/collect.mjs` 当成 cwd 相对(Bash 的 cwd 是 workDir),报了句「脚本不在 + * scripts/ 下」,然后靠 Glob 满仓找,白烧 4 个 turn。它需要的不是一个事实, + * 是一条**解析规则**,并且要点明 shell 命令得先 cd —— 这两句就是那条规则。 + */ +function baseDirNotice(baseDir: string): string { + return [ + `Base directory for this skill: ${baseDir}`, + `Every relative path in the instructions below resolves against that directory, not your current working directory. Run shell commands from it (\`cd ${baseDir} && …\`) and pass it as the prefix to file tools.`, + ].join("\n"); +} + function toTool(skill: AgentSkill): Tool { return { name: skill.name, @@ -35,9 +50,7 @@ function toTool(skill: AgentSkill): Tool { async execute() { try { const body = await skill.load(); - // base directory 必须前置:SKILL.md 里写「运行 scripts/analyze.py」时, - // 模型没有这一行就无处解这个相对路径,带脚本的 skill 直接不可用。 - return { output: `Base directory for this skill: ${skill.baseDir}\n\n${body.trim()}` }; + return { output: `${baseDirNotice(skill.baseDir)}\n\n${body.trim()}` }; } catch (err) { return { output: err instanceof Error ? err.message : String(err), diff --git a/packages/kernel/test/agent-skills.test.ts b/packages/kernel/test/agent-skills.test.ts index d395e35..58eac49 100644 --- a/packages/kernel/test/agent-skills.test.ts +++ b/packages/kernel/test/agent-skills.test.ts @@ -73,6 +73,21 @@ describe("toSkillTools", () => { expect(res.output).toContain("跑 scripts/a.py"); }); + it("不只报路径,还要说清相对路径以它为基准、shell 命令得先 cd", async () => { + // 光给一个路径**不够**:CLI 真机 3/3 次都在第一个 Bash 上失败,模型把 `scripts/a.py` + // 当 cwd 相对,然后靠 Glob 满仓找。它要的是解析规则,不是一个事实。 + const [tool] = toSkillTools([skill({ baseDir: "/x/y" })], silent); + const res = await tool!.execute({}, { + workDir: "/x", + sessionId: "s", + logger: silent, + askQuestion: noAsk, + }); + expect(res.output).toContain("resolves against that directory"); + expect(res.output).toContain("not your current working directory"); + expect(res.output).toContain("cd /x/y"); + }); + it("工具的 description 就是 frontmatter 里那句,不是模板拼的", () => { const [tool] = toSkillTools([skill({ description: "迁移数据库" })], silent); expect(tool!.description).toBe("迁移数据库"); diff --git a/packages/kernel/test/live/skills.verify.live.test.ts b/packages/kernel/test/live/skills.verify.live.test.ts index ad5398e..ab2d8d8 100644 --- a/packages/kernel/test/live/skills.verify.live.test.ts +++ b/packages/kernel/test/live/skills.verify.live.test.ts @@ -111,6 +111,27 @@ function resultsOfTool(messages: Message[], toolName: string): string { return chunks.join("\n"); } +/** 失败过的工具调用名。用来断言「模型一次就做对了」,而不只是「最终做成了」。 */ +function failedToolNames(messages: Message[]): string[] { + const nameById = new Map(); + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const b of m.content) { + if (b.type === "tool_use") nameById.set(b.id, b.name); + } + } + const failed: string[] = []; + for (const m of messages) { + if (typeof m.content === "string") continue; + for (const b of m.content) { + if (b.type === "tool_result" && b.isError === true) { + failed.push(nameById.get(b.toolUseId) ?? b.toolUseId); + } + } + } + return failed; +} + function allToolResults(messages: Message[]): string { const chunks: string[] = []; for (const m of messages) { @@ -209,6 +230,10 @@ gate(`skill 真机链路(model=${model})`, () => { expect(resultsOfTool(out, "release-notes")).toContain(".helios/skills/release-notes"); // ③ 相对路径真的解开了:脚本跑起来、输出进了上下文 expect(allToolResults(out)).toContain("RELEASE-LINE-42"); + // ④ **一次就解对了**,没有先撞墙再靠 Glob 满仓找。 + // 这条是 CLI 真机逼出来的:只报一个路径时模型把 scripts/… 当 cwd 相对, + // 3/3 次第一个 Bash 都失败,然后白烧 2~6 个 turn 找路——而 ①②③ 全绿。 + expect(failedToolNames(out)).toEqual([]); }, 600_000, ); From 3550658dfbdb3651f4cbf6e4e77b921f8582aced Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Mon, 31 Aug 2026 17:13:33 +0800 Subject: [PATCH 10/11] =?UTF-8?q?test(kernel):=20=E8=A1=A5=E3=80=8C?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E6=8C=82=E8=BD=BD=E6=81=92=E5=AE=9A=E6=8E=92?= =?UTF-8?q?=E5=9C=A8=E5=86=85=E7=BD=AE=E4=B9=8B=E5=90=8E=E3=80=8D=E6=8A=A4?= =?UTF-8?q?=E6=A0=8F=EF=BC=88=E6=AC=A0=E4=BA=86=E5=9B=9B=E8=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个装配根都靠最后一行 `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 预算对机器负载太敏感——非本轮引入,记一笔。 --- docs/code-organization.md | 26 ++ .../test/live/code-review.eval.live.test.ts | 25 ++ .../kernel/test/live/fixtures/evalProject.ts | 77 ++++++ .../unit/policies/pluginMountOrder.test.ts | 246 ++++++++++++++++++ 4 files changed, 374 insertions(+) create mode 100644 packages/kernel/test/unit/policies/pluginMountOrder.test.ts diff --git a/docs/code-organization.md b/docs/code-organization.md index 3e6b8f0..e73d606 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -192,6 +192,32 @@ skill 是**一个目录**:`SKILL.md` 是说明书,旁边可以躺 `scripts/* ⚠️ **payload 里永远不会有 `ports`。** 策略要什么 Port,构造期自己声明、由装配根注入; 拿不到的东西结构上就摸不到。给 mount 发一个 Port 注册表 = 把服务定位器换个地方请回来。 +### 插件挂载恒定排在内置之后 + +两个装配根(`buildDefaultMounts` 每 run / `buildSessionScopedMounts` 每会话)都在**最后** +才注册插件挂载。这条不是洁癖,两类消费方各有各的后果: + +| 挂载点合并语义 | 顺序错了会怎样 | +|---|---| +| `concat`(`context:prepare` / `session:start`) | 拼接顺序**模型可见** → 「装了哪个插件」决定提示词长什么样 | +| `firstWin`(`context:compact` / `tool:before` / `turn:end`) | 插件排前面就**整个顶掉内置** → 压缩该发生时不发生,上下文一路涨到 413 | + +护栏在 `test/unit/policies/pluginMountOrder.test.ts`(8 例,结构层 + 行为层), +四处变异全命中(run 级挪到最前 / 插到中间 / 会话级挪到 compaction 前 / `splitPluginMounts` 反转)。 + +⚠️ **写这类顺序护栏最容易写出空断言**:如果插件与内置**没挂在同一个挂载点上**, +「插件排在最后」恒成立,顺序怎么改都绿(第一版会话级就是这样,变异实测逃逸)。 +run 级同理——不传 `consumeNotifications` 时 `context:prepare` 上一个内置都没有。 +**先确认这个挂载点上真的既有内置又有插件,再断言相对位置。** + +⚠️ **`MountRegistry.dispatch` 把 handler 异常 warn 后跳过**,所以「内置赢了」与 +「内置炸了、插件顶上」在返回值上分不出来。断言 firstWin 时要同时收 warn +(第一版 payload 写错,内置抛 TypeError 被吞,`expect(out).toBeDefined()` 照样绿)。 + +**将来若真要让插件排到内置之前**:抄 Vite 的两档桶(`enforce: "pre" | "post"`,默认 post), +不要抄 VSCode 的数字优先级——那必然退化成 `order: 999999` 的军备竞赛。 +现在没有任何插件需要它,所以不加:为假想需求新增 API 表面的代价是永久的。 + ### 第三方怎么挂上去:`PluginModule.mounts()` 任何插件包(不限于 `Capability`)都能在模块上导出 `mounts(instance, ctx)` diff --git a/packages/kernel/test/live/code-review.eval.live.test.ts b/packages/kernel/test/live/code-review.eval.live.test.ts index 763735a..330b1da 100644 --- a/packages/kernel/test/live/code-review.eval.live.test.ts +++ b/packages/kernel/test/live/code-review.eval.live.test.ts @@ -38,6 +38,8 @@ const LAYERING_DEFECTS = ["core-imports-adapter", "core-does-io", "rule-in-adapt const DUPLICATION_DEFECTS = ["duplicated-request-builder", "drifted-default", "silent-optional-drop"]; /** 第九类:加载器漏掉了解释产物所必需的上下文,或在多来源汇合时静默丢弃一方。 */ const LOADER_DEFECTS = ["missing-base-dir", "silent-source-override", "fabricated-description"]; +/** 第十类:一个 push 循环放错位置。顺序不进任何返回值,判据只在消费端怎么用这个数组。 */ +const ORDER_DEFECTS = ["extension-before-builtin", "firstwin-hijacked", "handler-error-swallowed"]; const silent: Logger = { debug() {}, info() {}, warn() {}, error() {} }; const noAsk = async (_r: AskQuestionRequest): Promise => ({ answers: ["允许"] }); @@ -329,6 +331,29 @@ gate(`代码审查 eval(网关 ${BASE_URL} · 模型 ${model})`, () => { 600_000, ); + it( + "注册顺序专项:每行代码单独看都对,错在一个 push 循环放错了位置", + async () => { + // 第十种阅读方式,也是最不像 bug 的一类:顺序不进任何返回值、不进任何类型。 + // 判据完全在**消费端**——同一个顺序错误,对 join 型消费者是「外部看到的文本变了」, + // 对 firstWin 型消费者是「内置逻辑根本不执行」,严重程度差一个数量级。 + // 所以提示词要把模型推向「谁在用这个数组、怎么用」,而不是「这段代码对不对」。 + const session = kernel.createSession({ askQuestion: noAsk }); + const out = await session.sendMessage( + [ + "只看 src/hooks/registry.ts。文件头 JSDoc 写了三条约定。", + "build 造出的数组会被 runAll 和 resolveOnce 两个消费方用,用法不同。", + "逐条检查这三条约定有没有被违反,并说清每处违反对**这两个消费方各自**意味着什么。", + "给出函数名和原因。只读,不要改任何文件。", + ].join(""), + ); + const found = detectedDefects(reviewText(out)); + report("registration-order-review", found); + expect(found.filter((id) => ORDER_DEFECTS.includes(id)).length).toBeGreaterThanOrEqual(1); + }, + 600_000, + ); + it( "派生 agent + fork_context:子带着父的上下文开工,且不产生悬空 tool_use", async () => { diff --git a/packages/kernel/test/live/fixtures/evalProject.ts b/packages/kernel/test/live/fixtures/evalProject.ts index 99fe5f3..8eab8c4 100644 --- a/packages/kernel/test/live/fixtures/evalProject.ts +++ b/packages/kernel/test/live/fixtures/evalProject.ts @@ -339,6 +339,31 @@ export const DEFECTS: Defect[] = [ summary: "描述缺失时用目录名兜一个,等于挂了个永远不会被正确选中的条目,比直接跳过更糟", }, + // --- 注册顺序即语义类。共同的形状是:一个「往数组里 push」的循环放错了位置。 + // 每一行代码单独看都对,类型也全对,测试也全绿——因为顺序不进任何返回值。 + // 判据只在**消费端怎么用这个数组**:拼接给外部看(顺序可见)、还是取第一个就停 + // (顺序决定谁生效)。看不到消费端就无从判断这是不是 bug。 + { + id: "extension-before-builtin", + file: "src/hooks/registry.ts", + signals: [["扩展", "内置", "之前"], ["第三方", "内置", "前"], ["注册顺序", "扩展"]], + summary: + "build 把 extensions 的 push 放在内置 push 之前,违反文件头写死的「扩展恒定排在内置之后」;对 join 型消费者意味着外部可见的文本顺序由装了哪个扩展决定", + }, + { + id: "firstwin-hijacked", + file: "src/hooks/registry.ts", + signals: [["第一个", "短路"], ["firstwin", "顶"], ["resolve", "顺序"], ["顶掉", "内置"]], + summary: + "resolveOnce 取第一个非空结果就返回,配上顺序颠倒的注册表,扩展会把内置处理器整个顶掉——内置逻辑不是「顺序变了」而是根本不执行", + }, + { + id: "handler-error-swallowed", + file: "src/hooks/registry.ts", + signals: [["静默", "吞"], ["异常", "跳过", "日志"], ["catch", "空"], ["分不出", "失败"]], + summary: + "runAll 的 catch 里什么都不做,处理器抛异常与「处理器正常返回空」在调用方看来完全一样,坏掉的内置逻辑会被误判为「让位给了扩展」", + }, ]; /** 文件名 → 内容。写进临时 workDir 供 agent 审查。 */ @@ -801,6 +826,58 @@ export class Registry { return this.plugins.map((p) => p.run(input)); } } +`, + "src/hooks/registry.ts": `/** + * 处理器注册表。 + * + * ## 约定 + * + * - 一个位置上可以挂多个处理器,**注册顺序即执行顺序**。 + * - **扩展交出的处理器恒定排在内置之后。** 有两个消费方依赖这一点: + * \`runAll\` 把各处理器的输出按顺序拼成一段给外部看的文本(顺序外部可见), + * \`resolveOnce\` 取第一个非空结果就停(顺序决定谁生效)。 + * 让扩展排到前面,等于让「装了哪个扩展」决定外部看到什么、内置逻辑还跑不跑。 + * - 单个处理器抛异常不该打断其余处理器,但**必须留下痕迹**:否则调用方分不出 + * 「它正常地没有产出」和「它炸了」。 + */ + +export interface Handler { + name: string; + run(input: string): string | undefined; +} + +export interface Registry { + handlers: Handler[]; +} + +export function build(builtins: readonly Handler[], extensions: readonly Handler[]): Registry { + const handlers: Handler[] = []; + for (const h of extensions) handlers.push(h); + for (const h of builtins) handlers.push(h); + return { handlers }; +} + +/** 把所有处理器的输出拼成一段文本交给外部。 */ +export function runAll(registry: Registry, input: string): string { + const parts: string[] = []; + for (const h of registry.handlers) { + try { + const out = h.run(input); + if (out !== undefined) parts.push(out); + } catch { + } + } + return parts.join("\\n"); +} + +/** 取第一个给出结果的处理器,后面的不再调用。 */ +export function resolveOnce(registry: Registry, input: string): string | undefined { + for (const h of registry.handlers) { + const out = h.run(input); + if (out !== undefined) return out; + } + return undefined; +} `, "src/skills/loader.ts": `/** * 指引包(guide pack)加载器。 diff --git a/packages/kernel/test/unit/policies/pluginMountOrder.test.ts b/packages/kernel/test/unit/policies/pluginMountOrder.test.ts new file mode 100644 index 0000000..b0887f6 --- /dev/null +++ b/packages/kernel/test/unit/policies/pluginMountOrder.test.ts @@ -0,0 +1,246 @@ +// 「插件自带的挂载恒定排在内置之后」——这条至今只写在 buildDefaultMounts / +// buildSessionScopedMounts 的注释里,本文件是它的第一份护栏。 +// +// ⚠️ 为什么坏了会零症状:`context:prepare` 与 `session:start` 是 concat 语义, +// 拼接顺序**模型可见**。谁把那行 `for (const bundle of opts.pluginMounts)` 挪到内置之前, +// typecheck 全绿、全部单测全绿、agent 照常跑完——只是「装了哪个插件」开始决定提示词 +// 长什么样。与 skill 的 base directory 是同一类缺陷:产物能用,但喂给模型的字变了。 +// +// 两层都做: +// 结构层 —— describe() 里插件名在全部内置名之后(挪一行就红) +// 行为层 —— concat 真拼出来的文本里插件那段在内置那段之后(换个实现也红) +// 只有结构层挡不住「改用别的方式拼装但顺序又错了」。 + +import { describe, expect, it } from "vitest"; +import type { + CostMeterPort, + Message, + ModelRouterPort, + MountBundle, + ToolResultCachePort, + VersionProviderPort, +} from "@helios/ports"; +import { + buildDefaultMounts, + buildSessionScopedMounts, + splitPluginMounts, +} from "../../../src/policies"; +import { HookRunner } from "../../../src/hookRunner"; +import { memFs } from "./toolPayloads"; + +const silent = { debug() {}, info() {}, warn() {}, error() {} }; + +/** 内置 run 级策略要用到的最小 Port 组。都不做事,只要能被调用。 */ +function runPorts() { + return { + modelRouter: { async route() { + return undefined; + } } as unknown as ModelRouterPort, + costMeter: { + async onLLMCall() {}, + async onToolCall() {}, + async report() { + return undefined; + }, + } as unknown as CostMeterPort, + toolCache: { + async get() { + return undefined; + }, + async set() {}, + } as unknown as ToolResultCachePort, + versionProvider: { async get() { + return undefined; + } } as unknown as VersionProviderPort, + fileSystem: memFs().port, + }; +} + +// 两个装配根收的都是**已由 splitPluginMounts 拆过**的 bundle,所以 fixture 也得分作用域造。 +// (第一版混着造,直接撞上 scopeChecked 抛错——那道校验本身是对的。) +// +// ⚠️ fixture **不加 `as unknown as MountBundle`**:第一版加了,于是把 `run` 写成 +// `handler` 时 TS 一声不响,跑起来 `mount.run` 是 undefined → TypeError → 被 +// `dispatch` 的 try/catch 静默吞掉(没传 logger)→ 表现成"返回值是 undefined"。 +// 排查了一轮才定位到字段名。**给 fixture 上 as unknown 就是自己关掉这道检查。** + +/** run 级插件 bundle:挂 `context:prepare`(concat,模型可见)。 */ +function runBundle(name: string): MountBundle { + return { + name, + mounts: [{ at: "context:prepare", run: () => ({ append: [msg(`from-${name}`)] }) }], + }; +} + +/** 会话级插件 bundle:挂 `session:start`(concat,模型可见)。 */ +function sessionBundle(name: string): MountBundle { + return { + name, + mounts: [{ at: "session:start", run: () => ({ additionalContext: `ctx-${name}` }) }], + }; +} + +/** + * 挂 `context:compact` 的会话级插件——**会话级唯一与内置共享的挂载点**。 + * + * `context:compact` 是 firstWin,所以插件排在内置前面的后果不是"顺序变了", + * 是**内置压缩策略被整个顶掉**:模型该压缩的时候压不了,上下文一路涨到 413。 + */ +function compactBundle(name: string, onRun?: () => void): MountBundle { + return { + name, + mounts: [ + { + at: "context:compact", + run: () => { + onRun?.(); + return {}; + }, + }, + ], + }; +} + +/** 两个作用域都挂——只给 splitPluginMounts 用。 */ +function mixedBundle(name: string): MountBundle { + return { + name, + mounts: [...runBundle(name).mounts, ...sessionBundle(name).mounts], + }; +} + +function msg(text: string): Message { + return { id: text, role: "user", content: text }; +} + +/** 内置会话级只有 compactionPolicy,给它一副不触发压缩的最小依赖。 */ +function compactionDeps() { + return { + strategy: { + plan: () => ({ kind: "noop" as const }), + parseSummary: (t: string) => t, + }, + llm: { resolve: () => undefined }, + logger: silent, + } as unknown as Parameters[0]["compaction"]; +} + +describe("插件挂载恒定排在内置之后", () => { + describe("结构层:describe() 的顺序", () => { + it("run 级装配根:插件名排在全部内置名之后", () => { + const registry = buildDefaultMounts({ + ports: runPorts(), + hooks: new HookRunner(silent), + emit: () => {}, + // ⚠️ 必须传 consumeNotifications:不传时 `context:prepare` 上**一个内置都没有** + // (唯一的内置就是派生通知),插件成了独苗,"排在之后"这句话无从检验。 + // 第一版漏了它,被下面那道防空断言当场抓住。 + consumeNotifications: () => [], + pluginMounts: [runBundle("thirdparty")], + }); + const prepare = registry.describe()["context:prepare"] ?? []; + expect(prepare.length).toBeGreaterThan(1); // 否则这条断言是空的 + expect(prepare.at(-1)).toBe("thirdparty"); + }); + + it("会话级装配根:插件名排在内置之后", () => { + // ⚠️ 必须让插件挂在**和内置同一个挂载点**上。第一版让插件挂 `session:start`、 + // 内置只挂 `context:compact`,两者没有交集,于是 `at(-1)` 恒为插件名—— + // 把插件注册挪到 compaction 之前,测试照样全绿(变异 M3 实测逃逸)。 + // 「插件排在内置之后」这句话只有在共享挂载点上才有内容。 + const registry = buildSessionScopedMounts({ + compaction: compactionDeps(), + pluginMounts: [compactBundle("thirdparty")], + }); + expect(registry.describe()["context:compact"]).toEqual(["compaction", "thirdparty"]); + }); + + it("多个插件之间保持声明顺序(manifest 声明顺序即加载顺序)", () => { + const registry = buildDefaultMounts({ + ports: runPorts(), + hooks: new HookRunner(silent), + emit: () => {}, + pluginMounts: [runBundle("first"), runBundle("second")], + }); + const prepare = registry.describe()["context:prepare"] ?? []; + expect(prepare.slice(-2)).toEqual(["first", "second"]); + }); + }); + + describe("行为层:concat 真拼出来的顺序", () => { + it("context:prepare 拼出的消息里,插件注入的排在内置注入的之后", async () => { + // 内置在 context:prepare 上唯一会注入内容的是派生 agent 通知。 + const registry = buildDefaultMounts({ + ports: runPorts(), + hooks: new HookRunner(silent), + emit: () => {}, + consumeNotifications: () => [msg("from-builtin")], + pluginMounts: [runBundle("thirdparty")], + }); + const out = await registry.dispatch("context:prepare", { + runId: "r1", + turnId: "t1", + turnIndex: 0, + messages: [], + } as never); + const texts = (out?.append ?? []).map((m) => String(m.content)); + expect(texts).toEqual(["from-builtin", "from-thirdparty"]); + }); + + it("context:compact 是 firstWin:内置先胜出,插件顶不掉压缩策略", async () => { + // 这条是顺序错误在会话级的真实后果——不是"提示词顺序变了", + // 是压缩该发生时不发生,上下文一路涨到 413。 + const pluginCalls: string[] = []; + const registry = buildSessionScopedMounts({ + compaction: compactionDeps(), + pluginMounts: [compactBundle("greedy", () => pluginCalls.push("greedy"))], + }); + const warns: string[] = []; + await registry.dispatch( + "context:compact", + { sessionId: "s1", path: [], approxTokens: 0, force: false, lastLlmCallAt: 0 } as never, + { warn: (m) => warns.push(m) }, + ); + // 内置 compactionPolicy 先跑并给出结果(空 path → `{}`)→ firstWin 直接返回, + // 插件**根本没被调用**。断言"插件没跑"而不是比对返回值:两边都返回 `{}`, + // 比返回值分不出是谁给的。 + expect(pluginCalls).toEqual([]); + // ⚠️ 这条不是摆设:第一版 payload 写错,内置在 `p.path.length` 上抛了 TypeError, + // 被 dispatch 静默吞掉,于是**赢的是插件**——而当时那句 `expect(out).toBeDefined()` + // 照样通过。挂载点异常是 warn 后跳过,只看返回值分不出「内置赢了」和「内置死了」。 + expect(warns).toEqual([]); + }); + + it("session:start 的 additionalContext 同理:插件那段在内置那段之后", async () => { + // 会话级内置目前不往 session:start 注入内容,所以拿两个插件互相定位, + // 断言的是「concat 不会重排」这条不变量本身。 + const registry = buildSessionScopedMounts({ + compaction: compactionDeps(), + pluginMounts: [sessionBundle("early"), sessionBundle("late")], + }); + const out = await registry.dispatch("session:start", { + sessionId: "s1", + workDir: "/w", + } as never); + expect(String(out?.additionalContext)).toBe("ctx-early\n\nctx-late"); + }); + }); + + describe("splitPluginMounts 不改顺序", () => { + it("拆完两堆各自保持原声明顺序", () => { + const { session, run } = splitPluginMounts([mixedBundle("a"), mixedBundle("b")]); + expect(run.map((b) => b.name)).toEqual(["a", "b"]); + expect(session.map((b) => b.name)).toEqual(["a", "b"]); + }); + + it("拆不出东西的那一半不产生空 bundle(空名字只会污染排障日志)", () => { + const runOnly: MountBundle = { + name: "run-only", + mounts: [{ at: "turn:end", run: () => undefined }], + } as unknown as MountBundle; + const { session, run } = splitPluginMounts([runOnly]); + expect(run.map((b) => b.name)).toEqual(["run-only"]); + expect(session).toEqual([]); + }); + }); +}); From 950d23085c032de3aaccae6280e0308ecfb0f70f Mon Sep 17 00:00:00 2001 From: zhangzihao Date: Mon, 31 Aug 2026 18:00:00 +0800 Subject: [PATCH 11/11] =?UTF-8?q?test(kernel):=20derived-agent.live=20?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20pro=20=E4=BC=98=E5=85=88=EF=BC=8C=E6=B6=88?= =?UTF-8?q?=E6=8E=89=E5=8F=8D=E5=A4=8D=E4=B8=89=E6=AC=A1=E7=9A=84=E5=81=87?= =?UTF-8?q?=E7=BA=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补跑 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 的改动必须跑全套。 --- packages/kernel/test/live/derived-agent.live.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/kernel/test/live/derived-agent.live.test.ts b/packages/kernel/test/live/derived-agent.live.test.ts index 0aa7b15..3e043ed 100644 --- a/packages/kernel/test/live/derived-agent.live.test.ts +++ b/packages/kernel/test/live/derived-agent.live.test.ts @@ -16,9 +16,16 @@ import { Kernel, type Manifest } from "../../src/index"; const BASE_URL = process.env.HELIOS_LIVE_BASE_URL ?? "http://localhost:8788/v1"; /** 该网关无鉴权;仍要给 SDK 一个非空 key,否则 openai 客户端构造就抛。 */ const API_KEY = process.env.HELIOS_LIVE_API_KEY ?? "local-gateway"; +// ⚠️ pro 在前,与 code-review.eval / skills.verify 两个 live 文件一致。 +// +// 原本是 flash 优先(更快更便宜),全仓只有这一个文件如此,代价是它**三次以不同形态假红**: +// 一次「路径越界」、两次模型把 DSML 工具调用标记当纯文本吐出来。本文件断言的是 +// **代码属性**(数据沿 workflow DAG 流动、不产生悬空 tool_use),不是模型能力—— +// 让它跑在最不稳的模型上,等于让它因为与被测内容无关的原因变红。 +// flash 的耗时方差本身也大到不像省时间:同一用例实测 23.6s vs 142.8s。 const PREFERRED_MODELS = [ - "deepseek-v4-flash-0731-ali", "deepseek-v4-pro-0813-ali", + "deepseek-v4-flash-0731-ali", "glm-5.3", "kimi-k3", ];