Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
de9ec14
refactor(kernel): loop 的扩展面收成闭包,跨机制先后收进 harness 合成器
Aug 30, 2026
3a8b90d
feat(kernel): 插件可自带挂载点;capability 三处撞名收敛为 policy / MountBundle
Aug 30, 2026
2ca73e2
refactor(kernel): 抽出唯一的 run 组装点,修派生 agent 三处静默漏配
Aug 30, 2026
ce872f2
refactor(kernel): manifest 拆 ports / extensions 两段,补 requires 拓扑排序
Aug 30, 2026
92f6ae6
refactor(kernel): 删 CapabilityProvider.getHookHandlers,hook 的否决权只属于用户
Aug 30, 2026
19321d2
feat(kernel): 已装 extension 清单进 system 前缀,支持 EXTENSION.md 声明式描述
Aug 30, 2026
bee99f7
refactor(ports): CapabilityProvider → Capability,钉清 extension 与 capab…
Aug 30, 2026
85d3dcd
feat(ports,kernel): AgentSkill 契约 + 三来源汇合,capability-fs 重写为 cap-skills
Aug 31, 2026
7be222d
fix(kernel): skill 加载不只报 base directory,还要给出相对路径的解析规则
Aug 31, 2026
3550658
test(kernel): 补「插件挂载恒定排在内置之后」护栏(欠了四轮)
Aug 31, 2026
950d230
test(kernel): derived-agent.live 改用 pro 优先,消掉反复三次的假红
Aug 31, 2026
091bc2a
Merge pull request #55 from fujiwarazz/test/plugin-mount-order
fujiwarazz Aug 31, 2026
eb81c7f
Merge pull request #54 from fujiwarazz/feat/agent-skills
fujiwarazz Aug 31, 2026
f4ba171
Merge pull request #53 from fujiwarazz/refactor/capability-naming
fujiwarazz Aug 31, 2026
afbad21
Merge pull request #52 from fujiwarazz/feat/extension-listing
fujiwarazz Aug 31, 2026
e5533f0
Merge pull request #51 from fujiwarazz/refactor/hooks-are-user-authority
fujiwarazz Aug 31, 2026
45ecadc
Merge pull request #50 from fujiwarazz/refactor/manifest-ports-extens…
fujiwarazz Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*"
}
Expand Down
14 changes: 8 additions & 6 deletions apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
],
Expand All @@ -33,17 +33,19 @@ async function readManifest(workDir: string): Promise<Manifest> {
}

function resolveManifest(manifest: Manifest, workDir: string): Manifest {
const resolvePkg = <T extends { package: string }>(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 };
Expand Down
10 changes: 4 additions & 6 deletions apps/cli/test/cli.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 });
Expand Down
10 changes: 6 additions & 4 deletions apps/electron/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ const CODE_MODE = process.env.HELIOS_CODE_MODE === "1";
async function loadManifest(): Promise<Manifest> {
const raw = await readFile(CONFIG_PATH, "utf8");
const manifest = JSON.parse(raw) as Manifest;
const resolvePkg = <T extends { package: string }>(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) } : {}),
};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions apps/web/server/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,13 @@ const CONFIG_PATH = fileURLToPath(new URL("../../../helios.config.json", import.
async function loadManifest(): Promise<Manifest> {
const raw = await readFile(CONFIG_PATH, "utf8");
const manifest = JSON.parse(raw) as Manifest;
const resolvePkg = <T extends { package: string }>(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) } : {}),
};
}

Expand Down
2 changes: 1 addition & 1 deletion docs/PROJECT_INST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
100 changes: 100 additions & 0 deletions docs/[IP]loop-decoupling.md
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,106 @@ 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 —— 已在 P2.11 落地,见下。

### P2.11(已完成):命名收敛 + 插件自带挂载

**a) 三个 capability 撞车。** `CapabilityProvider`(插件契约)/ `kernel/src/capabilities/`
(port→mount 适配器)/ `cap-*` 包,共用一个词,讨论时永远要先问「你说的是哪个」。
保留 `CapabilityProvider`(它是对外契约、与包名一致),另两个改名:
目录 → `kernel/src/policies/`,函数 `xxxCapability` → `xxxPolicy`,
类型 `MountedCapability` → `MountBundle`。

> 后续修订:`CapabilityProvider` 已改名为 `Capability`——`getHookHandlers` 删掉之后,
> "Provider" 这半截不再指代任何东西。`capability-fs` 已随 `AgentSkill` 一并重写为
> `@helios/cap-skills`(只交出 skill、不再自己渲染工具)。上面保留当时的原名,是历史记录。

**b) `PluginModule.mounts(instance, ctx)`。** 任何插件包都能自己声明挂载,
kernel 用 `splitPluginMounts()` 按作用域拆进会话级 / run 级两个注册表。
插件挂载**恒定排在内置之后**(`context:prepare` 是 concat,拼接顺序模型可见)。

放在 `PluginModule` 而不是 `Capability` 上,因为想挂载的不只是插件——
判据是**Port 的方法签名有没有把调用时机钉死**。`memoryRecallPolicy` 因此从 kernel
**搬进了 `@helios/memory-fs`**:`recall(query)` 里没说何时调、结果去哪,
`session:start` + `<memory>` 标签那套是 MEMORY.md 这一派的策略,mem0 一条都用不上。

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

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

护栏:`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」
Expand Down
10 changes: 5 additions & 5 deletions docs/architecture-layers.md
Original file line number Diff line number Diff line change
@@ -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),
Expand All @@ -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__*
Expand Down Expand Up @@ -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 个 |
| 取法 | 按字段名取 | 遍历,每个吐一批工具 |
Expand Down Expand Up @@ -244,7 +244,7 @@ teammate 本就是独立会话、独立成本单元,各记各的账才是对

```
helios(容器一以贯之)
manifest → ServiceCollection → PortRegistry → CapabilityProvider.activate
manifest → ServiceCollection → PortRegistry → Capability.activate
→ ToolRegistry → Session → Agent → runTurnLoop

valos(三层手工递减,容器只管一层)
Expand Down
Loading
Loading